Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
13 changes: 12 additions & 1 deletion src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1516,9 +1516,20 @@ private static void EmitRawReturnCheck(CodeWriter writer, MockMemberModel method

// IMPORTANT: This check must appear synchronously (no await) after the engine
// dispatch call. The [ThreadStatic] RawReturnContext requires same-thread consumption.
// The pattern type must drop an outer nullable annotation (Task<string?>? → Task<string?>):
// nullable types are never legal in an `is` pattern (CS8116). For an outer-nullable
// member, a null raw value is a legitimate contract value — ReturnsAsync accepts a null
// task there — so it is returned rather than falling into the mismatch throw.
var patternType = method.ReturnType.TrimEnd('?');
var isOuterNullable = patternType.Length != method.ReturnType.Length;
writer.AppendLine($"if (global::TUnit.Mocks.Setup.RawReturnContext.TryConsume(out var __rawAsync))");
writer.OpenBrace();
writer.AppendLine($"if (__rawAsync is {method.ReturnType} __typedAsync) return __typedAsync;");
if (isOuterNullable)
{
writer.AppendLine("if (__rawAsync is null) return null;");
}

writer.AppendLine($"if (__rawAsync is {patternType} __typedAsync) return __typedAsync;");
Comment thread
thomhurst marked this conversation as resolved.
writer.AppendLine($"throw new global::System.InvalidOperationException($\"ReturnsAsync: expected {method.ReturnType} but got {{__rawAsync?.GetType().Name ?? \"null\"}}\");");
writer.CloseBrace();
}
Expand Down
245 changes: 217 additions & 28 deletions src/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,34 @@ void M()
return VerifyGeneratorOutput(source);
}

[Test]
public Task Interface_With_Dynamic_Async_Result()
{
// #6518 review: `dynamic` is illegal as a pattern type (CS8208) and as a typeof operand
// (CS1962), so the async conversion helper must spell it `object` — merely mocking this
// interface used to break the consumer's compilation.
var source = """
using System.Threading.Tasks;
using TUnit.Mocks;

public interface IDynamicService
{
Task<dynamic> GetAsync();
ValueTask<dynamic> ComputeAsync();
}

public class TestUsage
{
void M()
{
var mock = Mock.Of<IDynamicService>();
}
}
""";

return VerifyGeneratorOutput(source);
}

[Test]
public Task Interface_With_Generic_Methods()
{
Expand Down Expand Up @@ -2382,6 +2410,45 @@ public class TestUsage
AssertNoGeneratedError(source, "CS0535");
}

[Test]
public void Outer_Nullable_Task_Member_Keeps_Null_Lambda_Unambiguous()
{
// Regression (#6518 review): `Task<string>?` ends in '?', so the generic-task check
// misread it as bare Task and emitted the ungated non-generic alias next to the
// synchronous factory — making the pre-existing `Returns(() => null)` setup CS0121.
var source = """
#nullable enable
using System.Threading.Tasks;
using TUnit.Mocks;

public interface IOuterNullableTask
{
Task<string?>? GetNameAsync();
}

public class TestUsage
{
void M()
{
var mock = Mock.Of<IOuterNullableTask>();
mock.GetNameAsync().Returns(() => null);
}
}
""";

var output = GetGeneratedOutput(source);

// The alias must be the gated shape (ORP on net9.0+, generic below), never the
// ungated bare-task alias.
AssertContains(output, "Returns<TAsyncFactoryResult>");
AssertContains(output, "[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]");

AssertNoGeneratedError(source, "CS0121");
// Pre-existing on outer-nullable async members: the ReturnsAsync raw-return check used
// the annotated type in an `is` pattern.
AssertNoGeneratedError(source, "CS8116");
}

private static void AssertNoGeneratedError(string source, string errorId)
{
foreach (var diagnostic in GetGeneratedCompilationErrors(source))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,22 @@ namespace TUnit.Mocks.Generated
public IDialogReference_GetReturnValueAsync_M0_MockCall<T> ReturnsAsync(global::System.Threading.Tasks.Task<T?> task) { EnsureSetup().ReturnsRaw(task); return this; }
/// <summary>Return a pre-built Task from a factory, invoked on each call.</summary>
public IDialogReference_GetReturnValueAsync_M0_MockCall<T> ReturnsAsync(global::System.Func<global::System.Threading.Tasks.Task<T?>> taskFactory) { EnsureSetup().ReturnsRaw(() => (object?)taskFactory()); return this; }
/// <summary>Return a Task from a factory, invoked on each call. The Task is returned as-is, so an async factory stays pending until it completes.</summary>
public IDialogReference_GetReturnValueAsync_M0_MockCall<T> Returns<TAsyncFactoryResult>(global::System.Func<global::System.Threading.Tasks.Task<TAsyncFactoryResult>> taskFactory) { EnsureSetup().ReturnsRaw(() => { var task = taskFactory(); return task is null ? null : (object?)__TUnitMocksConvertAsyncResult<TAsyncFactoryResult>(task); }); return this; }

private static global::System.Threading.Tasks.Task<T?> __TUnitMocksConvertAsyncResult<TAsyncFactoryResult>(global::System.Threading.Tasks.Task<TAsyncFactoryResult> task)
=> task is global::System.Threading.Tasks.Task<T?> exact ? exact : __TUnitMocksAwaitAndConvert(task);

private static async global::System.Threading.Tasks.Task<T?> __TUnitMocksAwaitAndConvert<TAsyncFactoryResult>(global::System.Threading.Tasks.Task<TAsyncFactoryResult> task)
{
object? value = await task.ConfigureAwait(false);
switch (value)
{
case T exact: return exact;
case null: return default(T?)!;
default: throw new global::System.InvalidCastException("The async factory produced a result of type '" + value.GetType() + "', which is not convertible to the member's declared result type '" + typeof(T) + "'. Cast the factory result to the declared type in the lambda.");
}
}
#if NET9_0_OR_GREATER
/// <summary>Return a Task from a factory, invoked on each call. The Task is returned as-is, so an async factory stays pending until it completes.</summary>
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
Expand Down Expand Up @@ -600,6 +616,22 @@ namespace TUnit.Mocks.Generated
public IDialogService_UpdateDialogAsync_M0_MockCall<TData> ReturnsAsync(global::System.Threading.Tasks.Task<global::IDialogReference?> task) { EnsureSetup().ReturnsRaw(task); return this; }
/// <summary>Return a pre-built Task from a factory, invoked on each call.</summary>
public IDialogService_UpdateDialogAsync_M0_MockCall<TData> ReturnsAsync(global::System.Func<global::System.Threading.Tasks.Task<global::IDialogReference?>> taskFactory) { EnsureSetup().ReturnsRaw(() => (object?)taskFactory()); return this; }
/// <summary>Return a Task from a factory, invoked on each call. The Task is returned as-is, so an async factory stays pending until it completes.</summary>
public IDialogService_UpdateDialogAsync_M0_MockCall<TData> Returns<TAsyncFactoryResult>(global::System.Func<global::System.Threading.Tasks.Task<TAsyncFactoryResult>> taskFactory) { EnsureSetup().ReturnsRaw(() => { var task = taskFactory(); return task is null ? null : (object?)__TUnitMocksConvertAsyncResult<TAsyncFactoryResult>(task); }); return this; }

private static global::System.Threading.Tasks.Task<global::IDialogReference?> __TUnitMocksConvertAsyncResult<TAsyncFactoryResult>(global::System.Threading.Tasks.Task<TAsyncFactoryResult> task)
=> task is global::System.Threading.Tasks.Task<global::IDialogReference?> exact ? exact : __TUnitMocksAwaitAndConvert(task);

private static async global::System.Threading.Tasks.Task<global::IDialogReference?> __TUnitMocksAwaitAndConvert<TAsyncFactoryResult>(global::System.Threading.Tasks.Task<TAsyncFactoryResult> task)
{
object? value = await task.ConfigureAwait(false);
switch (value)
{
case global::IDialogReference exact: return exact;
case null: return default(global::IDialogReference?)!;
default: throw new global::System.InvalidCastException("The async factory produced a result of type '" + value.GetType() + "', which is not convertible to the member's declared result type '" + typeof(global::IDialogReference) + "'. Cast the factory result to the declared type in the lambda.");
}
}
#if NET9_0_OR_GREATER
/// <summary>Return a Task from a factory, invoked on each call. The Task is returned as-is, so an async factory stays pending until it completes.</summary>
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
Expand All @@ -620,6 +652,12 @@ namespace TUnit.Mocks.Generated
return this;
}

/// <summary>Configure a typed computed async return value using the actual method parameters. The returned task is handed back as-is, so an async factory stays pending until it completes.</summary>
public IDialogService_UpdateDialogAsync_M0_MockCall<TData> Returns<TAsyncFactoryResult>(global::System.Func<string, global::DialogParameters<TData>, global::System.Threading.Tasks.Task<TAsyncFactoryResult>> factory)
{
EnsureSetup().ReturnsRaw(args => { var task = factory((string)args[0]!, (global::DialogParameters<TData>)args[1]!); return task is null ? null : (object?)__TUnitMocksConvertAsyncResult<TAsyncFactoryResult>(task); });
return this;
}
#if NET9_0_OR_GREATER
/// <summary>Configure a typed computed async return value using the actual method parameters. The returned task is handed back as-is, so an async factory stays pending until it completes.</summary>
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
Expand Down Expand Up @@ -722,6 +760,23 @@ namespace TUnit.Mocks.Generated
public IDialogService_ShowDialogAsync_M1_MockCall<TDialog> ReturnsAsync(global::System.Threading.Tasks.Task<global::IDialogReference> task) { EnsureSetup().ReturnsRaw(task); return this; }
/// <summary>Return a pre-built Task from a factory, invoked on each call.</summary>
public IDialogService_ShowDialogAsync_M1_MockCall<TDialog> ReturnsAsync(global::System.Func<global::System.Threading.Tasks.Task<global::IDialogReference>> taskFactory) { EnsureSetup().ReturnsRaw(() => (object?)taskFactory()); return this; }
/// <summary>Return a Task from a factory, invoked on each call. The Task is returned as-is, so an async factory stays pending until it completes.</summary>
public IDialogService_ShowDialogAsync_M1_MockCall<TDialog> Returns<TAsyncFactoryResult>(global::System.Func<global::System.Threading.Tasks.Task<TAsyncFactoryResult>> taskFactory) { EnsureSetup().ReturnsRaw(() => { var task = taskFactory(); return task is null ? null : (object?)__TUnitMocksConvertAsyncResult<TAsyncFactoryResult>(task); }); return this; }

private static global::System.Threading.Tasks.Task<global::IDialogReference> __TUnitMocksConvertAsyncResult<TAsyncFactoryResult>(global::System.Threading.Tasks.Task<TAsyncFactoryResult> task)
=> task is global::System.Threading.Tasks.Task<global::IDialogReference> exact ? exact : __TUnitMocksAwaitAndConvert(task);

private static async global::System.Threading.Tasks.Task<global::IDialogReference> __TUnitMocksAwaitAndConvert<TAsyncFactoryResult>(global::System.Threading.Tasks.Task<TAsyncFactoryResult> task)
{
object? value = await task.ConfigureAwait(false);
switch (value)
{
case global::IDialogReference exact: return exact;
case null when typeof(global::IDialogReference).IsValueType && global::System.Nullable.GetUnderlyingType(typeof(global::IDialogReference)) is null: throw new global::System.InvalidCastException("The async factory (result type '" + typeof(TAsyncFactoryResult) + "') produced a null result, but the member's declared result type '" + typeof(global::IDialogReference) + "' is a non-nullable value type. Return a non-null value of the declared type from the factory.");
case null: return default(global::IDialogReference)!;
default: throw new global::System.InvalidCastException("The async factory produced a result of type '" + value.GetType() + "', which is not convertible to the member's declared result type '" + typeof(global::IDialogReference) + "'. Cast the factory result to the declared type in the lambda.");
}
}
#if NET9_0_OR_GREATER
/// <summary>Return a Task from a factory, invoked on each call. The Task is returned as-is, so an async factory stays pending until it completes.</summary>
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
Expand All @@ -742,6 +797,12 @@ namespace TUnit.Mocks.Generated
return this;
}

/// <summary>Configure a typed computed async return value using the actual method parameters. The returned task is handed back as-is, so an async factory stays pending until it completes.</summary>
public IDialogService_ShowDialogAsync_M1_MockCall<TDialog> Returns<TAsyncFactoryResult>(global::System.Func<object, global::DialogParameters, global::System.Threading.Tasks.Task<TAsyncFactoryResult>> factory)
{
EnsureSetup().ReturnsRaw(args => { var task = factory((object)args[0]!, (global::DialogParameters)args[1]!); return task is null ? null : (object?)__TUnitMocksConvertAsyncResult<TAsyncFactoryResult>(task); });
return this;
}
#if NET9_0_OR_GREATER
/// <summary>Configure a typed computed async return value using the actual method parameters. The returned task is handed back as-is, so an async factory stays pending until it completes.</summary>
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
Expand Down
Loading