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
159 changes: 129 additions & 30 deletions src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

Large diffs are not rendered by default.

220 changes: 150 additions & 70 deletions src/TUnit.Core/Helpers/DataSourceFactories.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,80 +33,119 @@ namespace TUnit.Core.Helpers;
#endif
public static class DataSourceFactories
{
#pragma warning disable CS1998 // sync-only async iterators: deferred invocation, no await by design
/// <summary>Data member declared as a single (non-enumerable, non-task) value.</summary>
public static async IAsyncEnumerable<Func<Task<object?[]?>>> FromValue(Func<object?> invoke)
public static IAsyncEnumerable<Func<Task<object?[]?>>> FromValue(Func<object?> invoke)
=> FromValueCore(invoke, keepTupleRowsWhole: false);

/// <summary>
/// <see cref="FromValue(Func{object?})"/> that keeps a tuple row whole when the test takes a single tuple,
/// as the attribute's reflection path does.
/// </summary>
public static IAsyncEnumerable<Func<Task<object?[]?>>> FromValue(DataGeneratorMetadata dataGeneratorMetadata, Func<object?> invoke)
=> FromValueCore(invoke, KeepsTupleRowsWhole(dataGeneratorMetadata));

/// <summary>Data member declared as a synchronous enumerable.</summary>
public static IAsyncEnumerable<Func<Task<object?[]?>>> FromEnumerable(Func<object?> invoke)
=> FromEnumerableCore(invoke, keepTupleRowsWhole: false);

/// <summary>
/// <see cref="FromEnumerable(Func{object?})"/> that keeps a tuple row whole when the test takes a single tuple,
/// as the attribute's reflection path does.
/// </summary>
public static IAsyncEnumerable<Func<Task<object?[]?>>> FromEnumerable(DataGeneratorMetadata dataGeneratorMetadata, Func<object?> invoke)
=> FromEnumerableCore(invoke, KeepsTupleRowsWhole(dataGeneratorMetadata));

/// <summary>Data member declared as <c>Task&lt;T&gt;</c>.</summary>
public static IAsyncEnumerable<Func<Task<object?[]?>>> FromTask<T>(Func<Task<T>> invoke)
=> FromTaskCore(invoke, keepTupleRowsWhole: false);

/// <summary>
/// <see cref="FromTask{T}(Func{Task{T}})"/> that keeps a tuple row whole when the test takes a single tuple,
/// as the attribute's reflection path does.
/// </summary>
public static IAsyncEnumerable<Func<Task<object?[]?>>> FromTask<T>(DataGeneratorMetadata dataGeneratorMetadata, Func<Task<T>> invoke)
=> FromTaskCore(invoke, KeepsTupleRowsWhole(dataGeneratorMetadata));

/// <summary>Data member declared as <c>ValueTask&lt;T&gt;</c>.</summary>
public static IAsyncEnumerable<Func<Task<object?[]?>>> FromValueTask<T>(Func<ValueTask<T>> invoke)
=> FromValueTaskCore(invoke, keepTupleRowsWhole: false);

/// <summary>
/// <see cref="FromValueTask{T}(Func{ValueTask{T}})"/> that keeps a tuple row whole when the test takes a single tuple,
/// as the attribute's reflection path does.
/// </summary>
public static IAsyncEnumerable<Func<Task<object?[]?>>> FromValueTask<T>(DataGeneratorMetadata dataGeneratorMetadata, Func<ValueTask<T>> invoke)
=> FromValueTaskCore(invoke, KeepsTupleRowsWhole(dataGeneratorMetadata));

/// <summary>Data member declared as <c>IAsyncEnumerable&lt;T&gt;</c>.</summary>
public static IAsyncEnumerable<Func<Task<object?[]?>>> FromAsyncEnumerable<T>(Func<IAsyncEnumerable<T>> invoke)
=> FromAsyncEnumerableCore(invoke, keepTupleRowsWhole: false);

/// <summary>
/// <see cref="FromAsyncEnumerable{T}(Func{IAsyncEnumerable{T}})"/> that keeps a tuple row whole when the test takes a
/// single tuple, as the attribute's reflection path does.
/// </summary>
public static IAsyncEnumerable<Func<Task<object?[]?>>> FromAsyncEnumerable<T>(DataGeneratorMetadata dataGeneratorMetadata, Func<IAsyncEnumerable<T>> invoke)
=> FromAsyncEnumerableCore(invoke, KeepsTupleRowsWhole(dataGeneratorMetadata));

#pragma warning disable CS1998 // sync-only async iterators: deferred invocation, no await by design
private static async IAsyncEnumerable<Func<Task<object?[]?>>> FromValueCore(Func<object?> invoke, bool keepTupleRowsWhole)
{
var result = invoke();

yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(result));
yield return () => Task.FromResult<object?[]?>(ToRow(result, keepTupleRowsWhole));
}

/// <summary>Data member declared as a synchronous enumerable.</summary>
public static async IAsyncEnumerable<Func<Task<object?[]?>>> FromEnumerable(Func<object?> invoke)
private static async IAsyncEnumerable<Func<Task<object?[]?>>> FromEnumerableCore(Func<object?> invoke, bool keepTupleRowsWhole)
{
var result = invoke();

if (result is IEnumerable enumerable && !(result is string))
foreach (var row in RowsFromValue(result, keepTupleRowsWhole))
{
foreach (var item in enumerable)
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(item));
}
}
else
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(result));
yield return row;
}
}
#pragma warning restore CS1998

/// <summary>Data member declared as <c>Task&lt;T&gt;</c>.</summary>
public static async IAsyncEnumerable<Func<Task<object?[]?>>> FromTask<T>(Func<Task<T>> invoke)
private static async IAsyncEnumerable<Func<Task<object?[]?>>> FromTaskCore<T>(Func<Task<T>> invoke, bool keepTupleRowsWhole)
{
var result = invoke();

var taskResult = await result;
if (taskResult is IEnumerable enumerable && !(taskResult is string))
// A null task is a source with no rows, as the attribute's reflection path reads it, so SkipIfEmpty skips the test.
if (result is null)
{
foreach (var item in enumerable)
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(item));
}
yield break;
}
else

var taskResult = await result;
foreach (var row in RowsFromValue(taskResult, keepTupleRowsWhole))
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(taskResult));
yield return row;
}
}

/// <summary>Data member declared as <c>ValueTask&lt;T&gt;</c>.</summary>
public static async IAsyncEnumerable<Func<Task<object?[]?>>> FromValueTask<T>(Func<ValueTask<T>> invoke)
private static async IAsyncEnumerable<Func<Task<object?[]?>>> FromValueTaskCore<T>(Func<ValueTask<T>> invoke, bool keepTupleRowsWhole)
{
var result = invoke();

var taskResult = await result;
if (taskResult is IEnumerable enumerable && !(taskResult is string))
var taskResult = await invoke();
foreach (var row in RowsFromValue(taskResult, keepTupleRowsWhole))
{
foreach (var item in enumerable)
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(item));
}
}
else
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(taskResult));
yield return row;
}
}

/// <summary>Data member declared as <c>IAsyncEnumerable&lt;T&gt;</c>.</summary>
public static async IAsyncEnumerable<Func<Task<object?[]?>>> FromAsyncEnumerable<T>(Func<IAsyncEnumerable<T>> invoke)
private static async IAsyncEnumerable<Func<Task<object?[]?>>> FromAsyncEnumerableCore<T>(Func<IAsyncEnumerable<T>> invoke, bool keepTupleRowsWhole)
{
var result = invoke();

// A null source has no rows, as the attribute's reflection path reads it, so SkipIfEmpty skips the test.
if (result is null)
{
yield break;
}

await foreach (var item in result)
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(item));
yield return () => Task.FromResult<object?[]?>(ToRow(item, keepTupleRowsWhole));
}
}

Expand All @@ -115,25 +154,20 @@ public static class DataSourceFactories
public static async IAsyncEnumerable<Func<Task<object?[]?>>> FromInstanceValue(DataGeneratorMetadata dataGeneratorMetadata, Func<object, object?> invoke)
{
var result = invoke(GetRequiredInstance(dataGeneratorMetadata));
var keepTupleRowsWhole = KeepsTupleRowsWhole(dataGeneratorMetadata);

yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(result));
yield return () => Task.FromResult<object?[]?>(ToRow(result, keepTupleRowsWhole));
}

/// <summary>Instance data member declared as a synchronous enumerable.</summary>
public static async IAsyncEnumerable<Func<Task<object?[]?>>> FromInstanceEnumerable(DataGeneratorMetadata dataGeneratorMetadata, Func<object, object?> invoke)
{
var result = invoke(GetRequiredInstance(dataGeneratorMetadata));
var keepTupleRowsWhole = KeepsTupleRowsWhole(dataGeneratorMetadata);

if (result is IEnumerable enumerable && !(result is string))
{
foreach (var item in enumerable)
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(item));
}
}
else
foreach (var row in RowsFromValue(result, keepTupleRowsWhole))
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(result));
yield return row;
}
}
#pragma warning restore CS1998
Expand All @@ -142,56 +176,57 @@ public static class DataSourceFactories
public static async IAsyncEnumerable<Func<Task<object?[]?>>> FromInstanceTask<T>(DataGeneratorMetadata dataGeneratorMetadata, Func<object, Task<T>> invoke)
{
var result = invoke(GetRequiredInstance(dataGeneratorMetadata));
var keepTupleRowsWhole = KeepsTupleRowsWhole(dataGeneratorMetadata);

var taskResult = await result;
if (taskResult is IEnumerable enumerable && !(taskResult is string))
// A null task is a source with no rows, as the attribute's reflection path reads it, so SkipIfEmpty skips the test.
if (result is null)
{
foreach (var item in enumerable)
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(item));
}
yield break;
}
else

var taskResult = await result;
foreach (var row in RowsFromValue(taskResult, keepTupleRowsWhole))
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(taskResult));
yield return row;
}
}

/// <summary>Instance data member declared as <c>ValueTask&lt;T&gt;</c>.</summary>
public static async IAsyncEnumerable<Func<Task<object?[]?>>> FromInstanceValueTask<T>(DataGeneratorMetadata dataGeneratorMetadata, Func<object, ValueTask<T>> invoke)
{
var result = invoke(GetRequiredInstance(dataGeneratorMetadata));
var keepTupleRowsWhole = KeepsTupleRowsWhole(dataGeneratorMetadata);

var taskResult = await result;
if (taskResult is IEnumerable enumerable && !(taskResult is string))
foreach (var row in RowsFromValue(taskResult, keepTupleRowsWhole))
{
foreach (var item in enumerable)
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(item));
}
}
else
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(taskResult));
yield return row;
}
}

/// <summary>Instance data member declared as <c>IAsyncEnumerable&lt;T&gt;</c>.</summary>
public static async IAsyncEnumerable<Func<Task<object?[]?>>> FromInstanceAsyncEnumerable<T>(DataGeneratorMetadata dataGeneratorMetadata, Func<object, IAsyncEnumerable<T>> invoke)
{
var result = invoke(GetRequiredInstance(dataGeneratorMetadata));
var keepTupleRowsWhole = KeepsTupleRowsWhole(dataGeneratorMetadata);

// A null source has no rows, as the attribute's reflection path reads it, so SkipIfEmpty skips the test.
if (result is null)
{
yield break;
}

await foreach (var item in result)
{
yield return () => Task.FromResult<object?[]?>(DataSourceHelpers.ToObjectArray(item));
yield return () => Task.FromResult<object?[]?>(ToRow(item, keepTupleRowsWhole));
}
}

/// <summary>
/// A data source field, or a private or protected property that the generated code reads by reflection, whose
/// declared type does not say whether it holds one row or many, such as <c>object</c>. The value's runtime type decides,
/// as it does when <see cref="MethodDataSourceAttribute"/> has no factory and reads the member itself.
/// A null value is one row holding null, as <see cref="FromValue"/> gives.
/// A null value is one row holding null, as <see cref="FromValue(Func{object?})"/> gives.
/// </summary>
public static async IAsyncEnumerable<Func<Task<object?[]?>>> FromRuntimeValue(DataGeneratorMetadata dataGeneratorMetadata, Func<object?> invoke)
{
Expand Down Expand Up @@ -226,6 +261,51 @@ public static class DataSourceFactories
// nothing instead would become an empty row, so the test would fail for a missing argument.
private static readonly Func<Task<object?[]?>> NullValueRow = static () => Task.FromResult<object?[]?>([null]);

// Splits a data member's value into rows the way FromEnumerable always has: an enumerable other than a string gives
// one row per item, anything else is one row.
private static IEnumerable<Func<Task<object?[]?>>> RowsFromValue(object? value, bool keepTupleRowsWhole)
{
if (value is IEnumerable enumerable && !(value is string))
{
foreach (var item in enumerable)
{
yield return () => Task.FromResult<object?[]?>(ToRow(item, keepTupleRowsWhole));
}
}
else
{
yield return () => Task.FromResult<object?[]?>(ToRow(value, keepTupleRowsWhole));
}
}

// When the test takes a single tuple, the attribute's reflection path (ToObjectArrayWithTypes) passes a tuple row
// as that one argument instead of splitting it into elements. Splitting would lose the difference between a null
// row and a one-element tuple holding null, such as Tuple.Create<string?>(null): both become [null].
private static bool KeepsTupleRowsWhole(DataGeneratorMetadata dataGeneratorMetadata)
{
return dataGeneratorMetadata.MembersToGenerate is [var member]
&& TupleHelper.IsTupleType(member switch
{
ParameterMetadata parameter => parameter.Type,
PropertyMetadata property => property.Type,
_ => null
});
}

private static object?[] ToRow(object? item, bool keepTupleRowsWhole)
{
if (keepTupleRowsWhole)
{
item = DataSourceHelpers.InvokeIfFunc(item);
if (DataSourceHelpers.IsTuple(item))
{
return [item];
Comment thread
thomhurst marked this conversation as resolved.
Resolved
}
}

return DataSourceHelpers.ToObjectArray(item);
}

private static object GetRequiredInstance(DataGeneratorMetadata dataGeneratorMetadata)
{
return dataGeneratorMetadata.TestClassInstance
Expand Down
53 changes: 53 additions & 0 deletions tests/TUnit.Core.SourceGenerator.Tests/Bugs/7225/Tests7225.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace TUnit.Core.SourceGenerator.Tests.Bugs._7225;

/// <summary>
/// [InstanceMethodDataSource] gets the same generated Factory as [MethodDataSource], so Native AOT
/// does not fall back to a reflection member lookup (#7225).
/// </summary>
internal class Tests7225 : TestsBase
{
[Test]
public Task Test() => RunTest(Path.Combine(Git.TestsDirectory.FullName,
"TUnit.TestProject",
"Bugs",
"_7225",
"InstanceMethodDataSourceFieldTests.cs"),
async generatedFiles =>
{
var source = string.Join(Environment.NewLine, generatedFiles);
const string testClass = "global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceFieldTests";
await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).Rows ?? global::System.Array.Empty<object>())");
await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).PropertyRows ?? global::System.Array.Empty<object>())");
await Assert.That(source).Contains($"FromEnumerable(dataGeneratorMetadata, static () => (object?){testClass}.StaticRows() ?? global::System.Array.Empty<object>())");
await Assert.That(source).DoesNotContain("new global::TUnit.Core.InstanceMethodDataSourceAttribute(\"Rows\"),");
});

/// <summary>
/// The factory keeps the behavior [InstanceMethodDataSource] had when it read members by reflection: an object
/// value decides its rows at run time, a null value is an empty source, and a private method is looked up
/// by a constant name that Native AOT keeps, as private fields and properties are.
/// </summary>
[Test]
public Task MemberShapes() => RunTest(Path.Combine(Git.TestsDirectory.FullName,
"TUnit.TestProject",
"Bugs",
"_7225",
"InstanceMethodDataSourceMemberShapeTests.cs"),
async generatedFiles =>
{
var source = string.Join(Environment.NewLine, generatedFiles);
const string testClass = "global::TUnit.TestProject.Bugs._7225.InstanceMethodDataSourceMemberShapeTests";
await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).ObjectProperty ?? global::System.Array.Empty<object>())");
await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).ObjectMethod() ?? global::System.Array.Empty<object>())");
await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).TupleRows ?? global::System.Array.Empty<object>())");
await Assert.That(source).Contains($"FromInstanceEnumerable(dataGeneratorMetadata, static instance => typeof({testClass}).GetMethod(\"PrivateRows\", ");
await Assert.That(source).DoesNotContain("new global::TUnit.Core.InstanceMethodDataSourceAttribute(\"PrivateRows\"),");
await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).NonGenericTaskRows() ?? global::System.Array.Empty<object>())");
await Assert.That(source).Contains($"FromInstanceRuntimeValue(dataGeneratorMetadata, static instance => (object?)(({testClass})instance).NonGenericValueTaskRows ?? global::System.Array.Empty<object>())");
await Assert.That(source).Contains($"FromRuntimeValue(dataGeneratorMetadata, static () => (object?){testClass}.StaticNonGenericTaskRows ?? global::System.Array.Empty<object>())");
await Assert.That(source).Contains($"FromRuntimeValue(dataGeneratorMetadata, static () => (object?){testClass}.StaticNonGenericValueTaskRows() ?? global::System.Array.Empty<object>())");
// A generic overload of the same name would make the GetMethod lookup ambiguous
await Assert.That(source).Contains($"global::System.Array.Find(typeof({testClass}).GetMethods(");
await Assert.That(source).Contains("m.Name == \"OverloadedRows\" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 0)!.Invoke(instance, null)");
});
}
Loading
Loading