diff --git a/README.md b/README.md index 3685f24..1d0da16 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,21 @@ step. | `Arg.Compat.X(...)` | same as the corresponding `Arg.X(...)` | | Nested mocks (`sub.Child.M(args).Returns(v)`) | `sub.Child.Mock.Setup.M(args).Returns(v)` plus a `// TODO` comment to register `Child` | +## CallInfo callbacks + +NSubstitute's `Returns(call => …)`, `When(...).Do(call => …)`, and `AndDoes(call => …)` callbacks +receive a `CallInfo` parameter that exposes the invocation arguments. Mockolate's equivalent +overloads instead take the method's parameters directly, so the migration rewrites the lambda: + +- **Body never reads the parameter** — drop it: `Do(call => x++)` → `Do(() => x++)`. +- **Body uses statically resolvable accesses** (`call.ArgAt(literalIndex)`, + `call[literalIndex]`, or type-unique `call.Arg()`) — rewrite the lambda's parameter list to + match the receiver method and replace each access with the matching parameter name: + `Returns(call => call.ArgAt(0) + 1)` → `Returns((int x) => x + 1)`. +- **Anything else** — bare `call`, dynamic indices, indexer-write for out/ref, ambiguous + `call.Arg()`, or local variables that would shadow the injected parameter names — preserve + the original lambda and emit a `// TODO` comment so the rewrite can be done by hand. + ## Argument arity Mockolate exposes both a direct-value overload and a matcher overload for properties and for methods with up to diff --git a/Source/Mockolate.Migration.Analyzers.CodeFixers/NSubstituteCodeFixProvider.cs b/Source/Mockolate.Migration.Analyzers.CodeFixers/NSubstituteCodeFixProvider.cs index f8be73c..9d93a19 100644 --- a/Source/Mockolate.Migration.Analyzers.CodeFixers/NSubstituteCodeFixProvider.cs +++ b/Source/Mockolate.Migration.Analyzers.CodeFixers/NSubstituteCodeFixProvider.cs @@ -79,7 +79,7 @@ protected override async Task ConvertAssertionAsync(CodeFixContext con Dictionary whenDoReplacements = FindAndBuildWhenDoReplacements(allInvocations, semanticModel, mockSymbol, cancellationToken); - HashSet andDoesRenames = + Dictionary andDoesRenames = FindAndDoesRenames(allInvocations, semanticModel, mockSymbol, cancellationToken); List nodesToReplace = [substituteCall,]; @@ -89,7 +89,7 @@ protected override async Task ConvertAssertionAsync(CodeFixContext con nodesToReplace.AddRange(raiseReplacements.Keys); nodesToReplace.AddRange(whenDoReplacements.Keys); nodesToReplace.AddRange(propertyVerifyReplacements.Keys); - nodesToReplace.AddRange(andDoesRenames); + nodesToReplace.AddRange(andDoesRenames.Keys); compilationUnit = compilationUnit.ReplaceNodes( nodesToReplace, @@ -122,13 +122,38 @@ protected override async Task ConvertAssertionAsync(CodeFixContext con return whenDoReplacement; } - if (andDoesRenames.Contains(invocation) && + if (andDoesRenames.TryGetValue(invocation, out IMethodSymbol? andDoesTarget) && rewritten is InvocationExpressionSyntax rewrittenInvocation && rewrittenInvocation.Expression is MemberAccessExpressionSyntax rewrittenAccess) { - // The inner setup rewrite has already been applied to `rewritten` — just rename AndDoes → Do. - return rewrittenInvocation.WithExpression( - rewrittenAccess.WithName(SyntaxFactory.IdentifierName("Do"))); + // The inner setup rewrite has already been applied to `rewritten`, but its nodes are + // not bound to the original SyntaxTree — semantic queries must run against the + // original argument syntax on `invocation`. The resulting (detached) ArgumentSyntax + // is then attached onto rewrittenInvocation's ArgumentList. + ArgumentListSyntax andDoesArgs = rewrittenInvocation.ArgumentList; + SyntaxTriviaList? andDoesTodo = null; + if (invocation.ArgumentList.Arguments.Count == 1) + { + ArgumentSyntax rewrittenArg = RewriteCallInfoCallback( + invocation.ArgumentList.Arguments[0], andDoesTarget, semanticModel, + cancellationToken, out CallbackRewriteOutcome outcome); + if (outcome != CallbackRewriteOutcome.NoChange) + { + andDoesArgs = andDoesArgs.WithArguments(SyntaxFactory.SingletonSeparatedList(rewrittenArg)); + } + + if (outcome == CallbackRewriteOutcome.NeedsTodo) + { + andDoesTodo = BuildCallInfoTodoTrivia(invocation); + } + } + + InvocationExpressionSyntax renamed = rewrittenInvocation + .WithExpression(rewrittenAccess.WithName(SyntaxFactory.IdentifierName("Do"))) + .WithArgumentList(andDoesArgs); + return andDoesTodo is { } trivia + ? renamed.WithLeadingTrivia(trivia) + : renamed; } } @@ -270,22 +295,31 @@ private static Dictionary FindAndBuildSetupReplacements( InvocationExpressionSyntax setupInvocation = SyntaxFactory.InvocationExpression(setupAccess, transformedArgs) .WithTriviaFrom(targetInvocation); + IMethodSymbol? receiverMethodSymbol = configuratorMethod is "Returns" or "ReturnsForAnyArgs" + ? semanticModel.GetSymbolInfo(targetInvocation, cancellationToken).Symbol as IMethodSymbol + : null; + (InvocationExpressionSyntax effectiveOuter, bool callInfoTodoNeeded) = MaybeRewriteCallInfoArgs( + outerInvocation, configuratorMethod, receiverMethodSymbol, semanticModel, cancellationToken); + bool isNested = targetMemberAccess.Expression is MemberAccessExpressionSyntax; - BuildSequentialOuterIfNeeded(outerInvocation, configuratorMethod, setupInvocation, + BuildSequentialOuterIfNeeded(effectiveOuter, configuratorMethod, setupInvocation, out InvocationExpressionSyntax? sequentialReplacement); InvocationExpressionSyntax? outerReplacement = sequentialReplacement ?? (isNested - ? BuildSimpleOuter(setupInvocation, outerInvocation, configuratorMethod) + ? BuildSimpleOuter(setupInvocation, effectiveOuter, configuratorMethod) : null); - if (outerReplacement is not null) + // Lambda args were rewritten OR the CallInfo callback bailed to Case C — either way, force + // an outer rebuild so the new args / TODO trivia have a node to attach to. + if (outerReplacement is null && (effectiveOuter != outerInvocation || callInfoTodoNeeded)) { - if (isNested) - { - outerReplacement = outerReplacement.WithLeadingTrivia( - BuildNestedTodoTrivia(outerInvocation, targetMemberAccess.Expression)); - } + outerReplacement = BuildSimpleOuter(setupInvocation, effectiveOuter, configuratorMethod); + } + if (outerReplacement is not null) + { + outerReplacement = ApplySetupTrivia(outerReplacement, outerInvocation, + isNested ? targetMemberAccess.Expression : null, callInfoTodoNeeded); result[outerInvocation] = outerReplacement; } else @@ -306,22 +340,31 @@ private static Dictionary FindAndBuildSetupReplacements( MemberAccessExpressionSyntax setupAccess = BuildSetupAccess( targetPropertyAccess.Expression, targetPropertyAccess.Name); + // Property setups have no method-parameter list to bind a CallInfo lambda against, so any + // `call.X` reference in a Returns lambda will fall through to Case C (TODO). Pure Case A + // (lambda body never reads `call`) still rewrites cleanly. + (InvocationExpressionSyntax effectivePropertyOuter, bool propertyCallInfoTodoNeeded) = + MaybeRewriteCallInfoArgs(outerInvocation, configuratorMethod, null, + semanticModel, cancellationToken); + bool isNestedProperty = targetPropertyAccess.Expression is MemberAccessExpressionSyntax; - BuildSequentialOuterIfNeeded(outerInvocation, configuratorMethod, setupAccess, + BuildSequentialOuterIfNeeded(effectivePropertyOuter, configuratorMethod, setupAccess, out InvocationExpressionSyntax? sequentialPropertyReplacement); InvocationExpressionSyntax? outerPropertyReplacement = sequentialPropertyReplacement ?? (isNestedProperty - ? BuildSimpleOuter(setupAccess, outerInvocation, configuratorMethod) + ? BuildSimpleOuter(setupAccess, effectivePropertyOuter, configuratorMethod) : null); - if (outerPropertyReplacement is not null) + if (outerPropertyReplacement is null && + (effectivePropertyOuter != outerInvocation || propertyCallInfoTodoNeeded)) { - if (isNestedProperty) - { - outerPropertyReplacement = outerPropertyReplacement.WithLeadingTrivia( - BuildNestedTodoTrivia(outerInvocation, targetPropertyAccess.Expression)); - } + outerPropertyReplacement = BuildSimpleOuter(setupAccess, effectivePropertyOuter, configuratorMethod); + } + if (outerPropertyReplacement is not null) + { + outerPropertyReplacement = ApplySetupTrivia(outerPropertyReplacement, outerInvocation, + isNestedProperty ? targetPropertyAccess.Expression : null, propertyCallInfoTodoNeeded); result[outerInvocation] = outerPropertyReplacement; } else @@ -334,6 +377,83 @@ private static Dictionary FindAndBuildSetupReplacements( return result; } + /// + /// Pre-rewrites lambda arguments on the outer configurator (e.g. .Returns(call => …)) when + /// the configurator is one that accepts a Func<CallInfo, T> overload. Returns the (possibly + /// unchanged) together with a flag indicating whether any of the + /// rewrites bailed to Case C and a TODO comment is required. + /// + private static (InvocationExpressionSyntax effectiveOuter, bool callInfoTodoNeeded) MaybeRewriteCallInfoArgs( + InvocationExpressionSyntax outerInvocation, string configuratorMethod, IMethodSymbol? receiverMethod, + SemanticModel semanticModel, CancellationToken cancellationToken) + { + if (configuratorMethod is not ("Returns" or "ReturnsForAnyArgs")) + { + return (outerInvocation, false); + } + + ArgumentListSyntax args = outerInvocation.ArgumentList; + if (args.Arguments.Count == 0) + { + return (outerInvocation, false); + } + + bool changed = false; + bool needsTodo = false; + ArgumentSyntax[] rewritten = new ArgumentSyntax[args.Arguments.Count]; + for (int i = 0; i < args.Arguments.Count; i++) + { + ArgumentSyntax newArg = RewriteCallInfoCallback(args.Arguments[i], receiverMethod, semanticModel, + cancellationToken, out CallbackRewriteOutcome outcome); + rewritten[i] = newArg; + if (outcome != CallbackRewriteOutcome.NoChange) + { + changed = true; + } + + if (outcome == CallbackRewriteOutcome.NeedsTodo) + { + needsTodo = true; + } + } + + if (!changed) + { + return (outerInvocation, needsTodo); + } + + ArgumentListSyntax newArgs = args.WithArguments(SyntaxFactory.SeparatedList(rewritten)); + return (outerInvocation.WithArgumentList(newArgs), needsTodo); + } + + /// + /// Combines the nested-mock and CallInfo TODO comments onto a single setup replacement when both apply. + /// Either flag may be inactive; if neither is active, the replacement is returned untouched. + /// + private static InvocationExpressionSyntax ApplySetupTrivia(InvocationExpressionSyntax replacement, + InvocationExpressionSyntax outerInvocation, ExpressionSyntax? nestedNavigationRoot, bool callInfoTodoNeeded) + { + if (nestedNavigationRoot is null && !callInfoTodoNeeded) + { + return replacement; + } + + SyntaxTriviaList trivia = outerInvocation.GetLeadingTrivia(); + if (nestedNavigationRoot is not null) + { + trivia = AppendTodoComment(trivia, outerInvocation, + $"// TODO: register the nested '{nestedNavigationRoot}' chain explicitly in the mock setup (Mockolate doesn't auto-mock recursively)"); + } + + if (callInfoTodoNeeded) + { + trivia = AppendTodoComment(trivia, outerInvocation, + "// TODO: review CallInfo usage manually — Mockolate's Do/Returns take typed parameters, not CallInfo"); + } + + return replacement.WithLeadingTrivia(trivia); + } + /// /// Builds the trivial outer replacement setup.{configurator}(args) with the original argument list /// unchanged. Used when an outer replacement is required (e.g. nested-mock TODO injection) but no argument @@ -350,21 +470,19 @@ private static InvocationExpressionSyntax BuildSimpleOuter(ExpressionSyntax setu .WithTriviaFrom(outerInvocation); /// - /// Constructs leading trivia that prepends a TODO comment about registering the nested property chain in - /// the Mockolate setup. The comment's indentation matches the line the original expression was on. + /// Leading trivia for a TODO comment alerting the user that a CallInfo-based callback could not be + /// rewritten automatically — the original lambda is preserved so the user can do it by hand. /// - private static SyntaxTriviaList BuildNestedTodoTrivia(InvocationExpressionSyntax outerInvocation, - ExpressionSyntax navigationChainRoot) - { - string chain = navigationChainRoot.ToString(); - string commentText = - $"// TODO: register the nested '{chain}' chain explicitly in the mock setup (Mockolate doesn't auto-mock recursively)"; + private static SyntaxTriviaList BuildCallInfoTodoTrivia(SyntaxNode anchor) => + AppendTodoComment(anchor.GetLeadingTrivia(), anchor, + "// TODO: review CallInfo usage manually — Mockolate's Do/Returns take typed parameters, not CallInfo"); - SyntaxTriviaList originalLeading = outerInvocation.GetLeadingTrivia(); - SyntaxTrivia indent = originalLeading.LastOrDefault(t => t.IsKind(SyntaxKind.WhitespaceTrivia)); - string endOfLine = DetectLineEnding(outerInvocation.SyntaxTree.GetRoot()); - - return originalLeading + private static SyntaxTriviaList AppendTodoComment(SyntaxTriviaList existingLeading, SyntaxNode anchor, + string commentText) + { + SyntaxTrivia indent = existingLeading.LastOrDefault(t => t.IsKind(SyntaxKind.WhitespaceTrivia)); + string endOfLine = DetectLineEnding(anchor.SyntaxTree.GetRoot()); + return existingLeading .Add(SyntaxFactory.Comment(commentText)) .Add(SyntaxFactory.EndOfLine(endOfLine)) .Add(indent); @@ -475,16 +593,17 @@ private static SimpleNameSyntax RenameConfiguratorIdentifier(InvocationExpressio /// /// Identifies trailing .AndDoes(callback) invocations whose call chain bottoms out at the tracked - /// mock symbol, so the dispatcher can rename them to .Do(...) after the inner setup rewrite has - /// already been applied via Roslyn's ReplaceNodes nested-rewrite mechanism. + /// mock symbol, mapping each one to the of the underlying setup target (or + /// when the bottom of the chain is a property access, in which case CallInfo body + /// references will fall back to a TODO). /// - private static HashSet FindAndDoesRenames( + private static Dictionary FindAndDoesRenames( IReadOnlyList allInvocations, SemanticModel? semanticModel, ISymbol? mockSymbol, CancellationToken cancellationToken) { - HashSet result = []; + Dictionary result = []; if (semanticModel is null || mockSymbol is null) { return result; @@ -500,7 +619,7 @@ private static HashSet FindAndDoesRenames( if (ChainResolvesToMock(access.Expression, semanticModel, mockSymbol, cancellationToken)) { - result.Add(invocation); + result[invocation] = FindBottomSetupMethod(access.Expression, semanticModel, cancellationToken); } } @@ -599,17 +718,42 @@ lambda.Body is not InvocationExpressionSyntax lambdaBody || MemberAccessExpressionSyntax setupAccess = BuildSetupAccess(whenAccess.Expression, lambdaMemberAccess.Name.WithoutTrivia()); InvocationExpressionSyntax setupCall = SyntaxFactory.InvocationExpression(setupAccess, transformedArgs); + ArgumentListSyntax doArgs = trailingInvocation.ArgumentList; + SyntaxTriviaList? callInfoTodo = null; + if (trailingMethod == "Do" && doArgs.Arguments.Count == 1) + { + IMethodSymbol? lambdaTargetMethod = + semanticModel.GetSymbolInfo(lambdaBody, cancellationToken).Symbol as IMethodSymbol; + ArgumentSyntax rewrittenDoArg = RewriteCallInfoCallback(doArgs.Arguments[0], lambdaTargetMethod, + semanticModel, cancellationToken, out CallbackRewriteOutcome outcome); + if (outcome != CallbackRewriteOutcome.NoChange) + { + doArgs = doArgs.WithArguments(SyntaxFactory.SingletonSeparatedList(rewrittenDoArg)); + } + + if (outcome == CallbackRewriteOutcome.NeedsTodo) + { + callInfoTodo = BuildCallInfoTodoTrivia(trailingInvocation); + } + } + (string trailingName, ArgumentListSyntax trailingArgs) = trailingMethod == "DoNotCallBase" ? ("SkippingBaseClass", trailingInvocation.ArgumentList) - : ("Do", trailingInvocation.ArgumentList); + : ("Do", doArgs); MemberAccessExpressionSyntax trailingMember = SyntaxFactory.MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, setupCall, SyntaxFactory.IdentifierName(trailingName)); - result[trailingInvocation] = SyntaxFactory.InvocationExpression(trailingMember, trailingArgs) + InvocationExpressionSyntax replacement = SyntaxFactory.InvocationExpression(trailingMember, trailingArgs) .WithTriviaFrom(trailingInvocation); + if (callInfoTodo is { } trivia) + { + replacement = replacement.WithLeadingTrivia(trivia); + } + + result[trailingInvocation] = replacement; } return result; @@ -1269,6 +1413,267 @@ private static InvocationExpressionSyntax BuildItInvocation(IdentifierNameSyntax argList); } + /// + /// Rewrites a single-arg lambda whose parameter is an NSubstitute CallInfo into a + /// Mockolate-compatible callback. Three outcomes: + /// + /// + /// Body never references the parameter → emit () => body (Mockolate's + /// parameterless Do(Action) / Returns(Func<T>) overload). + /// + /// + /// Body uses only statically-resolvable CallInfo accesses (literal-index ArgAt, + /// literal-index indexer in rvalue position, type-unique Arg<T>()) → rewrite the + /// lambda parameter list to match the receiver method and replace each access with the matching + /// parameter name. + /// + /// + /// Anything else (bare call, dynamic indices, indexer-write for out/ref, other CallInfo + /// APIs) → leave the lambda untouched and return + /// so the caller emits a TODO comment. + /// + /// + /// + private static ArgumentSyntax RewriteCallInfoCallback(ArgumentSyntax argument, + IMethodSymbol? receiverMethod, SemanticModel semanticModel, CancellationToken cancellationToken, + out CallbackRewriteOutcome outcome) + { + outcome = CallbackRewriteOutcome.NoChange; + if (argument.Expression is not LambdaExpressionSyntax lambda) + { + return argument; + } + + ParameterSyntax? parameter = lambda switch + { + SimpleLambdaExpressionSyntax simple => simple.Parameter, + ParenthesizedLambdaExpressionSyntax { ParameterList.Parameters: { Count: 1, } ps, } => ps[0], + _ => null, + }; + if (parameter is null) + { + return argument; + } + + IParameterSymbol? paramSymbol = semanticModel.GetDeclaredSymbol(parameter, cancellationToken); + if (paramSymbol is null) + { + return argument; + } + + // Symbol-equality match (text-prefiltered for speed) so identifiers that share the + // parameter's name but resolve to a nested-scope declaration don't count as references. + bool bodyReferencesParam = lambda.Body.DescendantNodesAndSelf() + .OfType() + .Where(id => id.Identifier.Text == paramSymbol.Name) + .Any(id => SymbolEqualityComparer.Default.Equals( + semanticModel.GetSymbolInfo(id, cancellationToken).Symbol, paramSymbol)); + + // Case A: body never reads the parameter — drop it entirely so Mockolate's parameterless + // Action / Func overload binds (works on every method arity). + if (!bodyReferencesParam) + { + ParenthesizedLambdaExpressionSyntax dropped = SyntaxFactory.ParenthesizedLambdaExpression( + SyntaxFactory.ParameterList(), + lambda.Body) + .WithTriviaFrom(lambda); + outcome = CallbackRewriteOutcome.Discarded; + return argument.WithExpression(dropped); + } + + // Body references the parameter. If the user already typed it, leave alone. + bool isCallInfo = paramSymbol.Type is { Name: "CallInfo", } t && + t.ContainingNamespace?.ToDisplayString() == "NSubstitute.Core"; + if (!isCallInfo) + { + return argument; + } + + // Case B: every reference must be statically resolvable. Any unhandled use → Case C. + if (receiverMethod is null || + !TryRewriteCallInfoBody(lambda, paramSymbol, receiverMethod, semanticModel, + cancellationToken, out LambdaExpressionSyntax? rewritten)) + { + outcome = CallbackRewriteOutcome.NeedsTodo; + return argument; + } + + outcome = CallbackRewriteOutcome.Rewritten; + return argument.WithExpression(rewritten!); + } + + private static bool TryRewriteCallInfoBody(LambdaExpressionSyntax lambda, IParameterSymbol paramSymbol, + IMethodSymbol receiverMethod, SemanticModel semanticModel, CancellationToken cancellationToken, + out LambdaExpressionSyntax? rewritten) + { + rewritten = null; + + // Bail if any local symbol declared inside the body shares a name with one of the receiver + // method's parameters. Covers locals, foreach variables, catch declarations, pattern variables, + // local-function parameters, and nested lambda parameters — any of which would either alias the + // injected parameter (illegal shadowing at the same scope, e.g. `string type = type;`) or + // re-bind the injected name inside a nested scope so our rewrite would resolve to the wrong thing. + HashSet paramNames = [.. receiverMethod.Parameters.Select(p => p.Name),]; + foreach (SyntaxNode node in lambda.Body.DescendantNodes()) + { + ISymbol? declared = semanticModel.GetDeclaredSymbol(node, cancellationToken); + if (declared is ILocalSymbol or IParameterSymbol or IRangeVariableSymbol && + paramNames.Contains(declared.Name)) + { + return false; + } + } + + Dictionary replacements = []; + + foreach (IdentifierNameSyntax reference in lambda.Body.DescendantNodesAndSelf() + .OfType() + .Where(id => id.Identifier.Text == paramSymbol.Name)) + { + // Skip identifiers that share the parameter's name but resolve to a different symbol + // (e.g. an inner lambda's parameter that shadows the outer CallInfo parameter). + if (!SymbolEqualityComparer.Default.Equals( + semanticModel.GetSymbolInfo(reference, cancellationToken).Symbol, paramSymbol)) + { + continue; + } + + switch (reference.Parent) + { + // call.ArgAt(N) / call.Arg() + case MemberAccessExpressionSyntax memberAccess when memberAccess.Expression == reference + && memberAccess.Parent is InvocationExpressionSyntax callExpr: + if (!TryResolveCallInfoCall(callExpr, memberAccess, receiverMethod, semanticModel, + cancellationToken, out ExpressionSyntax? callReplacement)) + { + return false; + } + + replacements[callExpr] = callReplacement!.WithTriviaFrom(callExpr); + break; + + // call[N] (rvalue only — write-to-indexer is the out/ref pattern, handled separately). + case ElementAccessExpressionSyntax elementAccess when elementAccess.Expression == reference: + if (elementAccess.Parent is AssignmentExpressionSyntax assign && assign.Left == elementAccess) + { + return false; + } + + if (!TryResolveCallInfoIndexer(elementAccess, receiverMethod, out ExpressionSyntax? indexerReplacement)) + { + return false; + } + + replacements[elementAccess] = indexerReplacement!.WithTriviaFrom(elementAccess); + break; + + default: + // Bare `call`, call.Args(), call.ArgTypes(), call.Target(), call.GetReturnType(), etc. + return false; + } + } + + CSharpSyntaxNode newBody = lambda.Body.ReplaceNodes(replacements.Keys, + (orig, _) => replacements[orig]); + + SeparatedSyntaxList newParams = SyntaxFactory.SeparatedList( + receiverMethod.Parameters.Select(p => + SyntaxFactory.Parameter(EscapedIdentifier(p.Name)) + .WithType(SyntaxFactory.ParseTypeName(p.Type.ToDisplayString())))); + + rewritten = SyntaxFactory.ParenthesizedLambdaExpression( + SyntaxFactory.ParameterList(newParams), + newBody) + .WithTriviaFrom(lambda); + return true; + } + + private static bool TryResolveCallInfoCall(InvocationExpressionSyntax callExpr, + MemberAccessExpressionSyntax memberAccess, IMethodSymbol receiverMethod, + SemanticModel semanticModel, CancellationToken cancellationToken, + out ExpressionSyntax? replacement) + { + replacement = null; + string method = memberAccess.Name.Identifier.Text; + + if (method == "ArgAt" && + callExpr.ArgumentList.Arguments.Count == 1 && + callExpr.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax { Token.Value: int idx, } && + idx >= 0 && idx < receiverMethod.Parameters.Length) + { + replacement = SyntaxFactory.IdentifierName(EscapedIdentifier(receiverMethod.Parameters[idx].Name)); + return true; + } + + if (method == "Arg" && callExpr.ArgumentList.Arguments.Count == 0 && + memberAccess.Name is GenericNameSyntax { TypeArgumentList.Arguments: { Count: 1, } typeArgs, } && + semanticModel.GetTypeInfo(typeArgs[0], cancellationToken).Type is { } targetType) + { + IParameterSymbol[] matches = receiverMethod.Parameters + .Where(p => SymbolEqualityComparer.Default.Equals(p.Type, targetType)) + .ToArray(); + if (matches.Length != 1) + { + return false; + } + + replacement = SyntaxFactory.IdentifierName(EscapedIdentifier(matches[0].Name)); + return true; + } + + return false; + } + + private static bool TryResolveCallInfoIndexer(ElementAccessExpressionSyntax elementAccess, + IMethodSymbol receiverMethod, out ExpressionSyntax? replacement) + { + replacement = null; + if (elementAccess.ArgumentList.Arguments.Count != 1 || + elementAccess.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax { Token.Value: int idx, } || + idx < 0 || idx >= receiverMethod.Parameters.Length) + { + return false; + } + + replacement = SyntaxFactory.IdentifierName(EscapedIdentifier(receiverMethod.Parameters[idx].Name)); + return true; + } + + /// + /// Builds an identifier token whose source text is escaped with a leading @ when + /// collides with a reserved C# keyword (e.g. event, class). + /// Contextual keywords are valid identifiers in expression/parameter positions and are left alone. + /// + private static SyntaxToken EscapedIdentifier(string name) => + SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None + ? SyntaxFactory.Identifier(default, SyntaxKind.None, "@" + name, name, default) + : SyntaxFactory.Identifier(name); + + /// + /// Walks down a configurator chain (e.g. sub.Bar(1).Returns(v).Throws<E>()) past every + /// entry until it lands on the underlying setup target. Returns + /// that target's , or when the bottom of the chain is + /// a property access (no method to map CallInfo accesses against). + /// + private static IMethodSymbol? FindBottomSetupMethod(ExpressionSyntax expression, + SemanticModel semanticModel, CancellationToken cancellationToken) + { + ExpressionSyntax current = expression; + while (current is InvocationExpressionSyntax inv && inv.Expression is MemberAccessExpressionSyntax memberAccess) + { + if (SetupConfiguratorMethods.Contains(memberAccess.Name.Identifier.Text) || + memberAccess.Name.Identifier.Text == "AndDoes") + { + current = memberAccess.Expression; + continue; + } + + return semanticModel.GetSymbolInfo(current, cancellationToken).Symbol as IMethodSymbol; + } + + return null; + } + /// /// Translates the NSubstitute creation call to a Mockolate creation chain. Returns /// when the call cannot be migrated. @@ -1380,6 +1785,21 @@ private static string DetectLineEnding(SyntaxNode root) return "\n"; } + + private enum CallbackRewriteOutcome + { + /// Lambda left untouched (already typed by the user, no callback at all, etc.). + NoChange, + + /// Body never referenced the parameter — emitted () => body. + Discarded, + + /// All call.X references rewritten to typed parameters. + Rewritten, + + /// Could not safely rewrite — original lambda preserved, caller should add a TODO comment. + NeedsTodo, + } } #pragma warning restore S3776 // Cognitive Complexity of methods should not be too high diff --git a/Tests/Mockolate.Migration.MoqPlayground/ArgumentMatcherTests.cs b/Tests/Mockolate.Migration.MoqPlayground/ArgumentMatcherTests.cs index 2b8c21a..9442cdc 100644 --- a/Tests/Mockolate.Migration.MoqPlayground/ArgumentMatcherTests.cs +++ b/Tests/Mockolate.Migration.MoqPlayground/ArgumentMatcherTests.cs @@ -11,7 +11,7 @@ namespace Mockolate.Migration.MoqPlayground; public class ArgumentMatcherTests { [Fact] - public async Task ItIs_predicate_matchesEvenAmounts() + public async Task ItIs_Predicate_MatchesEvenAmounts() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense("Dark", It.Is(i => i % 2 == 0))).Returns(true); @@ -21,7 +21,7 @@ public async Task ItIs_predicate_matchesEvenAmounts() } [Fact] - public async Task ItIsAny_matchesAnyValue() + public async Task ItIsAny_MatchesAnyValue() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense(It.IsAny(), It.IsAny())).Returns(true); @@ -31,7 +31,7 @@ public async Task ItIsAny_matchesAnyValue() } [Fact] - public async Task ItIsInRange_inclusive_matchesBoundaries() + public async Task ItIsInRange_Inclusive_MatchesBoundaries() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense("Dark", It.IsInRange(1, 5, Range.Inclusive))).Returns(true); @@ -42,7 +42,7 @@ public async Task ItIsInRange_inclusive_matchesBoundaries() } [Fact] - public async Task ItIsNotNull_rejectsNull() + public async Task ItIsNotNull_RejectsNull() { Mock factory = new(); factory.Setup(f => f.RegisterRecipe(It.IsNotNull())).Returns(true); @@ -52,7 +52,7 @@ public async Task ItIsNotNull_rejectsNull() } [Fact] - public async Task ItIsRegex_matchesPattern() + public async Task ItIsRegex_MatchesPattern() { Mock factory = new(); factory.Setup(f => f.RegisterRecipe(It.IsRegex("^Dark", RegexOptions.IgnoreCase))).Returns(true); @@ -62,7 +62,7 @@ public async Task ItIsRegex_matchesPattern() } [Fact] - public async Task OutParameter_isSetByItIsOut() + public async Task OutParameter_IsSetByItIsOut() { Mock dispenser = new(); int reserved = 7; @@ -75,7 +75,7 @@ public async Task OutParameter_isSetByItIsOut() } [Fact] - public async Task PlainValue_isUsedAsExactMatch() + public async Task PlainValue_IsUsedAsExactMatch() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense("Milk", 3)).Returns(true); @@ -85,7 +85,7 @@ public async Task PlainValue_isUsedAsExactMatch() } [Fact] - public async Task RefParameter_anyMatch_acceptsAnyRef() + public async Task RefParameter_AnyMatch_AcceptsAnyRef() { Mock dispenser = new(); dispenser.Setup(d => d.Refill("Dark", ref It.Ref.IsAny)).Returns(true); diff --git a/Tests/Mockolate.Migration.MoqPlayground/CreationTests.cs b/Tests/Mockolate.Migration.MoqPlayground/CreationTests.cs index 5c083b9..2510eea 100644 --- a/Tests/Mockolate.Migration.MoqPlayground/CreationTests.cs +++ b/Tests/Mockolate.Migration.MoqPlayground/CreationTests.cs @@ -9,7 +9,7 @@ namespace Mockolate.Migration.MoqPlayground; public class CreationTests { [Fact] - public async Task ClassMockWithConstructorArgs_isCreatedWithThoseArgs() + public async Task ClassMockWithConstructorArgs_IsCreatedWithThoseArgs() { // ChocolateRecipe has a parameterless ctor, but Moq supports passing args to base. Mock recipe = new(); @@ -19,7 +19,7 @@ public async Task ClassMockWithConstructorArgs_isCreatedWithThoseArgs() } [Fact] - public async Task DefaultLooseMock_returnsDefaultsForUnsetMembers() + public async Task DefaultLooseMock_ReturnsDefaultsForUnsetMembers() { Mock dispenser = new(); @@ -30,7 +30,7 @@ public async Task DefaultLooseMock_returnsDefaultsForUnsetMembers() } [Fact] - public async Task ExplicitLooseMock_isEquivalentToDefault() + public async Task ExplicitLooseMock_IsEquivalentToDefault() { Mock dispenser = new(MockBehavior.Loose); @@ -38,7 +38,7 @@ public async Task ExplicitLooseMock_isEquivalentToDefault() } [Fact] - public async Task ObjectAccess_isUsedToReachTheMockedInstance() + public async Task ObjectAccess_IsUsedToReachTheMockedInstance() { Mock factory = new(); factory.Setup(f => f.RegisterRecipe("Truffle")).Returns(true); @@ -49,7 +49,7 @@ public async Task ObjectAccess_isUsedToReachTheMockedInstance() } [Fact] - public async Task StrictMock_throwsForUnsetMembers() + public async Task StrictMock_ThrowsForUnsetMembers() { Mock dispenser = new(MockBehavior.Strict); diff --git a/Tests/Mockolate.Migration.MoqPlayground/EventTests.cs b/Tests/Mockolate.Migration.MoqPlayground/EventTests.cs index 5a35051..a5161e3 100644 --- a/Tests/Mockolate.Migration.MoqPlayground/EventTests.cs +++ b/Tests/Mockolate.Migration.MoqPlayground/EventTests.cs @@ -9,7 +9,7 @@ namespace Mockolate.Migration.MoqPlayground; public class EventTests { [Fact] - public async Task Raise_customDelegate_invokesSubscribedHandler() + public async Task Raise_CustomDelegate_InvokesSubscribedHandler() { Mock dispenser = new(); string? observedType = null; @@ -27,7 +27,7 @@ public async Task Raise_customDelegate_invokesSubscribedHandler() } [Fact] - public async Task Raise_eventHandlerStandard_passesArgs() + public async Task Raise_EventHandlerStandard_PassesArgs() { Mock dispenser = new(); int? observed = null; @@ -39,7 +39,7 @@ public async Task Raise_eventHandlerStandard_passesArgs() } [Fact] - public async Task ShopSubscribesOnConstruction_andTracksDispensedAmounts() + public async Task ShopSubscribesOnConstruction_AndTracksDispensedAmounts() { Mock dispenser = new(); Mock factory = new(); @@ -58,7 +58,7 @@ public async Task ShopSubscribesOnConstruction_andTracksDispensedAmounts() } [Fact] - public async Task VerifyAdd_recordsSubscription() + public async Task VerifyAdd_RecordsSubscription() { Mock dispenser = new(); ChocolateDispensedDelegate handler = (_, _) => { }; @@ -68,7 +68,7 @@ public async Task VerifyAdd_recordsSubscription() } [Fact] - public async Task VerifyRemove_recordsUnsubscription() + public async Task VerifyRemove_RecordsUnsubscription() { Mock dispenser = new(); ChocolateDispensedDelegate handler = (_, _) => { }; diff --git a/Tests/Mockolate.Migration.MoqPlayground/SequenceTests.cs b/Tests/Mockolate.Migration.MoqPlayground/SequenceTests.cs index 2c9c0ef..920066c 100644 --- a/Tests/Mockolate.Migration.MoqPlayground/SequenceTests.cs +++ b/Tests/Mockolate.Migration.MoqPlayground/SequenceTests.cs @@ -10,7 +10,7 @@ namespace Mockolate.Migration.MoqPlayground; public class SequenceTests { [Fact] - public async Task MockSequence_strictOrdering_isHonored() + public async Task MockSequence_StrictOrdering_IsHonored() { Mock dispenser = new(MockBehavior.Strict); MockSequence sequence = new(); @@ -25,7 +25,7 @@ public async Task MockSequence_strictOrdering_isHonored() } [Fact] - public async Task SetupSequence_returnsValuesInOrder() + public async Task SetupSequence_ReturnsValuesInOrder() { Mock factory = new(); factory.SetupSequence(f => f.RegisterRecipe(It.IsAny())) diff --git a/Tests/Mockolate.Migration.MoqPlayground/SetupTests.cs b/Tests/Mockolate.Migration.MoqPlayground/SetupTests.cs index 2a3c238..966f309 100644 --- a/Tests/Mockolate.Migration.MoqPlayground/SetupTests.cs +++ b/Tests/Mockolate.Migration.MoqPlayground/SetupTests.cs @@ -9,7 +9,7 @@ namespace Mockolate.Migration.MoqPlayground; public class SetupTests { [Fact] - public async Task Callback_observesArgumentsBeforeReturning() + public async Task Callback_ObservesArgumentsBeforeReturning() { Mock dispenser = new(); string? observedType = null; @@ -30,7 +30,7 @@ public async Task Callback_observesArgumentsBeforeReturning() } [Fact] - public async Task NestedMockSetup_recursive_chainsThroughChildMock() + public async Task NestedMockSetup_Recursive_ChainsThroughChildMock() { // Auto-mocking hierarchy: Moq creates child mocks for properties on demand. Mock outer = new() @@ -43,7 +43,7 @@ public async Task NestedMockSetup_recursive_chainsThroughChildMock() } [Fact] - public async Task Returns_argumentBased_evaluatesFromArgument() + public async Task Returns_ArgumentBased_EvaluatesFromArgument() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense(It.IsAny())).Returns((string s) => s.Length > 0); @@ -53,7 +53,7 @@ public async Task Returns_argumentBased_evaluatesFromArgument() } [Fact] - public async Task Returns_directValue_dispensesAndShopRecordsTotal() + public async Task Returns_DirectValue_DispensesAndShopRecordsTotal() { Mock dispenser = new(); Mock factory = new(); @@ -68,7 +68,7 @@ public async Task Returns_directValue_dispensesAndShopRecordsTotal() } [Fact] - public async Task Returns_lazyFactory_evaluatedOnEachCall() + public async Task Returns_LazyFactory_EvaluatedOnEachCall() { Mock dispenser = new(); int count = 1; @@ -83,7 +83,7 @@ public async Task Returns_lazyFactory_evaluatedOnEachCall() } [Fact] - public async Task ReturnsAsync_completesWithValue() + public async Task ReturnsAsync_CompletesWithValue() { Mock dispenser = new(); dispenser.Setup(d => d.DispenseAsync("Dark", 1)).ReturnsAsync(true); @@ -92,7 +92,7 @@ public async Task ReturnsAsync_completesWithValue() } [Fact] - public async Task ReturnsAsync_factory_evaluatesPerCall() + public async Task ReturnsAsync_Factory_EvaluatesPerCall() { Mock factory = new(); factory.Setup(f => f.BakeAsync(It.IsAny(), It.IsAny())) @@ -106,7 +106,7 @@ public async Task ReturnsAsync_factory_evaluatesPerCall() } [Fact] - public async Task SetupGet_property_returnsConfiguredValue() + public async Task SetupGet_Property_ReturnsConfiguredValue() { Mock dispenser = new(); dispenser.SetupGet(d => d.Name).Returns("Choco-9000"); @@ -115,7 +115,7 @@ public async Task SetupGet_property_returnsConfiguredValue() } [Fact] - public async Task SetupProperty_tracksAssignmentsAndReturnsLastValue() + public async Task SetupProperty_TracksAssignmentsAndReturnsLastValue() { Mock dispenser = new(); dispenser.SetupProperty(d => d.Name); @@ -126,7 +126,7 @@ public async Task SetupProperty_tracksAssignmentsAndReturnsLastValue() } [Fact] - public async Task SetupProperty_withInitialValue_returnsThatBeforeAssignment() + public async Task SetupProperty_WithInitialValue_ReturnsThatBeforeAssignment() { Mock dispenser = new(); dispenser.SetupProperty(d => d.Name, "Default"); @@ -135,7 +135,7 @@ public async Task SetupProperty_withInitialValue_returnsThatBeforeAssignment() } [Fact] - public async Task SetupSequence_returnsValuesInOrder_thenDefault() + public async Task SetupSequence_ReturnsValuesInOrder_ThenDefault() { Mock dispenser = new(); dispenser.SetupSequence(d => d.Dispense(It.IsAny(), It.IsAny())) @@ -150,7 +150,7 @@ await That(() => dispenser.Object.Dispense("Dark", 1)) } [Fact] - public async Task Throws_genericException_isRaisedOnInvocation() + public async Task Throws_GenericException_IsRaisedOnInvocation() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense("reset", 0)).Throws(); @@ -160,7 +160,7 @@ await That(() => dispenser.Object.Dispense("reset", 0)) } [Fact] - public async Task Throws_specificInstance_isRaisedOnInvocation() + public async Task Throws_SpecificInstance_IsRaisedOnInvocation() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense("", 0)).Throws(new InvalidChocolateException("empty type")); @@ -171,7 +171,7 @@ await That(() => dispenser.Object.Dispense("", 0)) } [Fact] - public async Task ThrowsAsync_completesWithException() + public async Task ThrowsAsync_CompletesWithException() { Mock dispenser = new(); dispenser.Setup(d => d.DispenseAsync("Dark", 1)).ThrowsAsync(new TimeoutException()); diff --git a/Tests/Mockolate.Migration.MoqPlayground/UnsupportedFeatureTests.cs b/Tests/Mockolate.Migration.MoqPlayground/UnsupportedFeatureTests.cs index 83590fa..0c532ff 100644 --- a/Tests/Mockolate.Migration.MoqPlayground/UnsupportedFeatureTests.cs +++ b/Tests/Mockolate.Migration.MoqPlayground/UnsupportedFeatureTests.cs @@ -15,7 +15,7 @@ public class UnsupportedFeatureTests { // NOT YET MIGRATED: CallBase = true (delegate to base implementation) [Fact] - public async Task CallBase_invokesBaseClassImplementation() + public async Task CallBase_InvokesBaseClassImplementation() { Mock recipe = new() { @@ -30,7 +30,7 @@ public async Task CallBase_invokesBaseClassImplementation() // NOT YET MIGRATED: Custom DefaultValueProvider [Fact] - public async Task DefaultValueProviderMock_returnsAutoMockedReferenceTypes() + public async Task DefaultValueProviderMock_ReturnsAutoMockedReferenceTypes() { Mock factory = new() { @@ -46,7 +46,7 @@ public async Task DefaultValueProviderMock_returnsAutoMockedReferenceTypes() // NOT YET MIGRATED: It.IsNotIn / It.IsIn (set membership matchers) [Fact] - public async Task ItIsIn_acceptsAnyValueFromTheSet() + public async Task ItIsIn_AcceptsAnyValueFromTheSet() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense(It.IsIn("Dark", "Milk"), 1)).Returns(true); @@ -58,7 +58,7 @@ public async Task ItIsIn_acceptsAnyValueFromTheSet() // NOT YET MIGRATED: Mock.As() to add a secondary interface [Fact] - public async Task MockAs_castToAdditionalInterface() + public async Task MockAs_CastToAdditionalInterface() { Mock dispenser = new(); dispenser.As() @@ -70,7 +70,7 @@ public async Task MockAs_castToAdditionalInterface() // NOT YET MIGRATED: Mock.Of() (LINQ to Mocks) [Fact] - public async Task MockOf_setsUpAllReturnsImplicitly() + public async Task MockOf_SetsUpAllReturnsImplicitly() { IChocolateDispenser dispenser = Moq.Mock.Of(d => d.Name == "Quick" && @@ -82,7 +82,7 @@ public async Task MockOf_setsUpAllReturnsImplicitly() // NOT YET MIGRATED: MockRepository for grouped Verifiable + VerifyAll [Fact] - public async Task MockRepository_groupsAndVerifiesAllInOneShot() + public async Task MockRepository_GroupsAndVerifiesAllInOneShot() { MockRepository repo = new(MockBehavior.Strict); Mock dispenser = repo.Create(); @@ -98,7 +98,7 @@ public async Task MockRepository_groupsAndVerifiesAllInOneShot() // NOT YET MIGRATED: Protected() to set up protected virtual members [Fact] - public async Task Protected_setupOfProtectedMethod() + public async Task Protected_SetupOfProtectedMethod() { Mock recipe = new(); recipe.Protected().Setup("InternalSecret").Returns(123); @@ -110,7 +110,7 @@ public async Task Protected_setupOfProtectedMethod() // NOT YET MIGRATED: Returns overload that takes the Mock itself (mock.Object self-reference) [Fact] - public async Task Returns_factoryWithCapturedMock_canReadOtherSetups() + public async Task Returns_FactoryWithCapturedMock_CanReadOtherSetups() { Mock dispenser = new(); dispenser.SetupGet(d => d.TotalDispensed).Returns(42); @@ -123,7 +123,7 @@ public async Task Returns_factoryWithCapturedMock_canReadOtherSetups() // NOT YET MIGRATED: SetupAllProperties stubs every readable+writable property [Fact] - public async Task SetupAllProperties_makesAllPropertiesStateful() + public async Task SetupAllProperties_MakesAllPropertiesStateful() { Mock dispenser = new(); dispenser.SetupAllProperties(); @@ -137,7 +137,7 @@ public async Task SetupAllProperties_makesAllPropertiesStateful() // NOT YET MIGRATED: Strict mock with Verifiable() chain to check that every verifiable setup ran [Fact] - public async Task Strict_withVerifiableSetups_passesWhenAllAreInvoked() + public async Task Strict_WithVerifiableSetups_PassesWhenAllAreInvoked() { Mock dispenser = new(MockBehavior.Strict); dispenser.Setup(d => d.Dispense("Dark", 1)).Returns(true).Verifiable(); @@ -151,7 +151,7 @@ public async Task Strict_withVerifiableSetups_passesWhenAllAreInvoked() // NOT YET MIGRATED: Verifiable() + mock.Verify() [Fact] - public async Task VerifiableSetup_andMockVerify() + public async Task VerifiableSetup_AndMockVerify() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense("Dark", 1)).Returns(true).Verifiable(); @@ -163,7 +163,7 @@ public async Task VerifiableSetup_andMockVerify() // NOT YET MIGRATED: VerifyAll() / VerifyNoOtherCalls() [Fact] - public async Task VerifyAll_andVerifyNoOtherCalls() + public async Task VerifyAll_AndVerifyNoOtherCalls() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense("Dark", 1)).Returns(true); diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/ArgumentMatcherTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/ArgumentMatcherTests.cs index 175ab64..4ccb8cf 100644 --- a/Tests/Mockolate.Migration.NSubstitutePlayground/ArgumentMatcherTests.cs +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/ArgumentMatcherTests.cs @@ -7,7 +7,7 @@ namespace Mockolate.Migration.NSubstitutePlayground; public class ArgumentMatcherTests { [Fact] - public async Task ArgAny_matchesAnyValue() + public async Task ArgAny_MatchesAnyValue() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense(Arg.Any(), Arg.Any()).Returns(true); @@ -17,7 +17,7 @@ public async Task ArgAny_matchesAnyValue() } [Fact] - public async Task ArgCompat_AnyAndIs_workLikePlainArg() + public async Task ArgCompat_AnyAndIs_WorkLikePlainArg() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense(Arg.Compat.Any(), Arg.Compat.Is(i => i > 0)).Returns(true); @@ -27,7 +27,7 @@ public async Task ArgCompat_AnyAndIs_workLikePlainArg() } [Fact] - public async Task ArgIs_predicate_matchesEvenAmounts() + public async Task ArgIs_Predicate_MatchesEvenAmounts() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense("Dark", Arg.Is(i => i % 2 == 0)).Returns(true); @@ -37,7 +37,7 @@ public async Task ArgIs_predicate_matchesEvenAmounts() } [Fact] - public async Task ArgIs_value_matchesExactValue() + public async Task ArgIs_Value_MatchesExactValue() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense(Arg.Is("Dark"), Arg.Is(2)).Returns(true); @@ -47,7 +47,7 @@ public async Task ArgIs_value_matchesExactValue() } [Fact] - public async Task PlainValue_isUsedAsExactMatch() + public async Task PlainValue_IsUsedAsExactMatch() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense("Milk", 3).Returns(true); diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/CreationTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/CreationTests.cs index ed3aa17..403a761 100644 --- a/Tests/Mockolate.Migration.NSubstitutePlayground/CreationTests.cs +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/CreationTests.cs @@ -7,7 +7,7 @@ namespace Mockolate.Migration.NSubstitutePlayground; public class CreationTests { [Fact] - public async Task SubstituteFor_class_canConfigureVirtualMemberReturnValue() + public async Task SubstituteFor_Class_CanConfigureVirtualMemberReturnValue() { // Configure a class substitute's virtual member to return a specific value. ChocolateRecipe recipe = Substitute.For(); @@ -17,7 +17,7 @@ public async Task SubstituteFor_class_canConfigureVirtualMemberReturnValue() } [Fact] - public async Task SubstituteFor_multipleInterfaces_implementsBoth() + public async Task SubstituteFor_MultipleInterfaces_ImplementsBoth() { IChocolateDispenser dispenser = Substitute.For(); @@ -29,7 +29,7 @@ public async Task SubstituteFor_multipleInterfaces_implementsBoth() } [Fact] - public async Task SubstituteFor_singleInterface_createsLooseSubstitute() + public async Task SubstituteFor_SingleInterface_CreatesLooseSubstitute() { IChocolateDispenser dispenser = Substitute.For(); @@ -38,7 +38,7 @@ public async Task SubstituteFor_singleInterface_createsLooseSubstitute() } [Fact] - public async Task SubstituteForPartsOf_callsRealVirtualMembers() + public async Task SubstituteForPartsOf_CallsRealVirtualMembers() { ChocolateRecipe recipe = Substitute.ForPartsOf(); @@ -48,7 +48,7 @@ public async Task SubstituteForPartsOf_callsRealVirtualMembers() } [Fact] - public async Task SubstituteForTypeForwardingTo_forwardsCallsToConcreteImpl() + public async Task SubstituteForTypeForwardingTo_ForwardsCallsToConcreteImpl() { IChocolateAuditor auditor = Substitute.ForTypeForwardingTo(); diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/EventTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/EventTests.cs index 6caf335..8e5e788 100644 --- a/Tests/Mockolate.Migration.NSubstitutePlayground/EventTests.cs +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/EventTests.cs @@ -7,7 +7,7 @@ namespace Mockolate.Migration.NSubstitutePlayground; public class EventTests { [Fact] - public async Task Raise_customDelegate_invokesSubscribedHandler() + public async Task Raise_CustomDelegate_InvokesSubscribedHandler() { IChocolateDispenser dispenser = Substitute.For(); string? observedType = null; @@ -25,7 +25,7 @@ public async Task Raise_customDelegate_invokesSubscribedHandler() } [Fact] - public async Task Raise_eventHandlerWithArgsOnly_passesNullSender() + public async Task Raise_EventHandlerWithArgsOnly_PassesNullSender() { IChocolateDispenser dispenser = Substitute.For(); int? observedLow = null; @@ -37,7 +37,7 @@ public async Task Raise_eventHandlerWithArgsOnly_passesNullSender() } [Fact] - public async Task Raise_eventHandlerWithSenderAndArgs_passesBoth() + public async Task Raise_EventHandlerWithSenderAndArgs_PassesBoth() { IChocolateDispenser dispenser = Substitute.For(); object? observedSender = null; @@ -55,7 +55,7 @@ public async Task Raise_eventHandlerWithSenderAndArgs_passesBoth() } [Fact] - public async Task ShopSubscribesOnConstruction_andTracksDispensedAmounts() + public async Task ShopSubscribesOnConstruction_AndTracksDispensedAmounts() { IChocolateDispenser dispenser = Substitute.For(); IChocolateFactory factory = Substitute.For(); diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/SetupTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/SetupTests.cs index 1e6b7d6..decbd18 100644 --- a/Tests/Mockolate.Migration.NSubstitutePlayground/SetupTests.cs +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/SetupTests.cs @@ -8,7 +8,7 @@ namespace Mockolate.Migration.NSubstitutePlayground; public class SetupTests { [Fact] - public async Task NestedSubstituteSetup_chainedAcrossDependentSubstitutes() + public async Task NestedSubstituteSetup_ChainedAcrossDependentSubstitutes() { // NSubstitute auto-creates child substitutes for properties returning interfaces / classes. // Migration emits a TODO comment because Mockolate needs the child registered explicitly. @@ -19,7 +19,7 @@ public async Task NestedSubstituteSetup_chainedAcrossDependentSubstitutes() } [Fact] - public async Task PropertyAssignment_returnsLastSetValue() + public async Task PropertyAssignment_ReturnsLastSetValue() { IChocolateDispenser dispenser = Substitute.For(); @@ -29,7 +29,7 @@ public async Task PropertyAssignment_returnsLastSetValue() } [Fact] - public async Task PropertyReturns_setsConfiguredValue() + public async Task PropertyReturns_SetsConfiguredValue() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Name.Returns("Choco-9000"); @@ -38,7 +38,7 @@ public async Task PropertyReturns_setsConfiguredValue() } [Fact] - public async Task Returns_directValue_dispensesAndShopRecordsTotal() + public async Task Returns_DirectValue_DispensesAndShopRecordsTotal() { IChocolateDispenser dispenser = Substitute.For(); IChocolateFactory factory = Substitute.For(); @@ -53,7 +53,7 @@ public async Task Returns_directValue_dispensesAndShopRecordsTotal() } [Fact] - public async Task Returns_factory_evaluatesPerCall() + public async Task Returns_Factory_EvaluatesPerCall() { IChocolateDispenser dispenser = Substitute.For(); int counter = 1; @@ -68,7 +68,19 @@ public async Task Returns_factory_evaluatesPerCall() } [Fact] - public async Task Returns_sequenceOfValues_returnsThemInOrder() + public async Task Returns_CallInfoFactory_DispatchesByArgument() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg() == "Dark" && call.ArgAt(1) > 0); + + await That(dispenser.Dispense("Dark", 4)).IsTrue(); + await That(dispenser.Dispense("Milk", 4)).IsFalse(); + await That(dispenser.Dispense("Dark", 0)).IsFalse(); + } + + [Fact] + public async Task Returns_SequenceOfValues_ReturnsThemInOrder() { IChocolateDispenser dispenser = Substitute.For(); dispenser.CountByType("Dark").Returns(1, 2, 3); @@ -79,7 +91,7 @@ public async Task Returns_sequenceOfValues_returnsThemInOrder() } [Fact] - public async Task ReturnsAndDoes_combinesReturnAndSideEffect() + public async Task ReturnsAndDoes_CombinesReturnAndSideEffect() { IChocolateDispenser dispenser = Substitute.For(); int sideEffect = 0; @@ -92,7 +104,7 @@ public async Task ReturnsAndDoes_combinesReturnAndSideEffect() } [Fact] - public async Task ReturnsAsync_completesWithValue() + public async Task ReturnsAsync_CompletesWithValue() { IChocolateDispenser dispenser = Substitute.For(); dispenser.DispenseAsync("Dark", 1).Returns(Task.FromResult(true)); @@ -101,7 +113,7 @@ public async Task ReturnsAsync_completesWithValue() } [Fact] - public async Task ReturnsForAnyArgs_ignoresArgumentsAtSetup() + public async Task ReturnsForAnyArgs_IgnoresArgumentsAtSetup() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense("Dark", 1).ReturnsForAnyArgs(true); @@ -111,7 +123,7 @@ public async Task ReturnsForAnyArgs_ignoresArgumentsAtSetup() } [Fact] - public async Task Throws_genericException_isRaisedOnInvocation() + public async Task Throws_GenericException_IsRaisedOnInvocation() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense("reset", 0).Throws(); @@ -121,7 +133,7 @@ await That(() => dispenser.Dispense("reset", 0)) } [Fact] - public async Task Throws_specificInstance_isRaisedOnInvocation() + public async Task Throws_SpecificInstance_IsRaisedOnInvocation() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense("", 0).Throws(new InvalidChocolateException("empty type")); @@ -132,7 +144,7 @@ await That(() => dispenser.Dispense("", 0)) } [Fact] - public async Task ThrowsForAnyArgs_appliesEveryCall() + public async Task ThrowsForAnyArgs_AppliesEveryCall() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense("ignored", 0).ThrowsForAnyArgs(); diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/UnsupportedFeatureTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/UnsupportedFeatureTests.cs index 64fde60..7c5970f 100644 --- a/Tests/Mockolate.Migration.NSubstitutePlayground/UnsupportedFeatureTests.cs +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/UnsupportedFeatureTests.cs @@ -14,7 +14,7 @@ public class UnsupportedFeatureTests { // NOT YET MIGRATED: Arg.Do(action) — capture each invocation's argument [Fact] - public async Task ArgDo_capturesEveryInvocationArgument() + public async Task ArgDo_CapturesEveryInvocationArgument() { IChocolateDispenser dispenser = Substitute.For(); List amounts = new(); @@ -28,7 +28,7 @@ public async Task ArgDo_capturesEveryInvocationArgument() // NOT YET MIGRATED: Arg.Invoke<...> to call back into a delegate parameter [Fact] - public async Task ArgInvoke_callsThroughDelegateParameter() + public async Task ArgInvoke_CallsThroughDelegateParameter() { // Use a mini-substitute with an Action parameter to exercise Arg.Invoke. IInvokeTarget target = Substitute.For(); @@ -40,9 +40,13 @@ public async Task ArgInvoke_callsThroughDelegateParameter() await That(called).IsEqualTo(1); } - // NOT YET MIGRATED: CallInfo argument access via x => x.Arg() / x.ArgAt(index) + // PARTIALLY MIGRATED: clean CallInfo accesses are rewritten to typed lambda parameters + // (see SetupTests.Returns_CallInfoFactory_DispatchesByArgument). This test deliberately uses + // local variables whose names shadow the receiver method's parameters (`type`, `amount`), + // which would produce illegal shadowing after rewrite — the migration bails to a TODO + // comment instead of producing broken code. [Fact] - public async Task CallInfo_argumentAccessInReturns() + public async Task CallInfo_ArgumentAccessInReturns_WithShadowingLocals() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense(Arg.Any(), Arg.Any()) @@ -60,7 +64,7 @@ public async Task CallInfo_argumentAccessInReturns() // NOT YET MIGRATED: ClearSubstitute — removes setups and call history together [Fact] - public async Task ClearSubstitute_resetsBothCallsAndSetups() + public async Task ClearSubstitute_ResetsBothCallsAndSetups() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense("Dark", 1).Returns(true); @@ -75,7 +79,7 @@ public async Task ClearSubstitute_resetsBothCallsAndSetups() // NOT YET MIGRATED: Configure() — re-enter setup mode after calls have been recorded [Fact] - public async Task Configure_changesReturnAfterUse() + public async Task Configure_ChangesReturnAfterUse() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense("Dark", 1).Returns(false); @@ -88,7 +92,7 @@ public async Task Configure_changesReturnAfterUse() // NOT YET MIGRATED: Out parameters via Arg.Any() with discard pattern [Fact] - public async Task OutParameter_isSetByCallback() + public async Task OutParameter_IsSetByCallback() { IChocolateDispenser dispenser = Substitute.For(); dispenser @@ -107,7 +111,7 @@ public async Task OutParameter_isSetByCallback() // NOT YET MIGRATED: Received.InOrder for ordered cross-substitute verification [Fact] - public async Task ReceivedInOrder_ordersAcrossSubstitutes() + public async Task ReceivedInOrder_OrdersAcrossSubstitutes() { IChocolateDispenser dispenser = Substitute.For(); IChocolateAuditor auditor = Substitute.For(); @@ -125,7 +129,7 @@ public async Task ReceivedInOrder_ordersAcrossSubstitutes() // NOT YET MIGRATED: ReturnsNull / ReturnsNullForAnyArgs (extension methods) [Fact] - public async Task ReturnsNull_isShortcutForReturnsDefault() + public async Task ReturnsNull_IsShortcutForReturnsDefault() { IChocolateFactory factory = Substitute.For(); factory.BatchBakeAsync(Arg.Any>()).Returns(Task.FromResult>(null!)); @@ -136,7 +140,7 @@ public async Task ReturnsNull_isShortcutForReturnsDefault() // NOT YET MIGRATED: ThrowsAsync extension on async setups [Fact] - public async Task ThrowsAsync_appliesToAwaitedTask() + public async Task ThrowsAsync_AppliesToAwaitedTask() { IChocolateDispenser dispenser = Substitute.For(); dispenser.DispenseAsync("Dark", 1).ThrowsAsync(new TimeoutException()); diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/WhenDoTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/WhenDoTests.cs index e854892..3a5fb80 100644 --- a/Tests/Mockolate.Migration.NSubstitutePlayground/WhenDoTests.cs +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/WhenDoTests.cs @@ -7,7 +7,7 @@ namespace Mockolate.Migration.NSubstitutePlayground; public class WhenDoTests { [Fact] - public async Task When_DoNotCallBase_disablesPartialBaseInvocation() + public async Task When_DoNotCallBase_DisablesPartialBaseInvocation() { // ForPartsOf normally calls real virtual methods. DoNotCallBase suppresses that. ChocolateRecipe recipe = Substitute.ForPartsOf(); @@ -21,7 +21,7 @@ public async Task When_DoNotCallBase_disablesPartialBaseInvocation() } [Fact] - public async Task When_voidMethod_runsCallback() + public async Task When_VoidMethod_RunsCallback() { IChocolateDispenser dispenser = Substitute.For(); string? captured = null; @@ -34,7 +34,7 @@ public async Task When_voidMethod_runsCallback() } [Fact] - public async Task WhenForAnyArgs_voidMethod_runsCallbackForAnyInvocation() + public async Task WhenForAnyArgs_VoidMethod_RunsCallbackForAnyInvocation() { IChocolateDispenser dispenser = Substitute.For(); int count = 0; diff --git a/Tests/Mockolate.Migration.Tests/NSubstituteCodeFixProviderTests.AndDoesTests.cs b/Tests/Mockolate.Migration.Tests/NSubstituteCodeFixProviderTests.AndDoesTests.cs index f420e64..3403127 100644 --- a/Tests/Mockolate.Migration.Tests/NSubstituteCodeFixProviderTests.AndDoesTests.cs +++ b/Tests/Mockolate.Migration.Tests/NSubstituteCodeFixProviderTests.AndDoesTests.cs @@ -37,7 +37,7 @@ public void Test() { var sub = IFoo.CreateMock(); int counter = 0; - sub.Mock.Setup.Bar(1).Returns(1).Returns(2).Returns(3).Do(call => counter++); + sub.Mock.Setup.Bar(1).Returns(1).Returns(2).Returns(3).Do(() => counter++); } } """); @@ -72,7 +72,7 @@ public void Test() { var sub = IFoo.CreateMock(); int counter = 0; - sub.Mock.Setup.Bar(1).Returns(42).Do(call => counter++); + sub.Mock.Setup.Bar(1).Returns(42).Do(() => counter++); } } """); diff --git a/Tests/Mockolate.Migration.Tests/NSubstituteCodeFixProviderTests.CallInfoTests.cs b/Tests/Mockolate.Migration.Tests/NSubstituteCodeFixProviderTests.CallInfoTests.cs new file mode 100644 index 0000000..d915009 --- /dev/null +++ b/Tests/Mockolate.Migration.Tests/NSubstituteCodeFixProviderTests.CallInfoTests.cs @@ -0,0 +1,634 @@ +using Verifier = Mockolate.Migration.Tests.Verifiers.CSharpCodeFixVerifier; + +namespace Mockolate.Migration.Tests; + +public partial class NSubstituteCodeFixProviderTests +{ + public sealed class CallInfoTests + { + public sealed class AndDoesPath + { + [Fact] + public async Task AmbiguousArgGeneric_FallsBackToTodo() + => await Verifier.VerifyCodeFixAsync( + """ + using System; + using NSubstitute; + + public interface IFoo { int Bar(int x, int y); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.Bar(0, 0).Returns(0).AndDoes(call => Console.WriteLine(call.Arg())); + } + } + """, + """ + using System; + using NSubstitute; + using Mockolate; + + public interface IFoo { int Bar(int x, int y); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + // TODO: review CallInfo usage manually — Mockolate's Do/Returns take typed parameters, not CallInfo + sub.Mock.Setup.Bar(0, 0).Returns(0).Do(call => Console.WriteLine(call.Arg())); + } + } + """); + + [Fact] + public async Task AssignThroughIndexer_FallsBackToTodo() + => await Verifier.VerifyCodeFixAsync( + """ + using NSubstitute; + + public interface IFoo { bool TryGet(string key, out int value); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.TryGet("k", out _).Returns(true).AndDoes(call => { call[1] = 42; }); + } + } + """, + """ + using NSubstitute; + using Mockolate; + + public interface IFoo { bool TryGet(string key, out int value); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + // TODO: review CallInfo usage manually — Mockolate's Do/Returns take typed parameters, not CallInfo + sub.Mock.Setup.TryGet("k", out _).Returns(true).Do(call => { call[1] = 42; }); + } + } + """); + + [Fact] + public async Task BodyDoesNotReferenceCallInfo_DropsParameter() + => await Verifier.VerifyCodeFixAsync( + """ + using NSubstitute; + + public interface IFoo { int Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + int counter = 0; + sub.Bar(1, "a").Returns(42).AndDoes(call => counter++); + } + } + """, + """ + using NSubstitute; + using Mockolate; + + public interface IFoo { int Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + int counter = 0; + sub.Mock.Setup.Bar(1, "a").Returns(42).Do(() => counter++); + } + } + """); + + [Fact] + public async Task BodyUsesArgAtAndIndexer_RewritesToTypedParameters() + => await Verifier.VerifyCodeFixAsync( + """ + using System; + using NSubstitute; + + public interface IFoo { int Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.Bar(0, "").Returns(0).AndDoes(call => Console.WriteLine(call.ArgAt(0) + ":" + call[1])); + } + } + """, + """ + using System; + using NSubstitute; + using Mockolate; + + public interface IFoo { int Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + sub.Mock.Setup.Bar(0, "").Returns(0).Do((int x, string y) => Console.WriteLine(x + ":" + y)); + } + } + """); + + [Fact] + public async Task BodyUsesArgGenericByType_RewritesWhenUnique() + => await Verifier.VerifyCodeFixAsync( + """ + using System; + using NSubstitute; + + public interface IFoo { int Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.Bar(0, "").Returns(0).AndDoes(call => Console.WriteLine(call.Arg())); + } + } + """, + """ + using System; + using NSubstitute; + using Mockolate; + + public interface IFoo { int Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + sub.Mock.Setup.Bar(0, "").Returns(0).Do((int x, string y) => Console.WriteLine(y)); + } + } + """); + } + + public sealed class WhenDoPath + { + [Fact] + public async Task BodyUsesArgAt_RewritesToTypedParameters() + => await Verifier.VerifyCodeFixAsync( + """ + using System; + using NSubstitute; + + public interface IFoo { void Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.When(s => s.Bar(0, "")).Do(call => Console.WriteLine(call.ArgAt(0))); + } + } + """, + """ + using System; + using NSubstitute; + using Mockolate; + + public interface IFoo { void Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + sub.Mock.Setup.Bar(0, "").Do((int x, string y) => Console.WriteLine(x)); + } + } + """); + + [Fact] + public async Task MultiArgMethod_BodyIgnoresCallInfo_DropsParameter() + => await Verifier.VerifyCodeFixAsync( + """ + using NSubstitute; + + public interface IFoo { void Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + int counter = 0; + sub.When(x => x.Bar(1, "a")).Do(call => counter++); + } + } + """, + """ + using NSubstitute; + using Mockolate; + + public interface IFoo { void Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + int counter = 0; + sub.Mock.Setup.Bar(1, "a").Do(() => counter++); + } + } + """); + } + + public sealed class ReturnsPath + { + [Fact] + public async Task LambdaIgnoresCallInfo_DropsParameter() + => await Verifier.VerifyCodeFixAsync( + """ + using NSubstitute; + + public interface IFoo { int Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.Bar(0, "").Returns(call => 42); + } + } + """, + """ + using NSubstitute; + using Mockolate; + + public interface IFoo { int Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + sub.Mock.Setup.Bar(0, "").Returns(() => 42); + } + } + """); + + [Fact] + public async Task LambdaUsesArgAt_OnReceiverWithKeywordParameterName_EscapesIdentifier() + => await Verifier.VerifyCodeFixAsync( + """ + using NSubstitute; + + public interface IFoo { string Trigger(string @event); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.Trigger("x").Returns(call => call.ArgAt(0)); + } + } + """, + """ + using NSubstitute; + using Mockolate; + + public interface IFoo { string Trigger(string @event); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + sub.Mock.Setup.Trigger("x").Returns((string @event) => @event); + } + } + """); + + [Fact] + public async Task LambdaUsesArgAt_RewritesToTypedParameters() + => await Verifier.VerifyCodeFixAsync( + """ + using NSubstitute; + + public interface IFoo { int Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.Bar(0, "").Returns(call => call.ArgAt(0) * 2); + } + } + """, + """ + using NSubstitute; + using Mockolate; + + public interface IFoo { int Bar(int x, string y); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + sub.Mock.Setup.Bar(0, "").Returns((int x, string y) => x * 2); + } + } + """); + + [Fact] + public async Task LambdaWithBareCallInfo_FallsBackToTodo() + => await Verifier.VerifyCodeFixAsync( + """ + using NSubstitute; + using NSubstitute.Core; + + public interface IFoo { int Bar(int x); } + + public class Tests + { + int Helper(CallInfo c) => 1; + + public void Test() + { + var sub = [|Substitute.For()|]; + sub.Bar(0).Returns(call => Helper(call)); + } + } + """, + """ + using NSubstitute; + using NSubstitute.Core; + using Mockolate; + + public interface IFoo { int Bar(int x); } + + public class Tests + { + int Helper(CallInfo c) => 1; + + public void Test() + { + var sub = IFoo.CreateMock(); + // TODO: review CallInfo usage manually — Mockolate's Do/Returns take typed parameters, not CallInfo + sub.Mock.Setup.Bar(0).Returns(call => Helper(call)); + } + } + """); + + [Fact] + public async Task LambdaWithForeachVariableShadowingReceiverParameter_FallsBackToTodo() + => await Verifier.VerifyCodeFixAsync( + """ + using NSubstitute; + + public interface IFoo { int Bar(int x); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.Bar(0).Returns(call => + { + foreach (var x in new[] { 1, 2 }) { _ = x; } + return call.ArgAt(0); + }); + } + } + """, + """ + using NSubstitute; + using Mockolate; + + public interface IFoo { int Bar(int x); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + // TODO: review CallInfo usage manually — Mockolate's Do/Returns take typed parameters, not CallInfo + sub.Mock.Setup.Bar(0).Returns(call => + { + foreach (var x in new[] { 1, 2 }) { _ = x; } + return call.ArgAt(0); + }); + } + } + """); + + [Fact] + public async Task LambdaWithLocalShadowingParameterName_FallsBackToTodo() + => await Verifier.VerifyCodeFixAsync( + """ + using NSubstitute; + + public interface IFoo { bool Dispense(string type, int amount); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.Dispense("Dark", 1).Returns(call => + { + string type = call.Arg(); + int amount = call.ArgAt(1); + return type == "Dark" && amount > 0; + }); + } + } + """, + """ + using NSubstitute; + using Mockolate; + + public interface IFoo { bool Dispense(string type, int amount); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + // TODO: review CallInfo usage manually — Mockolate's Do/Returns take typed parameters, not CallInfo + sub.Mock.Setup.Dispense("Dark", 1).Returns(call => + { + string type = call.Arg(); + int amount = call.ArgAt(1); + return type == "Dark" && amount > 0; + }); + } + } + """); + + [Fact] + public async Task LambdaWithNestedLambdaParameterShadowingReceiverParameter_FallsBackToTodo() + => await Verifier.VerifyCodeFixAsync( + """ + using System; + using NSubstitute; + + public interface IFoo { int Bar(int x); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.Bar(0).Returns(call => + { + Action a = (x) => Console.WriteLine(x); + return call.ArgAt(0); + }); + } + } + """, + """ + using System; + using NSubstitute; + using Mockolate; + + public interface IFoo { int Bar(int x); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + // TODO: review CallInfo usage manually — Mockolate's Do/Returns take typed parameters, not CallInfo + sub.Mock.Setup.Bar(0).Returns(call => + { + Action a = (x) => Console.WriteLine(x); + return call.ArgAt(0); + }); + } + } + """); + + [Fact] + public async Task LambdaWithNestedLambdaShadowingCallInfoName_DropsParameter() + => await Verifier.VerifyCodeFixAsync( + """ + using System; + using NSubstitute; + + public interface IFoo { int Bar(int x); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.Bar(0).Returns(call => ((Func)(call => call + 1))(7)); + } + } + """, + """ + using System; + using NSubstitute; + using Mockolate; + + public interface IFoo { int Bar(int x); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + sub.Mock.Setup.Bar(0).Returns(() => ((Func)(call => call + 1))(7)); + } + } + """); + + [Fact] + public async Task MixedSequence_PerArgRewritePreservesValuesAndRewritesLambdas() + => await Verifier.VerifyCodeFixAsync( + """ + using NSubstitute; + + public interface IFoo { int Bar(int x); } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.Bar(0).Returns(1, call => call.ArgAt(0), 3); + } + } + """, + """ + using NSubstitute; + using Mockolate; + + public interface IFoo { int Bar(int x); } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + sub.Mock.Setup.Bar(0).Returns(1).Returns((int x) => x).Returns(3); + } + } + """); + + [Fact] + public async Task OnProperty_LambdaIgnoresCallInfo_DropsParameter() + => await Verifier.VerifyCodeFixAsync( + """ + using NSubstitute; + + public interface IFoo { int Value { get; } } + + public class Tests + { + public void Test() + { + var sub = [|Substitute.For()|]; + sub.Value.Returns(call => 7); + } + } + """, + """ + using NSubstitute; + using Mockolate; + + public interface IFoo { int Value { get; } } + + public class Tests + { + public void Test() + { + var sub = IFoo.CreateMock(); + sub.Mock.Setup.Value.Returns(() => 7); + } + } + """); + } + } +} diff --git a/Tests/Mockolate.Migration.Tests/NSubstituteCodeFixProviderTests.WhenDoTests.cs b/Tests/Mockolate.Migration.Tests/NSubstituteCodeFixProviderTests.WhenDoTests.cs index 8bfea38..fd06426 100644 --- a/Tests/Mockolate.Migration.Tests/NSubstituteCodeFixProviderTests.WhenDoTests.cs +++ b/Tests/Mockolate.Migration.Tests/NSubstituteCodeFixProviderTests.WhenDoTests.cs @@ -37,7 +37,7 @@ public void Test() { var sub = IFoo.CreateMock(); int counter = 0; - sub.Mock.Setup.Bar("hello").Do(call => counter++); + sub.Mock.Setup.Bar("hello").Do(() => counter++); } } """); @@ -105,7 +105,7 @@ public void Test() { var sub = IFoo.CreateMock(); int counter = 0; - sub.Mock.Setup.Bar(It.IsAny()).Do(_ => counter++); + sub.Mock.Setup.Bar(It.IsAny()).Do(() => counter++); } } """);