Skip to content

Commit 8350a00

Browse files
authored
fix(mocks): dispatch abstract indexers through the engine instead of calling base (#6517)
CreateIndexerModel never set IsAbstractMember, so every class indexer took the virtual-member path in GenerateOverrideIndexer and emitted a `return base[...];` / `base[...] = value;` fallback. For an abstract indexer there is no base implementation, so the generated mock failed to compile with CS0205 — blocking e.g. DbDataReader, which declares two abstract indexers. Carry IsAbstractMember/IsVirtualMember on the indexer model like methods and properties already do; the existing abstract branches in GenerateOverrideIndexer then emit engine-only dispatch. Virtual indexers keep their base fallback. Fixes #6516
1 parent fe936cc commit 8350a00

4 files changed

Lines changed: 333 additions & 0 deletions

File tree

src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,8 @@ private static MockMemberModel CreateIndexerModel(IPropertySymbol indexer, ref i
915915
DeclaringInterfaceName = declaringInterfaceName,
916916
NullableAnnotation = indexer.Type.NullableAnnotation.ToString(),
917917
SmartDefault = indexer.Type.GetSmartDefault(indexer.Type.IsNullableAnnotated()),
918+
IsAbstractMember = indexer.IsAbstract,
919+
IsVirtualMember = indexer.IsVirtual || indexer.IsOverride,
918920
OverrideAccessModifier = overrideAccessModifier,
919921
GetterAccessModifier = GetAccessorAccessModifier(indexer.GetMethod, overrideAccessModifier, compilationAssembly),
920922
SetterAccessModifier = GetAccessorAccessModifier(indexer.SetMethod, overrideAccessModifier, compilationAssembly),

tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2382,6 +2382,45 @@ public class TestUsage
23822382
AssertNoGeneratedError(source, "CS0535");
23832383
}
23842384

2385+
[Test]
2386+
public Task Abstract_Class_With_Abstract_Indexer()
2387+
{
2388+
// Regression #6516: an abstract indexer took the virtual-member path and emitted a
2389+
// `base[...]` fallback — CS0205, there is no base implementation. Abstract indexers must
2390+
// dispatch through the engine only, like abstract methods/properties (DbDataReader shape).
2391+
var source = """
2392+
using TUnit.Mocks;
2393+
2394+
public abstract class Repository
2395+
{
2396+
public abstract object this[int index] { get; }
2397+
public abstract object this[string key] { get; }
2398+
public abstract string this[int a, int b] { get; set; }
2399+
public virtual string this[bool flag] { get => "base"; set { } }
2400+
}
2401+
2402+
public class TestUsage
2403+
{
2404+
void M()
2405+
{
2406+
var mock = Repository.Mock();
2407+
}
2408+
}
2409+
""";
2410+
2411+
var output = GetGeneratedOutput(source);
2412+
2413+
// Virtual indexer keeps its base fallback; abstract ones must not reference base at all.
2414+
AssertContains(output, "return base[flag];");
2415+
AssertDoesNotContain(output, "return base[index];");
2416+
AssertDoesNotContain(output, "return base[key];");
2417+
AssertDoesNotContain(output, "base[a, b]");
2418+
2419+
AssertNoGeneratedError(source, "CS0205");
2420+
2421+
return VerifyGeneratorOutput(source);
2422+
}
2423+
23852424
private static void AssertNoGeneratedError(string source, string errorId)
23862425
{
23872426
foreach (var diagnostic in GetGeneratedCompilationErrors(source))
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// <auto-generated/>
2+
#pragma warning disable
3+
#nullable enable
4+
5+
file sealed class RepositoryMockImpl : global::Repository, global::TUnit.Mocks.IRaisable, global::TUnit.Mocks.IMockObject
6+
{
7+
private readonly global::TUnit.Mocks.MockEngine<global::Repository> _engine;
8+
9+
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
10+
global::TUnit.Mocks.IMock? global::TUnit.Mocks.IMockObject.MockWrapper { get; set; }
11+
12+
[global::System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
13+
internal RepositoryMockImpl(global::TUnit.Mocks.MockEngine<global::Repository> engine) : base()
14+
{
15+
_engine = engine;
16+
}
17+
18+
public override object this[int index]
19+
{
20+
get => _engine.HandleCallWithReturn<object>(0, "get_Item", new object?[] { index }, default!);
21+
}
22+
23+
public override object this[string key]
24+
{
25+
get => _engine.HandleCallWithReturn<object>(1, "get_Item", new object?[] { key }, default!);
26+
}
27+
28+
public override string this[int a, int b]
29+
{
30+
get => _engine.HandleCallWithReturn<string>(2, "get_Item", new object?[] { a, b }, "");
31+
set => _engine.HandleCall(3, "set_Item", new object?[] { a, b, value });
32+
}
33+
34+
public override string this[bool flag]
35+
{
36+
get
37+
{
38+
if (_engine.TryHandleCallWithReturn<string>(4, "get_Item", new object?[] { flag }, "", out var __result))
39+
{
40+
return __result;
41+
}
42+
return base[flag];
43+
}
44+
set
45+
{
46+
if (!_engine.TryHandleCall(5, "set_Item", new object?[] { flag, value }))
47+
{
48+
base[flag] = value;
49+
}
50+
}
51+
}
52+
53+
[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
54+
public void RaiseEvent(string eventName, object? args)
55+
{
56+
throw new global::System.InvalidOperationException($"No event named '{eventName}' exists on this mock.");
57+
}
58+
}
59+
60+
file static class RepositoryPartialMockFactory
61+
{
62+
[global::System.Runtime.CompilerServices.ModuleInitializer]
63+
internal static void Register()
64+
{
65+
global::TUnit.Mocks.MockRegistry.RegisterFactory<global::Repository>(Create);
66+
}
67+
68+
private static global::TUnit.Mocks.Mock<global::Repository> Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs)
69+
{
70+
var engine = new global::TUnit.Mocks.MockEngine<global::Repository>(behavior);
71+
var impl = new RepositoryMockImpl(engine);
72+
engine.Raisable = impl;
73+
var mock = new global::TUnit.Mocks.Mock<global::Repository>(impl, engine);
74+
return mock;
75+
}
76+
}
77+
78+
79+
// ===== FILE SEPARATOR =====
80+
81+
// <auto-generated/>
82+
#pragma warning disable
83+
#nullable enable
84+
85+
namespace TUnit.Mocks.Generated
86+
{
87+
public static class Repository_MockMemberExtensions
88+
{
89+
public static global::TUnit.Mocks.MockMethodCall<object> Item(this global::TUnit.Mocks.Mock<global::Repository> mock, global::TUnit.Mocks.Arguments.Arg<int> index)
90+
{
91+
var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { index.Matcher };
92+
return new global::TUnit.Mocks.MockMethodCall<object>(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, "get_Item", matchers);
93+
}
94+
95+
public static global::TUnit.Mocks.MockMethodCall<object> Item(this global::TUnit.Mocks.Mock<global::Repository> mock, global::TUnit.Mocks.Arguments.Arg<string> key)
96+
{
97+
var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { key.Matcher };
98+
return new global::TUnit.Mocks.MockMethodCall<object>(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 1, "get_Item", matchers);
99+
}
100+
101+
public static global::TUnit.Mocks.MockMethodCall<string> Item(this global::TUnit.Mocks.Mock<global::Repository> mock, global::TUnit.Mocks.Arguments.Arg<int> a, global::TUnit.Mocks.Arguments.Arg<int> b)
102+
{
103+
var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { a.Matcher, b.Matcher };
104+
return new global::TUnit.Mocks.MockMethodCall<string>(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 2, "get_Item", matchers);
105+
}
106+
107+
public static global::TUnit.Mocks.VoidMockMethodCall SetItem(this global::TUnit.Mocks.Mock<global::Repository> mock, global::TUnit.Mocks.Arguments.Arg<int> a, global::TUnit.Mocks.Arguments.Arg<int> b, global::TUnit.Mocks.Arguments.Arg<string> value)
108+
{
109+
var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { a.Matcher, b.Matcher, value.Matcher };
110+
return new global::TUnit.Mocks.VoidMockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 3, "set_Item", matchers);
111+
}
112+
113+
public static global::TUnit.Mocks.MockMethodCall<string> Item(this global::TUnit.Mocks.Mock<global::Repository> mock, global::TUnit.Mocks.Arguments.Arg<bool> flag)
114+
{
115+
var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { flag.Matcher };
116+
return new global::TUnit.Mocks.MockMethodCall<string>(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 4, "get_Item", matchers);
117+
}
118+
119+
public static global::TUnit.Mocks.VoidMockMethodCall SetItem(this global::TUnit.Mocks.Mock<global::Repository> mock, global::TUnit.Mocks.Arguments.Arg<bool> flag, global::TUnit.Mocks.Arguments.Arg<string> value)
120+
{
121+
var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { flag.Matcher, value.Matcher };
122+
return new global::TUnit.Mocks.VoidMockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 5, "set_Item", matchers);
123+
}
124+
125+
#if NET9_0_OR_GREATER
126+
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
127+
public static void Reset(this global::TUnit.Mocks.Mock<global::Repository> mock)
128+
=> global::TUnit.Mocks.Mock.Reset(mock);
129+
130+
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
131+
public static void VerifyAll(this global::TUnit.Mocks.Mock<global::Repository> mock)
132+
=> global::TUnit.Mocks.Mock.VerifyAll(mock);
133+
134+
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
135+
public static void VerifyNoOtherCalls(this global::TUnit.Mocks.Mock<global::Repository> mock)
136+
=> global::TUnit.Mocks.Mock.VerifyNoOtherCalls(mock);
137+
138+
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
139+
public static void SetupAllProperties(this global::TUnit.Mocks.Mock<global::Repository> mock)
140+
=> global::TUnit.Mocks.Mock.SetupAllProperties(mock);
141+
142+
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
143+
public static global::TUnit.Mocks.Diagnostics.MockDiagnostics GetDiagnostics(this global::TUnit.Mocks.Mock<global::Repository> mock)
144+
=> global::TUnit.Mocks.Mock.GetDiagnostics(mock);
145+
146+
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
147+
public static void SetState(this global::TUnit.Mocks.Mock<global::Repository> mock, string? stateName)
148+
=> global::TUnit.Mocks.Mock.SetState(mock, stateName);
149+
150+
[global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)]
151+
public static void InState(this global::TUnit.Mocks.Mock<global::Repository> mock, string stateName, global::System.Action<global::TUnit.Mocks.Mock<global::Repository>> configure)
152+
=> global::TUnit.Mocks.Mock.InState(mock, stateName, configure);
153+
154+
extension(global::TUnit.Mocks.Mock<global::Repository> mock)
155+
{
156+
public global::System.Collections.Generic.IReadOnlyList<global::TUnit.Mocks.Verification.CallRecord> Invocations => global::TUnit.Mocks.Mock.Invocations(mock);
157+
158+
public global::TUnit.Mocks.MockBehavior Behavior => global::TUnit.Mocks.Mock.Behavior(mock);
159+
160+
public global::TUnit.Mocks.IDefaultValueProvider? DefaultValueProvider
161+
{
162+
get => global::TUnit.Mocks.Mock.GetDefaultValueProvider(mock);
163+
set => global::TUnit.Mocks.Mock.SetDefaultValueProvider(mock, value);
164+
}
165+
}
166+
#endif
167+
}
168+
}
169+
170+
171+
// ===== FILE SEPARATOR =====
172+
173+
// <auto-generated/>
174+
#pragma warning disable
175+
#nullable enable
176+
177+
namespace TUnit.Mocks
178+
{
179+
public static class Repository_MockStaticExtension
180+
{
181+
extension(global::Repository _)
182+
{
183+
public static global::TUnit.Mocks.Mock<global::Repository> Mock()
184+
{
185+
return global::TUnit.Mocks.Mock.Of<global::Repository>();
186+
}
187+
188+
public static global::TUnit.Mocks.Mock<global::Repository> Mock(global::TUnit.Mocks.MockBehavior behavior)
189+
{
190+
return global::TUnit.Mocks.Mock.Of<global::Repository>(behavior);
191+
}
192+
}
193+
}
194+
}
195+
196+
197+
// ===== FILE SEPARATOR =====
198+
199+
// <auto-generated/>
200+
#pragma warning disable
201+
#nullable enable
202+
203+
namespace TUnit.Mocks.Generated;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
using TUnit.Mocks;
2+
using TUnit.Mocks.Arguments;
3+
using TUnit.Mocks.Verification;
4+
5+
namespace TUnit.Mocks.Tests;
6+
7+
// Regression: https://github.com/thomhurst/TUnit/issues/6516
8+
// The indexer model never carried IsAbstractMember, so an abstract indexer on a mocked
9+
// abstract class took the virtual-member path and emitted a `return base[...];` /
10+
// `base[...] = value;` fallback — CS0205, there is no base implementation to call.
11+
// Abstract indexers must dispatch through the engine only, exactly like abstract
12+
// methods and abstract non-indexer properties already do.
13+
14+
#region Test types
15+
16+
// The issue's repro shape (mirrors DbDataReader's `this[int]` / `this[string]`).
17+
public abstract class AbstractIndexerRepository
18+
{
19+
public abstract object this[int index] { get; }
20+
public abstract object this[string key] { get; }
21+
}
22+
23+
// Exercises the abstract setter branch too.
24+
public abstract class AbstractReadWriteIndexer
25+
{
26+
public abstract string this[int index] { get; set; }
27+
}
28+
29+
// Virtual indexer must keep its base fallback.
30+
public class VirtualIndexerStore
31+
{
32+
public virtual string this[int index]
33+
{
34+
get => $"base-{index}";
35+
set { }
36+
}
37+
}
38+
39+
#endregion
40+
41+
public class Issue6516Tests
42+
{
43+
[Test]
44+
public async Task Abstract_GetOnly_Indexers_Compile_And_Return_Default_When_Unconfigured()
45+
{
46+
var mock = AbstractIndexerRepository.Mock();
47+
48+
await Assert.That(mock.Object[0]).IsNull();
49+
await Assert.That(mock.Object["missing"]).IsNull();
50+
}
51+
52+
[Test]
53+
public async Task Abstract_Indexer_Configured_Via_Setup_Returns_Value()
54+
{
55+
var mock = AbstractIndexerRepository.Mock();
56+
mock.Item(3).Returns("three");
57+
mock.Item("name").Returns("by-key");
58+
59+
await Assert.That(mock.Object[3]).IsEqualTo("three");
60+
await Assert.That(mock.Object["name"]).IsEqualTo("by-key");
61+
62+
mock.Item(3).WasCalled(Times.Once);
63+
mock.Item("name").WasCalled(Times.Once);
64+
}
65+
66+
[Test]
67+
public async Task Abstract_ReadWrite_Indexer_Setter_Dispatches_Through_Engine()
68+
{
69+
var mock = AbstractReadWriteIndexer.Mock();
70+
mock.Item(1).Returns("one");
71+
72+
await Assert.That(mock.Object[1]).IsEqualTo("one");
73+
74+
mock.Object[7] = "seven";
75+
76+
mock.SetItem(7, "seven").WasCalled(Times.Once);
77+
mock.SetItem(Any<int>(), Any<string>()).WasCalled(Times.Once);
78+
}
79+
80+
[Test]
81+
public async Task Virtual_Indexer_Still_Falls_Back_To_Base_When_Unconfigured()
82+
{
83+
var mock = VirtualIndexerStore.Mock();
84+
mock.Item(1).Returns("configured");
85+
86+
await Assert.That(mock.Object[1]).IsEqualTo("configured");
87+
await Assert.That(mock.Object[2]).IsEqualTo("base-2");
88+
}
89+
}

0 commit comments

Comments
 (0)