Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,28 @@ internal static class MockMembersBuilder
private const int MaxTypedParams = 8;
private const int MaxFuncOverloadParams = 4;

// Two distinct shadowing problems below — different fixes because the kinds collide differently.

// "Object" clashes with the Mock<T>.Object PROPERTY (member-kind collision: property vs.
// generated method/property). A trailing underscore is enough since nothing on object
// is named "Object_".
private static readonly HashSet<string> MockMemberNames = new(System.StringComparer.Ordinal)
{
"Object",
"GetHashCode", "GetType", "ToString", "Equals"
};

// Equals/GetHashCode/ToString/GetType clash with inherited object INSTANCE METHODS.
// C# overload resolution always prefers an instance method on the receiver over an
// extension method, so a generated `Equals` extension is unreachable (mock.Equals(...)
// binds to object.Equals and returns bool). A trailing-underscore rename would also be
// reachable, but we use an "Of" suffix to read naturally at the call site
// (mock.EqualsOf(other).Returns(true)).
private static readonly Dictionary<string, string> ObjectMemberDisambiguations = new(System.StringComparer.Ordinal)
{
{ "Equals", "EqualsOf" },
{ "GetHashCode", "GetHashCodeOf" },
{ "ToString", "ToStringOf" },
{ "GetType", "GetTypeOf" },
};

public static string Build(MockTypeModel model)
Expand Down Expand Up @@ -137,7 +155,11 @@ private static string GetWrapperName(string safeName, MockMemberModel method)
=> $"{safeName}_{method.Name}_M{method.MemberId}_MockCall";

private static string GetSafeMemberName(string name)
=> MockMemberNames.Contains(name) ? name + "_" : name;
{
if (ObjectMemberDisambiguations.TryGetValue(name, out var renamed))
return renamed;
return MockMemberNames.Contains(name) ? name + "_" : name;
}

private static string GetCombinedTypeParameterList(MockTypeModel model, MockMemberModel method)
{
Expand Down
61 changes: 55 additions & 6 deletions TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,21 @@ public class DoubleInterfaceExplicit : IGetIdInt, IGetIdString
public virtual int OwnMember() => 0;
}

// ─── T8 SKIPPED. Self-referential IEquatable<T> — `mock.Equals(...)` resolves
// to object.Equals via extension-method dispatch rather than to the
// generator-emitted setup extension. Separate design limitation: would
// require either renaming the extension or generating a disambiguating
// helper (e.g. `mock.EqualsOf(...)`).
// ─── T8. Self-referential IEquatable<T> — `mock.Equals(...)` would resolve to
// object.Equals via overload-resolution (extension methods can't beat instance
// methods on object). Generator emits a disambiguating `EqualsOf(...)` helper
// so the typed setup is reachable. Same disambiguation applies to GetHashCode
// and ToString. (GetType is not virtual on object, so it can never be overridden
// and the GetTypeOf helper is unreachable in practice — kept symmetrically for
// completeness only.)

public class SelfEquatable : IEquatable<SelfEquatable>
{
public virtual bool Equals(SelfEquatable? other) => ReferenceEquals(this, other);
public override bool Equals(object? obj) => obj is SelfEquatable s && Equals(s);
public override int GetHashCode() => 0;
public override string ToString() => "base";
}

// ─── T9. Nullable value types ────────────────────────────────────────────────

Expand Down Expand Up @@ -334,7 +344,46 @@ public async Task T7_Two_Interfaces_Same_Name_Different_Returns()
mock.OwnMember().WasCalled(Times.Once);
}

// T8 test elided — see the SKIPPED note above the type declarations.
// ── T8 ──

[Test]
public async Task T8_Self_Referential_IEquatable_Mockable()
{
var mock = SelfEquatable.Mock();
var other = new SelfEquatable();
var unrelated = new SelfEquatable();

// Setup via the disambiguated helper. Returns(...) must be reachable on the result.
mock.EqualsOf(other).Returns(true);
mock.EqualsOf(unrelated).Returns(false);

// Direct call routes through the generated impl's Equals override to the engine.
await Assert.That(mock.Object.Equals(other)).IsTrue();
await Assert.That(mock.Object.Equals(unrelated)).IsFalse();

// Interface-cast path resolves to the same underlying setup.
IEquatable<SelfEquatable> asInterface = mock.Object;
await Assert.That(asInterface.Equals(other)).IsTrue();
await Assert.That(asInterface.Equals(unrelated)).IsFalse();

// Verification: each setup tracks both the direct and interface-cast invocations.
mock.EqualsOf(other).WasCalled(Times.Exactly(2));
mock.EqualsOf(unrelated).WasCalled(Times.Exactly(2));

var third = new SelfEquatable();
mock.EqualsOf(third).WasNeverCalled();

// GetHashCodeOf / ToStringOf — same disambiguation pattern.
// GetType is not virtual on object so cannot be exercised; see class-level note.
mock.GetHashCodeOf().Returns(7);
mock.ToStringOf().Returns("mocked");

await Assert.That(mock.Object.GetHashCode()).IsEqualTo(7);
await Assert.That(mock.Object.ToString()).IsEqualTo("mocked");

mock.GetHashCodeOf().WasCalled(Times.Once);
mock.ToStringOf().WasCalled(Times.Once);
}

// ── T9 ──

Expand Down
Loading