Skip to content

Commit be1d378

Browse files
EvangelinkEvangelinkCopilot
authored
Assert.That: restore friendly method-call display from #6691 (#8364)
Co-authored-by: Evangelink <amaury@evangelink.net> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 938c1a7 commit be1d378

2 files changed

Lines changed: 176 additions & 5 deletions

File tree

src/TestFramework/TestFramework/Assertions/Assert.That.cs

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -891,9 +891,14 @@ private static void HandleMethodCallExpression(MethodCallExpression callExpr, Di
891891
// (to avoid showing both "list" and "list[0]")
892892
ExtractVariablesFromExpression(callExpr.Arguments[0], details, evaluationCache, suppressIntermediateValues);
893893
}
894-
else if (callExpr.Method.Name == GetMethodName && callExpr.Object is not null && callExpr.Arguments.Count > 0)
894+
else if (IsArrayGetCall(callExpr))
895895
{
896-
// Handle multi-dimensional array indexers (e.g., array.Get(i, j) displayed as array[i, j])
896+
// Handle array indexers (e.g., array.Get(i, j) displayed as array[i, j]).
897+
// In practice this only fires for multidimensional arrays — single-dimensional
898+
// arrays surface as ArrayIndex in LINQ expressions, not as a Get method call.
899+
// We gate on the receiver actually being an array so arbitrary user-defined
900+
// `Get(...)` methods on non-array types are NOT mis-rendered as `obj[...]`
901+
// (issue #6691); they go through the regular method-call path below.
897902
string objectName = GetCleanMemberName(callExpr.Object);
898903
string indexDisplay = string.Join(", ", callExpr.Arguments.Select(GetIndexArgumentDisplay));
899904
string indexerDisplay = $"{objectName}[{indexDisplay}]";
@@ -919,9 +924,10 @@ private static void HandleMethodCallExpression(MethodCallExpression callExpr, Di
919924
}
920925
else
921926
{
922-
// For non-boolean methods, capture the method call itself
923-
// (e.g., "list.Count" when used in a comparison)
924-
string methodCallDisplay = GetCleanMemberName(callExpr);
927+
// For non-boolean methods, capture the method call itself using a friendly receiver
928+
// (issue #6691): static methods get the declaring type, captured-this instance methods
929+
// render as `this.Method(...)`, extension methods on `this` also render as `this.Method(...)`.
930+
string methodCallDisplay = GetMethodCallDisplayName(callExpr);
925931
TryAddExpressionValue(callExpr, methodCallDisplay, details, evaluationCache);
926932

927933
// Don't extract from the object to avoid duplication
@@ -935,6 +941,107 @@ private static void HandleMethodCallExpression(MethodCallExpression callExpr, Di
935941
}
936942
}
937943

944+
/// <summary>
945+
/// Matches <c>array.Get(i[, j[, k...]])</c> calls on an array receiver. In practice this only
946+
/// fires for multidimensional arrays (e.g. <c>int[,]</c>) which expose runtime-synthesized
947+
/// <c>Get</c>/<c>Set</c>/<c>Address</c> methods on the array type itself — not on
948+
/// <see cref="Array"/>; single-dimensional arrays surface as <see cref="ExpressionType.ArrayIndex"/>
949+
/// rather than a method call. Gating on the receiver actually being an array prevents arbitrary
950+
/// user-defined instance methods named <c>Get</c> from being mis-rendered as <c>obj[...]</c>
951+
/// (issue #6691).
952+
/// </summary>
953+
private static bool IsArrayGetCall(MethodCallExpression callExpr)
954+
=> callExpr.Method.Name == GetMethodName
955+
&& callExpr.Object is not null
956+
&& callExpr.Object.Type.IsArray
957+
&& callExpr.Arguments.Count > 0;
958+
959+
/// <summary>
960+
/// Builds a friendly display name for a method-call expression so the failure message uses the
961+
/// same syntax the user wrote. Static methods get prefixed with their declaring type's name;
962+
/// instance methods on captured <c>this</c> render as <c>this.Method(...)</c>; extension methods
963+
/// use the first argument as the receiver. Fixes issue #6691.
964+
/// </summary>
965+
private static string GetMethodCallDisplayName(MethodCallExpression callExpr)
966+
{
967+
string methodName = callExpr.Method.Name;
968+
969+
// Extension methods are static methods on a static class marked [Extension]; the receiver is the
970+
// first argument. Render like the user wrote: receiver.Method(rest).
971+
if (callExpr.Object is null
972+
&& callExpr.Method.IsDefined(typeof(ExtensionAttribute), inherit: false)
973+
&& callExpr.Arguments.Count > 0)
974+
{
975+
Expression firstArg = callExpr.Arguments[0];
976+
Type receiverParamType = callExpr.Method.GetParameters()[0].ParameterType;
977+
string receiver = IsCapturedThis(firstArg, receiverParamType)
978+
? "this"
979+
: GetCleanMemberName(firstArg);
980+
string extArgs = string.Join(", ", callExpr.Arguments.Skip(1).Select(static a => CleanExpressionText(a.ToString())));
981+
return $"{receiver}.{methodName}({extArgs})";
982+
}
983+
984+
string argsStr = string.Join(", ", callExpr.Arguments.Select(static a => CleanExpressionText(a.ToString())));
985+
986+
if (callExpr.Object is null)
987+
{
988+
// Regular static method: prefix with a friendly type display (no namespace, nested
989+
// types separated with `.` instead of the reflection `+`) so nested types keep their
990+
// nesting context (Outer.Inner.Method rather than Inner.Method).
991+
string typeName = callExpr.Method.DeclaringType is { } dt
992+
? GetFriendlyTypeName(dt)
993+
: NullAngleBrackets;
994+
return $"{typeName}.{methodName}({argsStr})";
995+
}
996+
997+
if (IsCapturedThis(callExpr.Object, callExpr.Method.DeclaringType))
998+
{
999+
return $"this.{methodName}({argsStr})";
1000+
}
1001+
1002+
string objectDisplay = GetCleanMemberName(callExpr.Object);
1003+
return $"{objectDisplay}.{methodName}({argsStr})";
1004+
}
1005+
1006+
/// <summary>
1007+
/// Returns a user-friendly display name for <paramref name="type"/>: BCL aliases via
1008+
/// <see cref="CleanTypeName(string)"/>, namespace stripped, and nested-type separators
1009+
/// (reflection's <c>+</c>) converted to <c>.</c>.
1010+
/// </summary>
1011+
private static string GetFriendlyTypeName(Type type)
1012+
{
1013+
string raw = type.Name;
1014+
string cleaned = CleanTypeName(raw);
1015+
if (!ReferenceEquals(cleaned, raw))
1016+
{
1017+
return cleaned;
1018+
}
1019+
1020+
// Walk up the nesting chain to produce Outer.Inner instead of Outer+Inner.
1021+
return type.IsNested && type.DeclaringType is { } declaring
1022+
? $"{GetFriendlyTypeName(declaring)}.{type.Name}"
1023+
: type.Name;
1024+
}
1025+
1026+
/// <summary>
1027+
/// Returns <see langword="true"/> if <paramref name="objectExpr"/> is a reference to the enclosing
1028+
/// instance (<c>this</c>) — either accessed via the compiler-synthesized display-class field
1029+
/// (named like <c>&lt;&gt;4__this</c>) or as a <see cref="ConstantExpression"/> representing
1030+
/// the enclosing instance (no-closure case). For the constant form we require the expression's
1031+
/// static type to exactly match its runtime type and be assignable to <paramref name="declaringType"/>,
1032+
/// so inherited methods on <c>this</c> still render as <c>this.Method(...)</c> without
1033+
/// mis-labeling base-typed locals as <c>this</c>.
1034+
/// </summary>
1035+
private static bool IsCapturedThis(Expression objectExpr, Type? declaringType)
1036+
=> (objectExpr is MemberExpression me
1037+
&& me.Member.Name.StartsWith("<>", StringComparison.Ordinal)
1038+
&& me.Member.Name.EndsWith("__this", StringComparison.Ordinal))
1039+
|| (declaringType is not null
1040+
&& objectExpr is ConstantExpression ce
1041+
&& ce.Value is not null
1042+
&& ce.Type == ce.Value.GetType()
1043+
&& declaringType.IsAssignableFrom(ce.Type));
1044+
9381045
/// <summary>
9391046
/// Evaluates only the *sub-children* of a writable assignment target (the Left of an Assign or
9401047
/// compound assignment, or the Operand of a Pre/Post Increment/Decrement). Walking the writable

test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.That.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,6 +1514,65 @@ public void That_NullDelegateTypedMember_StillAppearsInDetails()
15141514
ex.Message.Should().Contain("null");
15151515
}
15161516

1517+
// ---- #6691 method-call display: locks down the friendly receiver rendering ---------------
1518+
// Without the fix, arbitrary `Get(...)` instance methods on non-array types render as
1519+
// `obj[arg]` (misleading), instance methods on `this` render with the full type name, and
1520+
// static methods on the same type show as `Method(args)` with no receiver context.
1521+
private static int GetAnimal() => 42;
1522+
1523+
public int GetAnimalInstance() => 7;
1524+
1525+
public void That_ArbitraryGetMethodOnNonArray_RendersAsMethodCall_NotIndexer()
1526+
{
1527+
var box = new GetterBox();
1528+
int expected = 0;
1529+
1530+
Action act = () => Assert.That(() => box.Get(1) == expected);
1531+
1532+
AssertFailedException ex = act.Should().Throw<AssertFailedException>().Which;
1533+
// Must render as `box.Get(1)`, not as `box[1]` (former #6691 regression).
1534+
ex.Message.Should().Contain("box.Get(1)");
1535+
ex.Message.Should().NotContain("box[1]");
1536+
}
1537+
1538+
public void That_StaticMethodOnSameType_RendersWithTypeNamePrefix()
1539+
{
1540+
int expected = 0;
1541+
1542+
Action act = () => Assert.That(() => GetAnimal() == expected);
1543+
1544+
AssertFailedException ex = act.Should().Throw<AssertFailedException>().Which;
1545+
// The declaring type should prefix the static method call so the user can locate it.
1546+
ex.Message.Should().Contain(nameof(GetAnimal));
1547+
ex.Message.Should().Contain(nameof(AssertTests));
1548+
}
1549+
1550+
public void That_InstanceMethodOnThis_RendersAsThisMethod()
1551+
{
1552+
int expected = 0;
1553+
1554+
Action act = () => Assert.That(() => GetAnimalInstance() == expected);
1555+
1556+
AssertFailedException ex = act.Should().Throw<AssertFailedException>().Which;
1557+
// Captured-this instance methods should render as `this.MethodName(...)`.
1558+
ex.Message.Should().Contain($"this.{nameof(GetAnimalInstance)}");
1559+
}
1560+
1561+
public void That_ExtensionMethodOnThis_RendersAsThisMethod()
1562+
{
1563+
string expected = "wrong";
1564+
1565+
Action act = () => Assert.That(() => this.GetGreeting() == expected);
1566+
1567+
AssertFailedException ex = act.Should().Throw<AssertFailedException>().Which;
1568+
ex.Message.Should().Contain($"this.{nameof(AssertTestsExtensions.GetGreeting)}");
1569+
}
1570+
1571+
private sealed class GetterBox
1572+
{
1573+
public int Get(int key) => key + 100;
1574+
}
1575+
15171576
// ---- Object-typed sub-expressions: locks down current behavior when two side-effecting
15181577
// method calls return the same mutable reference. The cache stores reference values, so by
15191578
// the time details are extracted both slots point to the post-mutation object; with
@@ -1561,6 +1620,11 @@ public Shape GetValueWithSideEffect()
15611620
}
15621621
}
15631622

1623+
internal static class AssertTestsExtensions
1624+
{
1625+
public static string GetGreeting(this AssertTests _) => "hello";
1626+
}
1627+
15641628
internal static class MutableBoxHelper
15651629
{
15661630
public static int ComputeValue() => 42;

0 commit comments

Comments
 (0)