Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 5 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,13 @@ 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), and a null raw value falls
// through to the informative throw either way.
var patternType = method.ReturnType.TrimEnd('?');
writer.AppendLine($"if (global::TUnit.Mocks.Setup.RawReturnContext.TryConsume(out var __rawAsync))");
writer.OpenBrace();
writer.AppendLine($"if (__rawAsync is {method.ReturnType} __typedAsync) return __typedAsync;");
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
74 changes: 55 additions & 19 deletions src/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -753,9 +753,9 @@ private static void GenerateTypedReturnsAsyncOverload(CodeWriter writer, List<Mo
}

// Returns alias, so an `async (a, b) => ...` lambda binds here rather than failing against
// the synchronous typed overload with CS4010. Deprioritised — and therefore net9.0+ only —
// for the same reasons as the parameterless alias; see EmitReturnsAsyncOverloads.
// See issue #6495.
// the synchronous typed overload with CS4010. ORP-deprioritised on net9.0+, generic below
// that, for the same reasons as the parameterless alias; see EmitReturnsAsyncOverloads.
// See issues #6495 and #6515.
writer.AppendLine();
writer.AppendLine("#if NET9_0_OR_GREATER");
writer.AppendLine("/// <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>");
Expand All @@ -765,6 +765,18 @@ private static void GenerateTypedReturnsAsyncOverload(CodeWriter writer, List<Mo
writer.AppendLine($"EnsureSetup().ReturnsRaw(args => (object?)factory({castArgs}));");
writer.AppendLine("return this;");
}
writer.AppendLine("#else");
var genericTaskKind = taskType.Substring(0, taskType.IndexOf('<'));
// TAsyncFactoryResult rather than TResult: the wrapper type carries the mocked method's own
// type parameters, and TResult is a common user choice — a same-name inner declaration
// would be a CS0693 shadowing warning in generated code.
var genericFuncType = $"global::System.Func<{typeList}, {genericTaskKind}<TAsyncFactoryResult>>";
writer.AppendLine("/// <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>");
using (writer.Block($"public {wrapperName} Returns<TAsyncFactoryResult>({genericFuncType} factory)"))
{
writer.AppendLine($"EnsureSetup().ReturnsRaw(args => (object?)factory({castArgs}));");
writer.AppendLine("return this;");
}
writer.AppendLine("#endif");
}

Expand Down Expand Up @@ -1830,24 +1842,48 @@ private static void EmitReturnsAsyncOverloads(CodeWriter writer, string wrapperN
// having to know about ReturnsAsync (the synchronous Func<T> overload rejects it with
// CS4010). The returned task is handed back as-is, so it stays pending. See issue #6495.
//
// Deprioritised against the synchronous Returns(Func<T>) sibling: when T is a reference
// type, a lambda whose body pins nothing — Returns(() => null), Returns(() => throw ...) —
// converts equally well to Func<T> and Func<Task<T>>, which would be CS0121. The priority
// breaks that tie back to the pre-existing synchronous meaning. A genuine async lambda is
// unaffected: it is not convertible to Func<T> at all, so it is the only candidate.
//
// That makes the alias inseparable from the attribute, which only reaches the consumer's
// compilation on net9.0+ — TUnit.Mocks polyfills it internally for its own build, so a
// net8.0 consumer would hit CS0246. Emitting the overload there without the priority would
// hand them the ambiguity instead, so the whole alias is net9.0+ (matching the framework
// polyfills below); net8.0 keeps ReturnsAsync, which already returns the task as-is.
writer.AppendLine("#if NET9_0_OR_GREATER");
writer.AppendLine($"/// <summary>Return a {taskLabel} from a factory, invoked on each call. The {taskLabel} is returned as-is, so an async factory stays pending until it completes.</summary>");
writer.AppendLine(PriorityMinusOneAttribute);
writer.AppendLine($"public {wrapperName} Returns(global::System.Func<{taskType}> taskFactory) {{ EnsureSetup().ReturnsRaw(() => (object?)taskFactory()); return this; }}");
writer.AppendLine("#endif");
// For Task<T>/ValueTask<T>, the alias must not collide with the synchronous
// Returns(Func<T>) sibling: when the result type is a reference type, a lambda whose body
// pins nothing — Returns(() => null), Returns(() => throw ...) — converts equally well to
// Func<T> and Func<Task<T>>, which would be CS0121. On net9.0+,
// [OverloadResolutionPriority(-1)] breaks that tie back to the pre-existing synchronous
// meaning; a genuine async lambda is unaffected, as it is not convertible to Func<T> at
// all. The attribute only reaches the consumer's compilation on net9.0+, so below that the
// alias is generic instead (#6515): a typeless lambda body (null / throw / default) cannot
// infer the result type parameter, which excludes the alias from the candidate set and
// resolves the same tie the same way — while an async lambda with an inferable body binds
// it, ValueTask included. The generic form trades the ORP alias's compile-time result-type
// check for inference (a wrong-typed factory surfaces when the setup is consumed), which
// is why the ORP alias remains the net9.0+ shape. Bare Task/ValueTask members have no
// synchronous Returns sibling to collide with, so their alias needs neither.
var aliasDoc = $"/// <summary>Return a {taskLabel} from a factory, invoked on each call. The {taskLabel} is returned as-is, so an async factory stays pending until it completes.</summary>";
if (IsGenericTaskType(taskType))
{
var taskKind = isValueTask ? "global::System.Threading.Tasks.ValueTask" : "global::System.Threading.Tasks.Task";
writer.AppendLine("#if NET9_0_OR_GREATER");
writer.AppendLine(aliasDoc);
writer.AppendLine(PriorityMinusOneAttribute);
writer.AppendLine($"public {wrapperName} Returns(global::System.Func<{taskType}> taskFactory) {{ EnsureSetup().ReturnsRaw(() => (object?)taskFactory()); return this; }}");
writer.AppendLine("#else");
writer.AppendLine(aliasDoc);
writer.AppendLine($"public {wrapperName} Returns<TAsyncFactoryResult>(global::System.Func<{taskKind}<TAsyncFactoryResult>> taskFactory) {{ EnsureSetup().ReturnsRaw(() => (object?)taskFactory()); return this; }}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the mocked result type in the generic alias

On the below-net9 branch, an async lambda returning a subtype infers that subtype rather than the mocked member's declared result type. For example, for Task<object> GetAsync(), .Returns(async () => { await Task.Yield(); return "value"; }) registers a Task<string>; the generated implementation then tests the raw value against Task<object>, which fails because Task<T> is invariant, and returns a faulted task with InvalidOperationException. The same regression affects interface/base return types, ValueTask<T>, and the typed-parameter alias.

Useful? React with 👍 / 👎.

writer.AppendLine("#endif");
}
else
{
writer.AppendLine(aliasDoc);
writer.AppendLine($"public {wrapperName} Returns(global::System.Func<{taskType}> taskFactory) {{ EnsureSetup().ReturnsRaw(() => (object?)taskFactory()); return this; }}");
}
}

/// <summary>
/// True for Task&lt;T&gt;/ValueTask&lt;T&gt; type strings, false for bare Task/ValueTask.
/// An outer-nullable member type (<c>Task&lt;string&gt;?</c>) carries a trailing <c>?</c>,
/// so trim it before testing — misclassifying it as bare would emit the ungated alias next
/// to the synchronous factory and make <c>Returns(() =&gt; null)</c> ambiguous (CS0121).
/// </summary>
private static bool IsGenericTaskType(string taskType) => taskType.TrimEnd('?').EndsWith(">");

private static void EmitEnsureSetup(CodeWriter writer, string builderType, bool hasTypeArguments)
{
// CAS-based lazy init avoids the LazyInitializer Func closure and its two scratch fields.
Expand Down
39 changes: 39 additions & 0 deletions tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2382,6 +2382,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 @@ -208,6 +208,9 @@ namespace TUnit.Mocks.Generated
/// <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)]
public IDialogReference_GetReturnValueAsync_M0_MockCall<T> Returns(global::System.Func<global::System.Threading.Tasks.Task<T?>> taskFactory) { EnsureSetup().ReturnsRaw(() => (object?)taskFactory()); return this; }
#else
/// <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(() => (object?)taskFactory()); return this; }
#endif

// ICallVerification
Expand Down Expand Up @@ -604,6 +607,9 @@ namespace TUnit.Mocks.Generated
/// <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)]
public IDialogService_UpdateDialogAsync_M0_MockCall<TData> Returns(global::System.Func<global::System.Threading.Tasks.Task<global::IDialogReference?>> taskFactory) { EnsureSetup().ReturnsRaw(() => (object?)taskFactory()); return this; }
#else
/// <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(() => (object?)taskFactory()); return this; }
#endif

/// <summary>Configure a typed computed return value using the actual method parameters.</summary>
Expand All @@ -628,6 +634,13 @@ namespace TUnit.Mocks.Generated
EnsureSetup().ReturnsRaw(args => (object?)factory((string)args[0]!, (global::DialogParameters<TData>)args[1]!));
return this;
}
#else
/// <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 => (object?)factory((string)args[0]!, (global::DialogParameters<TData>)args[1]!));
return this;
}
#endif

/// <summary>Execute a typed callback using the actual method parameters.</summary>
Expand Down Expand Up @@ -726,6 +739,9 @@ namespace TUnit.Mocks.Generated
/// <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)]
public IDialogService_ShowDialogAsync_M1_MockCall<TDialog> Returns(global::System.Func<global::System.Threading.Tasks.Task<global::IDialogReference>> taskFactory) { EnsureSetup().ReturnsRaw(() => (object?)taskFactory()); return this; }
#else
/// <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(() => (object?)taskFactory()); return this; }
#endif

/// <summary>Configure a typed computed return value using the actual method parameters.</summary>
Expand All @@ -750,6 +766,13 @@ namespace TUnit.Mocks.Generated
EnsureSetup().ReturnsRaw(args => (object?)factory((object)args[0]!, (global::DialogParameters)args[1]!));
return this;
}
#else
/// <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 => (object?)factory((object)args[0]!, (global::DialogParameters)args[1]!));
return this;
}
#endif

/// <summary>Execute a typed callback using the actual method parameters.</summary>
Expand Down
Loading