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
130 changes: 124 additions & 6 deletions TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<object?>()";
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
Expand Down
61 changes: 61 additions & 0 deletions TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -1029,6 +1042,54 @@ private static void GeneratePropertyExtensionBlock(CodeWriter writer, List<MockM
}
}

/// <summary>
/// Emits <c>Item(args...)</c> and (optionally) <c>SetItem(args..., value)</c> extension methods
/// for an indexer, returning <see cref="MockMethodCall{TReturn}"/> / <see cref="VoidMockMethodCall"/>
/// so that callers can configure (<c>.Returns()</c>) and verify (<c>.WasCalled()</c>) per-index.
/// </summary>
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<T> so callers can pass a literal, Arg.Any<T>(), 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<global::TUnit.Mocks.Arguments.IArgumentMatcher>()"
: $"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);
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
70 changes: 65 additions & 5 deletions TUnit.Mocks.Tests/KitchenSinkEdgeCasesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string>()).WasCalled(Times.Exactly(2));
mock.SetItem(6, "six").WasCalled(Times.Once);
mock.SetItem(Any<int>(), Any<string>()).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 ──

Expand Down
Loading