diff --git a/src/TestFramework/TestFramework/Assertions/Assert.That.cs b/src/TestFramework/TestFramework/Assertions/Assert.That.cs index f0d137b95f..61589839b6 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.That.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.That.cs @@ -891,9 +891,14 @@ private static void HandleMethodCallExpression(MethodCallExpression callExpr, Di // (to avoid showing both "list" and "list[0]") ExtractVariablesFromExpression(callExpr.Arguments[0], details, evaluationCache, suppressIntermediateValues); } - else if (callExpr.Method.Name == GetMethodName && callExpr.Object is not null && callExpr.Arguments.Count > 0) + else if (IsArrayGetCall(callExpr)) { - // Handle multi-dimensional array indexers (e.g., array.Get(i, j) displayed as array[i, j]) + // Handle array indexers (e.g., array.Get(i, j) displayed as array[i, j]). + // In practice this only fires for multidimensional arrays — single-dimensional + // arrays surface as ArrayIndex in LINQ expressions, not as a Get method call. + // We gate on the receiver actually being an array so arbitrary user-defined + // `Get(...)` methods on non-array types are NOT mis-rendered as `obj[...]` + // (issue #6691); they go through the regular method-call path below. string objectName = GetCleanMemberName(callExpr.Object); string indexDisplay = string.Join(", ", callExpr.Arguments.Select(GetIndexArgumentDisplay)); string indexerDisplay = $"{objectName}[{indexDisplay}]"; @@ -919,9 +924,10 @@ private static void HandleMethodCallExpression(MethodCallExpression callExpr, Di } else { - // For non-boolean methods, capture the method call itself - // (e.g., "list.Count" when used in a comparison) - string methodCallDisplay = GetCleanMemberName(callExpr); + // For non-boolean methods, capture the method call itself using a friendly receiver + // (issue #6691): static methods get the declaring type, captured-this instance methods + // render as `this.Method(...)`, extension methods on `this` also render as `this.Method(...)`. + string methodCallDisplay = GetMethodCallDisplayName(callExpr); TryAddExpressionValue(callExpr, methodCallDisplay, details, evaluationCache); // Don't extract from the object to avoid duplication @@ -935,6 +941,107 @@ private static void HandleMethodCallExpression(MethodCallExpression callExpr, Di } } + /// + /// Matches array.Get(i[, j[, k...]]) calls on an array receiver. In practice this only + /// fires for multidimensional arrays (e.g. int[,]) which expose runtime-synthesized + /// Get/Set/Address methods on the array type itself — not on + /// ; single-dimensional arrays surface as + /// rather than a method call. Gating on the receiver actually being an array prevents arbitrary + /// user-defined instance methods named Get from being mis-rendered as obj[...] + /// (issue #6691). + /// + private static bool IsArrayGetCall(MethodCallExpression callExpr) + => callExpr.Method.Name == GetMethodName + && callExpr.Object is not null + && callExpr.Object.Type.IsArray + && callExpr.Arguments.Count > 0; + + /// + /// Builds a friendly display name for a method-call expression so the failure message uses the + /// same syntax the user wrote. Static methods get prefixed with their declaring type's name; + /// instance methods on captured this render as this.Method(...); extension methods + /// use the first argument as the receiver. Fixes issue #6691. + /// + private static string GetMethodCallDisplayName(MethodCallExpression callExpr) + { + string methodName = callExpr.Method.Name; + + // Extension methods are static methods on a static class marked [Extension]; the receiver is the + // first argument. Render like the user wrote: receiver.Method(rest). + if (callExpr.Object is null + && callExpr.Method.IsDefined(typeof(ExtensionAttribute), inherit: false) + && callExpr.Arguments.Count > 0) + { + Expression firstArg = callExpr.Arguments[0]; + Type receiverParamType = callExpr.Method.GetParameters()[0].ParameterType; + string receiver = IsCapturedThis(firstArg, receiverParamType) + ? "this" + : GetCleanMemberName(firstArg); + string extArgs = string.Join(", ", callExpr.Arguments.Skip(1).Select(static a => CleanExpressionText(a.ToString()))); + return $"{receiver}.{methodName}({extArgs})"; + } + + string argsStr = string.Join(", ", callExpr.Arguments.Select(static a => CleanExpressionText(a.ToString()))); + + if (callExpr.Object is null) + { + // Regular static method: prefix with a friendly type display (no namespace, nested + // types separated with `.` instead of the reflection `+`) so nested types keep their + // nesting context (Outer.Inner.Method rather than Inner.Method). + string typeName = callExpr.Method.DeclaringType is { } dt + ? GetFriendlyTypeName(dt) + : NullAngleBrackets; + return $"{typeName}.{methodName}({argsStr})"; + } + + if (IsCapturedThis(callExpr.Object, callExpr.Method.DeclaringType)) + { + return $"this.{methodName}({argsStr})"; + } + + string objectDisplay = GetCleanMemberName(callExpr.Object); + return $"{objectDisplay}.{methodName}({argsStr})"; + } + + /// + /// Returns a user-friendly display name for : BCL aliases via + /// , namespace stripped, and nested-type separators + /// (reflection's +) converted to .. + /// + private static string GetFriendlyTypeName(Type type) + { + string raw = type.Name; + string cleaned = CleanTypeName(raw); + if (!ReferenceEquals(cleaned, raw)) + { + return cleaned; + } + + // Walk up the nesting chain to produce Outer.Inner instead of Outer+Inner. + return type.IsNested && type.DeclaringType is { } declaring + ? $"{GetFriendlyTypeName(declaring)}.{type.Name}" + : type.Name; + } + + /// + /// Returns if is a reference to the enclosing + /// instance (this) — either accessed via the compiler-synthesized display-class field + /// (named like <>4__this) or as a representing + /// the enclosing instance (no-closure case). For the constant form we require the expression's + /// static type to exactly match its runtime type and be assignable to , + /// so inherited methods on this still render as this.Method(...) without + /// mis-labeling base-typed locals as this. + /// + private static bool IsCapturedThis(Expression objectExpr, Type? declaringType) + => (objectExpr is MemberExpression me + && me.Member.Name.StartsWith("<>", StringComparison.Ordinal) + && me.Member.Name.EndsWith("__this", StringComparison.Ordinal)) + || (declaringType is not null + && objectExpr is ConstantExpression ce + && ce.Value is not null + && ce.Type == ce.Value.GetType() + && declaringType.IsAssignableFrom(ce.Type)); + /// /// Evaluates only the *sub-children* of a writable assignment target (the Left of an Assign or /// compound assignment, or the Operand of a Pre/Post Increment/Decrement). Walking the writable diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.That.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.That.cs index b1dc3503a1..2deb08a7b6 100644 --- a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.That.cs +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.That.cs @@ -1514,6 +1514,65 @@ public void That_NullDelegateTypedMember_StillAppearsInDetails() ex.Message.Should().Contain("null"); } + // ---- #6691 method-call display: locks down the friendly receiver rendering --------------- + // Without the fix, arbitrary `Get(...)` instance methods on non-array types render as + // `obj[arg]` (misleading), instance methods on `this` render with the full type name, and + // static methods on the same type show as `Method(args)` with no receiver context. + private static int GetAnimal() => 42; + + public int GetAnimalInstance() => 7; + + public void That_ArbitraryGetMethodOnNonArray_RendersAsMethodCall_NotIndexer() + { + var box = new GetterBox(); + int expected = 0; + + Action act = () => Assert.That(() => box.Get(1) == expected); + + AssertFailedException ex = act.Should().Throw().Which; + // Must render as `box.Get(1)`, not as `box[1]` (former #6691 regression). + ex.Message.Should().Contain("box.Get(1)"); + ex.Message.Should().NotContain("box[1]"); + } + + public void That_StaticMethodOnSameType_RendersWithTypeNamePrefix() + { + int expected = 0; + + Action act = () => Assert.That(() => GetAnimal() == expected); + + AssertFailedException ex = act.Should().Throw().Which; + // The declaring type should prefix the static method call so the user can locate it. + ex.Message.Should().Contain(nameof(GetAnimal)); + ex.Message.Should().Contain(nameof(AssertTests)); + } + + public void That_InstanceMethodOnThis_RendersAsThisMethod() + { + int expected = 0; + + Action act = () => Assert.That(() => GetAnimalInstance() == expected); + + AssertFailedException ex = act.Should().Throw().Which; + // Captured-this instance methods should render as `this.MethodName(...)`. + ex.Message.Should().Contain($"this.{nameof(GetAnimalInstance)}"); + } + + public void That_ExtensionMethodOnThis_RendersAsThisMethod() + { + string expected = "wrong"; + + Action act = () => Assert.That(() => this.GetGreeting() == expected); + + AssertFailedException ex = act.Should().Throw().Which; + ex.Message.Should().Contain($"this.{nameof(AssertTestsExtensions.GetGreeting)}"); + } + + private sealed class GetterBox + { + public int Get(int key) => key + 100; + } + // ---- Object-typed sub-expressions: locks down current behavior when two side-effecting // method calls return the same mutable reference. The cache stores reference values, so by // the time details are extracted both slots point to the post-mutation object; with @@ -1561,6 +1620,11 @@ public Shape GetValueWithSideEffect() } } +internal static class AssertTestsExtensions +{ + public static string GetGreeting(this AssertTests _) => "hello"; +} + internal static class MutableBoxHelper { public static int ComputeValue() => 42;