diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index 9795c2d090..4ecadf46bf 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -65,10 +65,16 @@ private static void BuildInterfaceMockImpl(CodeWriter writer, MockTypeModel mode // Properties — skip static abstract (they're in bridge DIMs) foreach (var prop in model.Properties) { - if (prop.IsIndexer) continue; if (prop.IsStaticAbstract) continue; writer.AppendLine(); - GenerateInterfaceProperty(writer, prop, model); + if (prop.IsIndexer) + { + GenerateInterfaceIndexer(writer, prop); + } + else + { + GenerateInterfaceProperty(writer, prop, model); + } } // Events — skip static abstract (they're in bridge DIMs) @@ -113,10 +119,16 @@ private static void BuildWrapMockImpl(CodeWriter writer, MockTypeModel model, st // Properties — skip static abstract (they're in bridge DIMs) foreach (var prop in model.Properties) { - if (prop.IsIndexer) continue; if (prop.IsStaticAbstract) continue; writer.AppendLine(); - GenerateWrapProperty(writer, prop, model); + if (prop.IsIndexer) + { + GenerateWrapIndexer(writer, prop); + } + else + { + GenerateWrapProperty(writer, prop, model); + } } // Events — skip static abstract (they're in bridge DIMs) @@ -451,10 +463,16 @@ private static void BuildPartialMockImpl(CodeWriter writer, MockTypeModel model, // Properties — skip static abstract (they're in bridge DIMs) foreach (var prop in model.Properties) { - if (prop.IsIndexer) continue; if (prop.IsStaticAbstract) continue; writer.AppendLine(); - GeneratePartialProperty(writer, prop, model); + if (prop.IsIndexer) + { + GeneratePartialIndexer(writer, prop); + } + else + { + GeneratePartialProperty(writer, prop, model); + } } // Events — skip static abstract (they're in bridge DIMs) @@ -1011,6 +1029,106 @@ private static void GeneratePartialProperty(CodeWriter writer, MockMemberModel p writer.CloseBrace(); } + private static void GenerateInterfaceIndexer(CodeWriter writer, MockMemberModel prop) + { + var paramList = FormatIndexerParameterList(prop); + writer.AppendLineIfNotEmpty(prop.ObsoleteAttribute); + writer.AppendLine($"public {prop.ReturnType} this[{paramList}]"); + writer.OpenBrace(); + + if (prop.HasGetter) + { + var argsArray = GetIndexerGetterArgsArray(prop); + writer.AppendLineIfNotEmpty(prop.GetterObsoleteAttribute); + writer.AppendLine($"get => _engine.HandleCallWithReturn<{prop.ReturnType}>({prop.MemberId}, \"get_Item\", {argsArray}, {prop.SmartDefault});"); + } + + if (prop.HasSetter) + { + var setterArgs = GetIndexerSetterArgsArray(prop); + writer.AppendLineIfNotEmpty(prop.SetterObsoleteAttribute); + writer.AppendLine($"set => _engine.HandleCall({prop.SetterMemberId}, \"set_Item\", {setterArgs});"); + } + + writer.CloseBrace(); + } + + private static void GeneratePartialIndexer(CodeWriter writer, MockMemberModel prop) + => GenerateOverrideIndexer(writer, prop, fallbackTarget: "base"); + + private static void GenerateWrapIndexer(CodeWriter writer, MockMemberModel prop) + => GenerateOverrideIndexer(writer, prop, fallbackTarget: "_wrappedInstance"); + + private static void GenerateOverrideIndexer(CodeWriter writer, MockMemberModel prop, string fallbackTarget) + { + var accessModifier = prop.IsProtected ? "protected" : "public"; + var paramList = FormatIndexerParameterList(prop); + var argPassList = string.Join(", ", prop.Parameters.Select(p => p.Name)); + writer.AppendLineIfNotEmpty(prop.ObsoleteAttribute); + writer.AppendLine($"{accessModifier} override {prop.ReturnType} this[{paramList}]"); + writer.OpenBrace(); + + if (prop.HasGetter) + { + var argsArray = GetIndexerGetterArgsArray(prop); + writer.AppendLineIfNotEmpty(prop.GetterObsoleteAttribute); + if (prop.IsAbstractMember) + { + writer.AppendLine($"get => _engine.HandleCallWithReturn<{prop.ReturnType}>({prop.MemberId}, \"get_Item\", {argsArray}, {prop.SmartDefault});"); + } + else + { + writer.AppendLine("get"); + writer.OpenBrace(); + writer.AppendLine($"if (_engine.TryHandleCallWithReturn<{prop.ReturnType}>({prop.MemberId}, \"get_Item\", {argsArray}, {prop.SmartDefault}, out var __result))"); + writer.OpenBrace(); + writer.AppendLine("return __result;"); + writer.CloseBrace(); + writer.AppendLine($"return {fallbackTarget}[{argPassList}];"); + writer.CloseBrace(); + } + } + + if (prop.HasSetter) + { + var setterArgs = GetIndexerSetterArgsArray(prop); + writer.AppendLineIfNotEmpty(prop.SetterObsoleteAttribute); + if (prop.IsAbstractMember) + { + writer.AppendLine($"set => _engine.HandleCall({prop.SetterMemberId}, \"set_Item\", {setterArgs});"); + } + else + { + writer.AppendLine("set"); + writer.OpenBrace(); + writer.AppendLine($"if (!_engine.TryHandleCall({prop.SetterMemberId}, \"set_Item\", {setterArgs}))"); + writer.OpenBrace(); + writer.AppendLine($"{fallbackTarget}[{argPassList}] = value;"); + writer.CloseBrace(); + writer.CloseBrace(); + } + } + + writer.CloseBrace(); + } + + private static string FormatIndexerParameterList(MockMemberModel indexer) + => FormatParameterList(indexer.Parameters); + + private static string GetIndexerGetterArgsArray(MockMemberModel indexer) + { + if (indexer.Parameters.Length == 0) return "global::System.Array.Empty()"; + var args = string.Join(", ", indexer.Parameters.Select(p => p.Name)); + return $"new object?[] {{ {args} }}"; + } + + private static string GetIndexerSetterArgsArray(MockMemberModel indexer) + { + if (indexer.Parameters.Length == 0) return "new object?[] { value }"; + var args = string.Join(", ", indexer.Parameters.Select(p => p.Name)) + ", value"; + return $"new object?[] {{ {args} }}"; + } + private static void GenerateEvent(CodeWriter writer, MockEventModel evt) { // Backing delegate field diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index 43fce63d63..d68b400a77 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -73,6 +73,19 @@ public static string Build(MockTypeModel model) GeneratePropertyExtensionBlock(writer, memberProps, model, safeName); } + // Indexers -- expose as Item(...)/SetItem(..., value) extension methods so + // setups and verifications can target distinct index values independently. + // Each indexer overload (different parameter signature) gets its own pair. + var indexers = model.Properties + .Where(p => p.IsIndexer && !p.IsStaticAbstract) + .ToList(); + foreach (var indexer in indexers) + { + if (!firstMember) writer.AppendLine(); + firstMember = false; + GenerateIndexerExtensionMethods(writer, indexer, model); + } + // Raise extension methods for events (skip static abstract) if (instanceEvents.Length > 0) { @@ -1029,6 +1042,54 @@ private static void GeneratePropertyExtensionBlock(CodeWriter writer, List + /// Emits Item(args...) and (optionally) SetItem(args..., value) extension methods + /// for an indexer, returning / + /// so that callers can configure (.Returns()) and verify (.WasCalled()) per-index. + /// + private static void GenerateIndexerExtensionMethods(CodeWriter writer, MockMemberModel indexer, MockTypeModel model) + { + var mockableType = MockImplBuilder.GetMockableTypeName(model); + var typeParams = MockImplBuilder.GetTypeParameterList(model); + var constraints = MockImplBuilder.GetConstraintClauses(model); + var extensionParam = $"this global::TUnit.Mocks.Mock<{mockableType}> mock"; + + // Indexer parameters as Arg so callers can pass a literal, Arg.Any(), etc. + var argParams = string.Join(", ", indexer.Parameters.Select(p => + $"global::TUnit.Mocks.Arguments.Arg<{p.FullyQualifiedType}> {p.Name}")); + var matcherList = indexer.Parameters.Length == 0 + ? "global::System.Array.Empty()" + : $"new global::TUnit.Mocks.Arguments.IArgumentMatcher[] {{ {string.Join(", ", indexer.Parameters.Select(p => $"{p.Name}.Matcher"))} }}"; + + if (indexer.HasGetter) + { + writer.AppendLineIfNotEmpty(indexer.ObsoleteAttribute); + var getterParams = string.IsNullOrEmpty(argParams) ? extensionParam : $"{extensionParam}, {argParams}"; + using (writer.Block($"public static global::TUnit.Mocks.MockMethodCall<{indexer.ReturnType}> Item{typeParams}({getterParams}){constraints}")) + { + writer.AppendLine($"var matchers = {matcherList};"); + writer.AppendLine($"return new global::TUnit.Mocks.MockMethodCall<{indexer.ReturnType}>(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {indexer.MemberId}, \"get_Item\", matchers);"); + } + } + + if (indexer.HasSetter) + { + if (indexer.HasGetter) writer.AppendLine(); + writer.AppendLineIfNotEmpty(indexer.ObsoleteAttribute); + var valueParam = $"global::TUnit.Mocks.Arguments.Arg<{indexer.ReturnType}> value"; + var setterArgParams = string.IsNullOrEmpty(argParams) ? valueParam : $"{argParams}, {valueParam}"; + var setterParams = $"{extensionParam}, {setterArgParams}"; + var setterMatcherList = indexer.Parameters.Length == 0 + ? "new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { value.Matcher }" + : $"new global::TUnit.Mocks.Arguments.IArgumentMatcher[] {{ {string.Join(", ", indexer.Parameters.Select(p => $"{p.Name}.Matcher"))}, value.Matcher }}"; + using (writer.Block($"public static global::TUnit.Mocks.VoidMockMethodCall SetItem{typeParams}({setterParams}){constraints}")) + { + writer.AppendLine($"var matchers = {setterMatcherList};"); + writer.AppendLine($"return new global::TUnit.Mocks.VoidMockMethodCall(global::TUnit.Mocks.MockRegistry.GetEngine(mock), {indexer.SetterMemberId}, \"set_Item\", matchers);"); + } + } + } + private static void GenerateRaiseExtensionMethods(CodeWriter writer, MockTypeModel model) { var mockableType = MockImplBuilder.GetMockableTypeName(model); diff --git a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs index 512ad51dc6..ed4c32c70f 100644 --- a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs +++ b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs @@ -777,7 +777,7 @@ private static MockMemberModel CreateIndexerModel(IPropertySymbol indexer, ref i Name = EscapeIdentifier(p.Name), Type = p.Type.GetMinimallyQualifiedNameWithNullability(), FullyQualifiedType = p.Type.GetFullyQualifiedNameWithNullability(), - Direction = ParameterDirection.In + Direction = p.GetParameterDirection() }).ToImmutableArray() ), ExplicitInterfaceName = explicitInterfaceName, diff --git a/TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs b/TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs index 0421541d1c..2087e874d7 100644 --- a/TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs +++ b/TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs @@ -165,10 +165,22 @@ public interface ITagLarge : ITagSmall new long Tag { get; } } -// ─── T14 SKIPPED. Interfaces with an indexer produce CS0535 -// ("does not implement IHasIndexer.this[int]") because the mock-impl builder -// skips indexer emission without providing a stub. Tracked as a separate -// generator gap — not in scope of the #5673 fix. +// ─── T14. Interface with indexer ──────────────────────────────────────────── + +public interface IHasIndexer +{ + string this[int index] { get; set; } + int Regular { get; set; } +} + +// T14b. Indexer with `in` parameter — exercises modifier forwarding +// in FormatIndexerParameterList. `in` is the only ref-kind C# permits on +// indexer parameters. +public interface IHasInIndexer +{ + string this[in int key] { get; } +} + // ─── T15 SKIPPED. Mocking a class that implements a static-abstract interface // hits the bridge builder, which treats the target as an interface ("Type // in interface list is not an interface"). Separate generator issue. @@ -433,7 +445,55 @@ public async Task T13_Derived_Interface_New_Property_Redeclaration() await Assert.That(asSmall.Tag).IsEqualTo((short)0); } - // T14, T15 tests elided — see the SKIPPED notes above the type declarations. + // ── T14 ── + + [Test] + public async Task T14_Interface_With_Indexer_Compiles_Regular_Property_Works() + { + var mock = IHasIndexer.Mock(); + mock.Regular.Returns(123); + + await Assert.That(mock.Object.Regular).IsEqualTo(123); + mock.Regular.WasCalled(Times.Once); + } + + [Test] + public async Task T14_Interface_With_Indexer_Get_Set_Configurable_And_Verifiable() + { + var mock = IHasIndexer.Mock(); + mock.Item(0).Returns("zero"); + mock.Item(1).Returns("one"); + + await Assert.That(mock.Object[0]).IsEqualTo("zero"); + await Assert.That(mock.Object[1]).IsEqualTo("one"); + await Assert.That(mock.Object[0]).IsEqualTo("zero"); + + mock.Object[5] = "five"; + mock.Object[5] = "five-again"; + mock.Object[6] = "six"; + + // Distinct index values produce independent setups (verified by the get_*). + mock.Item(0).WasCalled(Times.Exactly(2)); + mock.Item(1).WasCalled(Times.Once); + + // Setter verification per index value. + mock.SetItem(5, Any()).WasCalled(Times.Exactly(2)); + mock.SetItem(6, "six").WasCalled(Times.Once); + mock.SetItem(Any(), Any()).WasCalled(Times.Exactly(3)); + } + + [Test] + public async Task T14b_Indexer_With_In_Parameter_Compiles_And_Dispatches() + { + var mock = IHasInIndexer.Mock(); + mock.Item(7).Returns("seven"); + + var k = 7; + await Assert.That(mock.Object[in k]).IsEqualTo("seven"); + mock.Item(7).WasCalled(Times.Once); + } + + // T15 test elided — see the SKIPPED note above the type declarations. // ── T16 ──