Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
83 changes: 83 additions & 0 deletions TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2050,6 +2050,89 @@ public class TestUsage
return VerifyGeneratorOutput(source);
}

[Test]
public void Interface_Hiding_Base_Members_With_New_Forwards_All_Slots_In_Wrapper()
{
// Regression #6252: IDerived hides IBase members with `new`. IBase.X and IDerived.X are
// distinct interface slots; the wrapper forwards each member as an explicit impl, which
// satisfies only the slot it names — so each hidden base slot needs its own explicit
// forward (cast to that interface) or the build fails with CS0535.
var source = """
using TUnit.Mocks;

public interface IBase
{
void SomeMethod();
int Prop { get; }
event System.Action Evt;
}

public interface IDerived : IBase
{
new void SomeMethod();
new int Prop { get; }
new event System.Action Evt;
}

public class TestUsage
{
void M() { var mock = Mock.Of<IDerived>(); }
}
""";

var output = GetGeneratedOutput(source);

// Both the derived slot and the hidden base slot are forwarded explicitly.
AssertContains(output, "void global::IDerived.SomeMethod()");
AssertContains(output, "void global::IBase.SomeMethod()");
AssertContains(output, "((global::IBase)Object).SomeMethod()");
AssertContains(output, "int global::IDerived.Prop");
AssertContains(output, "int global::IBase.Prop");
AssertContains(output, "global::IDerived.Evt");
AssertContains(output, "global::IBase.Evt");

AssertNoGeneratedError(source, "CS0535");
}

[Test]
public void Interface_Inheriting_Identical_Member_From_Multiple_Interfaces_Forwards_Both_Slots()
{
// Regression #6252 (diamond): IDiamondC inherits an identically-signed Go() from two
// unrelated interfaces. One impl satisfies both slots, but the wrapper must forward both —
// and BOTH forwards (including the primary) must cast, else `Object.Go()` is ambiguous (CS0121).
var source = """
using TUnit.Mocks;

public interface IDiamondA { void Go(); }
public interface IDiamondB { void Go(); }
public interface IDiamondC : IDiamondA, IDiamondB { }

public class TestUsage
{
void M() { var mock = Mock.Of<IDiamondC>(); }
}
""";

var output = GetGeneratedOutput(source);

AssertContains(output, "((global::IDiamondA)Object).Go()");
AssertContains(output, "((global::IDiamondB)Object).Go()");

AssertNoGeneratedError(source, "CS0121");
AssertNoGeneratedError(source, "CS0535");
}

private static void AssertNoGeneratedError(string source, string errorId)
{
foreach (var diagnostic in GetGeneratedCompilationErrors(source))
{
if (string.Equals(diagnostic.Id, errorId, StringComparison.Ordinal))
{
throw new InvalidOperationException($"Generated code produced {errorId}: {diagnostic}");
}
}
}

private static string GetGeneratedOutput(string source, IEnumerable<Microsoft.CodeAnalysis.MetadataReference>? additionalReferences = null)
=> string.Join(Environment.NewLine, RunGenerator(source, additionalReferences));

Expand Down
78 changes: 64 additions & 14 deletions TUnit.Mocks.SourceGenerator/Builders/MockWrapperTypeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,27 @@ public static string Build(MockTypeModel model)
private static void GenerateMethodForwarding(CodeWriter writer, MockMemberModel method, MockTypeModel model)
{
var interfaceName = method.ExplicitInterfaceName ?? method.DeclaringInterfaceName ?? model.FullyQualifiedName;

EmitMethodForward(writer, method, interfaceName, GetPrimaryTarget(method, interfaceName));

// Additional explicit forwards for distinct interface slots this member also satisfies
// (base members hidden by `new`, or inherited from multiple interfaces). Each slot needs
// its own explicit impl, cast to that interface so the right slot is hit on Object (#6252).
foreach (var extra in method.AdditionalExplicitInterfaceNames)
{
writer.AppendLine();
EmitMethodForward(writer, method, extra, CastTarget(extra));
}
}

private static void EmitMethodForward(CodeWriter writer, MockMemberModel method, string interfaceName, string target)
{
var paramList = MockImplBuilder.GetParameterList(method);
var typeParams = MockImplBuilder.GetTypeParameterList(method);
var constraints = MockImplBuilder.GetConstraintClauses(method, forExplicitImplementation: true);
var argPassList = MockImplBuilder.GetArgPassList(method);
var returnType = (method.IsVoid && !method.IsAsync) ? "void" : method.ReturnType;

// When the method is an explicit interface impl on the underlying object,
// we must cast to call the correct method (e.g. ((IEnumerable)Object).GetEnumerator()).
var target = method.ExplicitInterfaceName is not null
? $"(({method.ExplicitInterfaceName})Object)"
: "Object";

// Copy the source member's [Obsolete] attribute onto the forward so the call to
// Object.{name}(...) inside this method is allowed by the compiler (a member
// marked [Obsolete] may freely call other obsolete members, suppressing CS0618/CS0612).
Expand All @@ -123,8 +132,18 @@ private static void GenerateMethodForwarding(CodeWriter writer, MockMemberModel
private static void GeneratePropertyForwarding(CodeWriter writer, MockMemberModel prop, MockTypeModel model)
{
var interfaceName = GetForwardingInterfaceName(prop, model);
EmitPropertyForward(writer, prop, interfaceName, GetPrimaryTarget(prop, interfaceName));

foreach (var extra in prop.AdditionalExplicitInterfaceNames)
{
writer.AppendLine();
EmitPropertyForward(writer, prop, extra, CastTarget(extra));
}
}

private static void EmitPropertyForward(CodeWriter writer, MockMemberModel prop, string interfaceName, string target)
{
var returnType = prop.ReturnType;
var target = GetForwardingTarget(prop);

writer.AppendLineIfNotEmpty(prop.ObsoleteAttribute);

Expand All @@ -140,10 +159,20 @@ private static void GeneratePropertyForwarding(CodeWriter writer, MockMemberMode
private static void GenerateIndexerForwarding(CodeWriter writer, MockMemberModel prop, MockTypeModel model)
{
var interfaceName = GetForwardingInterfaceName(prop, model);
EmitIndexerForward(writer, prop, interfaceName, GetPrimaryTarget(prop, interfaceName));

foreach (var extra in prop.AdditionalExplicitInterfaceNames)
{
writer.AppendLine();
EmitIndexerForward(writer, prop, extra, CastTarget(extra));
}
}

private static void EmitIndexerForward(CodeWriter writer, MockMemberModel prop, string interfaceName, string target)
{
var returnType = prop.ReturnType;
var paramList = MockImplBuilder.GetParameterList(prop);
var argPassList = MockImplBuilder.GetArgPassList(prop);
var target = GetForwardingTarget(prop);

writer.AppendLineIfNotEmpty(prop.ObsoleteAttribute);

Expand All @@ -155,14 +184,21 @@ private static void GenerateIndexerForwarding(CodeWriter writer, MockMemberModel
writer.AppendLine($"{returnType} {interfaceName}.this[{paramList}] {{ {getter}{setter}}}");
}

// Cast the underlying Object to a specific interface so an explicit forward dispatches to
// that interface's slot (necessary when distinct slots share a signature — e.g. a `new`-hidden
// base member, or wrapping a real object whose explicit impls differ per interface).
private static string CastTarget(string interfaceFqn) => $"(({interfaceFqn})Object)";

private static string GetForwardingInterfaceName(MockMemberModel member, MockTypeModel model)
=> member.ExplicitInterfaceName ?? member.DeclaringInterfaceName ?? model.FullyQualifiedName;

// When the member is an explicit interface impl on the underlying object,
// we must cast to access the correct member.
private static string GetForwardingTarget(MockMemberModel member)
=> member.ExplicitInterfaceName is not null
? $"(({member.ExplicitInterfaceName})Object)"
// The forwarding target for a member's *primary* slot. Normally the untyped `Object`, but cast
// to the slot's interface when the member is an explicit interface impl on the underlying object,
// or when it also satisfies other slots — otherwise `Object.X` may be ambiguous (CS0121) or bind
// to the wrong slot (#6252).
private static string GetPrimaryTarget(MockMemberModel member, string interfaceName)
=> member.ExplicitInterfaceName is not null || member.AdditionalExplicitInterfaceNames.Length > 0
? CastTarget(interfaceName)
: "Object";

private static string GetAccessorObsoletePrefix(string obsoleteAttribute)
Expand All @@ -171,9 +207,23 @@ private static string GetAccessorObsoletePrefix(string obsoleteAttribute)
private static void GenerateEventForwarding(CodeWriter writer, MockEventModel evt, MockTypeModel model)
{
var interfaceName = evt.ExplicitInterfaceName ?? evt.DeclaringInterfaceName ?? model.FullyQualifiedName;
// Event forwards historically target the untyped `Object`; only cast when the event also
// satisfies other slots, to avoid an ambiguous `Object.Evt` (diamond) while keeping output
// unchanged for the common single-slot case (#6252).
var target = evt.AdditionalExplicitInterfaceNames.Length > 0 ? CastTarget(interfaceName) : "Object";
EmitEventForward(writer, evt, interfaceName, target);

foreach (var extra in evt.AdditionalExplicitInterfaceNames)
{
writer.AppendLine();
EmitEventForward(writer, evt, extra, CastTarget(extra));
}
}

private static void EmitEventForward(CodeWriter writer, MockEventModel evt, string interfaceName, string target)
{
writer.AppendLineIfNotEmpty(evt.ObsoleteAttribute);
writer.AppendLine($"event {evt.EventHandlerType} {interfaceName}.{EscapeIdentifier(evt.Name)} {{ add => Object.{EscapeIdentifier(evt.Name)} += value; remove => Object.{EscapeIdentifier(evt.Name)} -= value; }}");
writer.AppendLine($"event {evt.EventHandlerType} {interfaceName}.{EscapeIdentifier(evt.Name)} {{ add => {target}.{EscapeIdentifier(evt.Name)} += value; remove => {target}.{EscapeIdentifier(evt.Name)} -= value; }}");
}

}
82 changes: 80 additions & 2 deletions TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,54 @@ private static bool RequiresExplicitImpl(INamedTypeSymbol? primaryClassSymbol, I
=> primaryClassSymbol is not null
&& primaryClassSymbol.FindImplementationForInterfaceMember(interfaceMember) is not null;

/// <summary>
/// Records that the member at <paramref name="index"/> must additionally be forwarded as an
/// explicit implementation of <paramref name="interfaceFqn"/> in the generated wrapper type.
/// Used when an identically-signed member is dropped during dedup but represents a distinct
/// interface slot (a base member hidden by <c>new</c>, or one inherited from multiple
/// interfaces): the shared impl satisfies every slot implicitly, but the wrapper forwards
/// explicitly and an explicit impl satisfies only the one slot it names (#6252). No-op when
/// the interface already matches the member's own slot or is already recorded. Only meaningful
/// for single-interface mocks — the only ones with a wrapper.
/// </summary>
private static void RecordAdditionalWrapperInterface(List<MockMemberModel> members, int index, string interfaceFqn)
{
var existing = members[index];
var updated = AppendDistinctSlot(existing.AdditionalExplicitInterfaceNames,
existing.ExplicitInterfaceName ?? existing.DeclaringInterfaceName, interfaceFqn, out var changed);
if (changed) members[index] = existing with { AdditionalExplicitInterfaceNames = updated };
}

/// <summary>Event counterpart of <see cref="RecordAdditionalWrapperInterface"/>. Locates the
/// surviving (non-static) event model by name, since the event seen-set is keyed by name only.</summary>
private static void RecordAdditionalWrapperInterfaceForEvent(List<MockEventModel> events, string eventName, string interfaceFqn)
{
for (int i = 0; i < events.Count; i++)
{
var e = events[i];
if (e.IsStaticAbstract || e.Name != eventName) continue;
var updated = AppendDistinctSlot(e.AdditionalExplicitInterfaceNames,
e.ExplicitInterfaceName ?? e.DeclaringInterfaceName, interfaceFqn, out var changed);
if (changed) events[i] = e with { AdditionalExplicitInterfaceNames = updated };
return;
}
}

/// <summary>Returns <paramref name="slots"/> with <paramref name="interfaceFqn"/> appended,
/// setting <paramref name="changed"/>. No-op (returns the input) when <paramref name="interfaceFqn"/>
/// already is the member's own slot (<paramref name="ownSlot"/>) or is already recorded — so the
/// caller can skip allocating a new model record.</summary>
private static EquatableArray<string> AppendDistinctSlot(
EquatableArray<string> slots, string? ownSlot, string interfaceFqn, out bool changed)
{
changed = false;
if (ownSlot == interfaceFqn) return slots;
var array = slots.AsImmutableArray();
if (array.Contains(interfaceFqn)) return slots;
changed = true;
return new EquatableArray<string>(array.Add(interfaceFqn));
}

private static MockMemberModel Tag(MockMemberModel model, int ownerTypeIndex)
=> ownerTypeIndex == 0 ? model : model with { OwnerTypeIndex = ownerTypeIndex };

Expand Down Expand Up @@ -164,7 +212,19 @@ private static void CollectMembers(
// the prior sighting is a non-mockable class member (NonMockableEntry)
// and we're walking an additional interface, re-implement explicitly
// so the mock intercepts interface dispatch anyway.
if (primaryClassSymbol is null || existing.Index != NonMockableEntry.Index) continue;
if (primaryClassSymbol is null || existing.Index != NonMockableEntry.Index)
{
// Single-interface mocks generate a wrapper that forwards each member
// as an explicit interface impl, which satisfies only the slot it names.
// This duplicate is a distinct slot (base member hidden by `new`, or one
// inherited from multiple interfaces) — record it so the wrapper also
// forwards that slot, else the build fails with CS0535 (#6252).
if (primaryClassSymbol is null && existing.Index >= 0)
{
RecordAdditionalWrapperInterface(state.Methods, existing.Index, interfaceFqn);
}
continue;
}
if (!state.SeenExplicitImpls.Add($"{interfaceFqn}|{fullKey}")) continue;
state.Methods.Add(Tag(CreateMethodModel(method, ref state.MemberIdCounter, interfaceFqn, interfaceFqn, explicitInterfaceCanDelegate: false, compilation: compilation), ownerTypeIndex));
break;
Expand Down Expand Up @@ -216,6 +276,9 @@ private static void CollectMembers(
else if (primaryClassSymbol is null)
{
MergePropertyAccessors(state.Properties, existingIndex.Value, property, ref state.MemberIdCounter, compilationAssembly);
// Distinct slot hidden by `new` (or inherited twice) — the wrapper
// needs its own explicit forward for it too (#6252).
RecordAdditionalWrapperInterface(state.Properties, existingIndex.Value, interfaceFqn);
}
// else: class-primary walk and the existing member already covers
// every accessor the interface needs — plain dedup.
Expand Down Expand Up @@ -244,6 +307,12 @@ private static void CollectMembers(
if (existingIndex.HasValue)
{
MergePropertyAccessors(state.Properties, existingIndex.Value, indexer, ref state.MemberIdCounter, compilationAssembly);
// Distinct indexer slot hidden by `new` (or inherited twice) — the
// wrapper needs its own explicit forward for it too (#6252).
if (primaryClassSymbol is null)
{
RecordAdditionalWrapperInterface(state.Properties, existingIndex.Value, interfaceFqn);
}
}
else if (primaryClassSymbol is not null && state.SeenExplicitImpls.Add($"{interfaceFqn}|{key}"))
{
Expand All @@ -262,7 +331,16 @@ private static void CollectMembers(
case IEventSymbol evt:
{
var key = $"E:{evt.Name}";
if (!state.SeenEvents.Add(key)) continue;
if (!state.SeenEvents.Add(key))
{
// Distinct event slot hidden by `new` (or inherited twice) — the wrapper
// needs its own explicit forward for it too (#6252).
if (primaryClassSymbol is null)
{
RecordAdditionalWrapperInterfaceForEvent(state.Events, evt.Name, interfaceFqn);
}
continue;
}

var explicitName = RequiresExplicitImpl(primaryClassSymbol, evt) ? interfaceFqn : null;
state.Events.Add(Tag(CreateEventModel(evt, explicitName, interfaceFqn), ownerTypeIndex));
Expand Down
Loading
Loading