From 95a702ecd6f4e5647d880c3ae38b22195062928a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:47:11 +0100 Subject: [PATCH] fix(mocks): dispatch abstract indexers through the engine instead of calling base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Discovery/MemberDiscovery.cs | 2 + .../MockGeneratorTests.cs | 39 ++++ ...t_Class_With_Abstract_Indexer.verified.txt | 203 ++++++++++++++++++ tests/TUnit.Mocks.Tests/Issue6516Tests.cs | 89 ++++++++ 4 files changed, 333 insertions(+) create mode 100644 tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Abstract_Class_With_Abstract_Indexer.verified.txt create mode 100644 tests/TUnit.Mocks.Tests/Issue6516Tests.cs diff --git a/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs b/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs index efee1ed4f4..7230a05466 100644 --- a/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs +++ b/src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs @@ -915,6 +915,8 @@ private static MockMemberModel CreateIndexerModel(IPropertySymbol indexer, ref i DeclaringInterfaceName = declaringInterfaceName, NullableAnnotation = indexer.Type.NullableAnnotation.ToString(), SmartDefault = indexer.Type.GetSmartDefault(indexer.Type.IsNullableAnnotated()), + IsAbstractMember = indexer.IsAbstract, + IsVirtualMember = indexer.IsVirtual || indexer.IsOverride, OverrideAccessModifier = overrideAccessModifier, GetterAccessModifier = GetAccessorAccessModifier(indexer.GetMethod, overrideAccessModifier, compilationAssembly), SetterAccessModifier = GetAccessorAccessModifier(indexer.SetMethod, overrideAccessModifier, compilationAssembly), diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs index cff8d39625..ef4e9590c7 100644 --- a/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs @@ -2382,6 +2382,45 @@ public class TestUsage AssertNoGeneratedError(source, "CS0535"); } + [Test] + public Task Abstract_Class_With_Abstract_Indexer() + { + // Regression #6516: an abstract indexer took the virtual-member path and emitted a + // `base[...]` fallback — CS0205, there is no base implementation. Abstract indexers must + // dispatch through the engine only, like abstract methods/properties (DbDataReader shape). + var source = """ + using TUnit.Mocks; + + public abstract class Repository + { + public abstract object this[int index] { get; } + public abstract object this[string key] { get; } + public abstract string this[int a, int b] { get; set; } + public virtual string this[bool flag] { get => "base"; set { } } + } + + public class TestUsage + { + void M() + { + var mock = Repository.Mock(); + } + } + """; + + var output = GetGeneratedOutput(source); + + // Virtual indexer keeps its base fallback; abstract ones must not reference base at all. + AssertContains(output, "return base[flag];"); + AssertDoesNotContain(output, "return base[index];"); + AssertDoesNotContain(output, "return base[key];"); + AssertDoesNotContain(output, "base[a, b]"); + + AssertNoGeneratedError(source, "CS0205"); + + return VerifyGeneratorOutput(source); + } + private static void AssertNoGeneratedError(string source, string errorId) { foreach (var diagnostic in GetGeneratedCompilationErrors(source)) diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Abstract_Class_With_Abstract_Indexer.verified.txt b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Abstract_Class_With_Abstract_Indexer.verified.txt new file mode 100644 index 0000000000..60d9f89cb1 --- /dev/null +++ b/tests/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Abstract_Class_With_Abstract_Indexer.verified.txt @@ -0,0 +1,203 @@ +// +#pragma warning disable +#nullable enable + +file sealed class RepositoryMockImpl : global::Repository, global::TUnit.Mocks.IRaisable, global::TUnit.Mocks.IMockObject +{ + private readonly global::TUnit.Mocks.MockEngine _engine; + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + global::TUnit.Mocks.IMock? global::TUnit.Mocks.IMockObject.MockWrapper { get; set; } + + [global::System.Diagnostics.CodeAnalysis.SetsRequiredMembers] + internal RepositoryMockImpl(global::TUnit.Mocks.MockEngine engine) : base() + { + _engine = engine; + } + + public override object this[int index] + { + get => _engine.HandleCallWithReturn(0, "get_Item", new object?[] { index }, default!); + } + + public override object this[string key] + { + get => _engine.HandleCallWithReturn(1, "get_Item", new object?[] { key }, default!); + } + + public override string this[int a, int b] + { + get => _engine.HandleCallWithReturn(2, "get_Item", new object?[] { a, b }, ""); + set => _engine.HandleCall(3, "set_Item", new object?[] { a, b, value }); + } + + public override string this[bool flag] + { + get + { + if (_engine.TryHandleCallWithReturn(4, "get_Item", new object?[] { flag }, "", out var __result)) + { + return __result; + } + return base[flag]; + } + set + { + if (!_engine.TryHandleCall(5, "set_Item", new object?[] { flag, value })) + { + base[flag] = value; + } + } + } + + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public void RaiseEvent(string eventName, object? args) + { + throw new global::System.InvalidOperationException($"No event named '{eventName}' exists on this mock."); + } +} + +file static class RepositoryPartialMockFactory +{ + [global::System.Runtime.CompilerServices.ModuleInitializer] + internal static void Register() + { + global::TUnit.Mocks.MockRegistry.RegisterFactory(Create); + } + + private static global::TUnit.Mocks.Mock Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs) + { + var engine = new global::TUnit.Mocks.MockEngine(behavior); + var impl = new RepositoryMockImpl(engine); + engine.Raisable = impl; + var mock = new global::TUnit.Mocks.Mock(impl, engine); + return mock; + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks.Generated +{ + public static class Repository_MockMemberExtensions + { + public static global::TUnit.Mocks.MockMethodCall Item(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.Arg index) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { index.Matcher }; + return new global::TUnit.Mocks.MockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, "get_Item", matchers); + } + + public static global::TUnit.Mocks.MockMethodCall Item(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.Arg key) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { key.Matcher }; + return new global::TUnit.Mocks.MockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 1, "get_Item", matchers); + } + + public static global::TUnit.Mocks.MockMethodCall Item(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.Arg a, global::TUnit.Mocks.Arguments.Arg b) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { a.Matcher, b.Matcher }; + return new global::TUnit.Mocks.MockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 2, "get_Item", matchers); + } + + public static global::TUnit.Mocks.VoidMockMethodCall SetItem(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.Arg a, global::TUnit.Mocks.Arguments.Arg b, global::TUnit.Mocks.Arguments.Arg value) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { a.Matcher, b.Matcher, value.Matcher }; + return new global::TUnit.Mocks.VoidMockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 3, "set_Item", matchers); + } + + public static global::TUnit.Mocks.MockMethodCall Item(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.Arg flag) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { flag.Matcher }; + return new global::TUnit.Mocks.MockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 4, "get_Item", matchers); + } + + public static global::TUnit.Mocks.VoidMockMethodCall SetItem(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.Arg flag, global::TUnit.Mocks.Arguments.Arg value) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { flag.Matcher, value.Matcher }; + return new global::TUnit.Mocks.VoidMockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 5, "set_Item", matchers); + } + + #if NET9_0_OR_GREATER + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void Reset(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.Reset(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void VerifyAll(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.VerifyAll(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void VerifyNoOtherCalls(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.VerifyNoOtherCalls(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void SetupAllProperties(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.SetupAllProperties(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static global::TUnit.Mocks.Diagnostics.MockDiagnostics GetDiagnostics(this global::TUnit.Mocks.Mock mock) + => global::TUnit.Mocks.Mock.GetDiagnostics(mock); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void SetState(this global::TUnit.Mocks.Mock mock, string? stateName) + => global::TUnit.Mocks.Mock.SetState(mock, stateName); + + [global::System.Runtime.CompilerServices.OverloadResolutionPriority(-1)] + public static void InState(this global::TUnit.Mocks.Mock mock, string stateName, global::System.Action> configure) + => global::TUnit.Mocks.Mock.InState(mock, stateName, configure); + + extension(global::TUnit.Mocks.Mock mock) + { + public global::System.Collections.Generic.IReadOnlyList Invocations => global::TUnit.Mocks.Mock.Invocations(mock); + + public global::TUnit.Mocks.MockBehavior Behavior => global::TUnit.Mocks.Mock.Behavior(mock); + + public global::TUnit.Mocks.IDefaultValueProvider? DefaultValueProvider + { + get => global::TUnit.Mocks.Mock.GetDefaultValueProvider(mock); + set => global::TUnit.Mocks.Mock.SetDefaultValueProvider(mock, value); + } + } + #endif + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks +{ + public static class Repository_MockStaticExtension + { + extension(global::Repository _) + { + public static global::TUnit.Mocks.Mock Mock() + { + return global::TUnit.Mocks.Mock.Of(); + } + + public static global::TUnit.Mocks.Mock Mock(global::TUnit.Mocks.MockBehavior behavior) + { + return global::TUnit.Mocks.Mock.Of(behavior); + } + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#pragma warning disable +#nullable enable + +namespace TUnit.Mocks.Generated; \ No newline at end of file diff --git a/tests/TUnit.Mocks.Tests/Issue6516Tests.cs b/tests/TUnit.Mocks.Tests/Issue6516Tests.cs new file mode 100644 index 0000000000..dee5e16e2e --- /dev/null +++ b/tests/TUnit.Mocks.Tests/Issue6516Tests.cs @@ -0,0 +1,89 @@ +using TUnit.Mocks; +using TUnit.Mocks.Arguments; +using TUnit.Mocks.Verification; + +namespace TUnit.Mocks.Tests; + +// Regression: https://github.com/thomhurst/TUnit/issues/6516 +// The indexer model never carried IsAbstractMember, so an abstract indexer on a mocked +// abstract class took the virtual-member path and emitted a `return base[...];` / +// `base[...] = value;` fallback — CS0205, there is no base implementation to call. +// Abstract indexers must dispatch through the engine only, exactly like abstract +// methods and abstract non-indexer properties already do. + +#region Test types + +// The issue's repro shape (mirrors DbDataReader's `this[int]` / `this[string]`). +public abstract class AbstractIndexerRepository +{ + public abstract object this[int index] { get; } + public abstract object this[string key] { get; } +} + +// Exercises the abstract setter branch too. +public abstract class AbstractReadWriteIndexer +{ + public abstract string this[int index] { get; set; } +} + +// Virtual indexer must keep its base fallback. +public class VirtualIndexerStore +{ + public virtual string this[int index] + { + get => $"base-{index}"; + set { } + } +} + +#endregion + +public class Issue6516Tests +{ + [Test] + public async Task Abstract_GetOnly_Indexers_Compile_And_Return_Default_When_Unconfigured() + { + var mock = AbstractIndexerRepository.Mock(); + + await Assert.That(mock.Object[0]).IsNull(); + await Assert.That(mock.Object["missing"]).IsNull(); + } + + [Test] + public async Task Abstract_Indexer_Configured_Via_Setup_Returns_Value() + { + var mock = AbstractIndexerRepository.Mock(); + mock.Item(3).Returns("three"); + mock.Item("name").Returns("by-key"); + + await Assert.That(mock.Object[3]).IsEqualTo("three"); + await Assert.That(mock.Object["name"]).IsEqualTo("by-key"); + + mock.Item(3).WasCalled(Times.Once); + mock.Item("name").WasCalled(Times.Once); + } + + [Test] + public async Task Abstract_ReadWrite_Indexer_Setter_Dispatches_Through_Engine() + { + var mock = AbstractReadWriteIndexer.Mock(); + mock.Item(1).Returns("one"); + + await Assert.That(mock.Object[1]).IsEqualTo("one"); + + mock.Object[7] = "seven"; + + mock.SetItem(7, "seven").WasCalled(Times.Once); + mock.SetItem(Any(), Any()).WasCalled(Times.Once); + } + + [Test] + public async Task Virtual_Indexer_Still_Falls_Back_To_Base_When_Unconfigured() + { + var mock = VirtualIndexerStore.Mock(); + mock.Item(1).Returns("configured"); + + await Assert.That(mock.Object[1]).IsEqualTo("configured"); + await Assert.That(mock.Object[2]).IsEqualTo("base-2"); + } +}