From 71f11230f353c95fecd52672171f2c176928e91e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:05:14 +0000 Subject: [PATCH 1/2] feat(mocks): handle ref struct parameters and return types gracefully The source generator now properly handles interfaces with ref struct members (e.g. ReadOnlySpan) instead of generating uncompilable code that tries to box them into object[] arrays. How it works: - Ref struct parameters are excluded from the args array and argument matchers. Non-ref-struct params on the same method still support full argument matching. - Methods with ref struct params route through the engine normally for setup (Returns, Throws, Callback) and verification (WasCalled). - Ref struct return types use the void dispatch path (HandleCall) since they can't be generic type arguments, then return default. - Properties with ref struct types are handled in MockImpl but excluded from MockMembers extensions (can't create PropertyMockCall). --- .../MockGeneratorTests.cs | 26 +++ ...ace_With_RefStruct_Parameters.verified.txt | 93 +++++++++ .../Builders/MockImplBuilder.cs | 62 +++++- .../Builders/MockMembersBuilder.cs | 41 ++-- .../Discovery/MemberDiscovery.cs | 9 +- .../Models/MockMemberModel.cs | 4 +- .../Models/MockParameterModel.cs | 5 +- TUnit.Mocks.Tests/RefStructTests.cs | 182 ++++++++++++++++++ 8 files changed, 396 insertions(+), 26 deletions(-) create mode 100644 TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_RefStruct_Parameters.verified.txt create mode 100644 TUnit.Mocks.Tests/RefStructTests.cs diff --git a/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs b/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs index 6e42e2a96f..076d5b0fd8 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs +++ b/TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs @@ -242,6 +242,32 @@ void M() return VerifyGeneratorOutput(source); } + [Test] + public Task Interface_With_RefStruct_Parameters() + { + var source = """ + using System; + using TUnit.Mocks; + + public interface IBufferProcessor + { + void Process(ReadOnlySpan data); + int Parse(ReadOnlySpan text); + string GetName(); + } + + public class TestUsage + { + void M() + { + var mock = Mock.Of(); + } + } + """; + + return VerifyGeneratorOutput(source); + } + [Test] public Task Interface_With_Mixed_Members() { diff --git a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_RefStruct_Parameters.verified.txt b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_RefStruct_Parameters.verified.txt new file mode 100644 index 0000000000..bfb4a1902d --- /dev/null +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_RefStruct_Parameters.verified.txt @@ -0,0 +1,93 @@ +// +#nullable enable + +namespace TUnit.Mocks.Generated +{ + internal static class IBufferProcessor_MockFactory + { + [global::System.Runtime.CompilerServices.ModuleInitializer] + internal static void Register() + { + global::TUnit.Mocks.Mock.RegisterFactory(Create); + } + + private static global::TUnit.Mocks.Mock Create(global::TUnit.Mocks.MockBehavior behavior) + { + var engine = new global::TUnit.Mocks.MockEngine(behavior); + var impl = new IBufferProcessor_MockImpl(engine); + engine.Raisable = impl; + var mock = new global::TUnit.Mocks.Mock(impl, engine); + return mock; + } + } +} + + +// ===== FILE SEPARATOR ===== + +// +#nullable enable + +namespace TUnit.Mocks.Generated +{ + internal sealed class IBufferProcessor_MockImpl : global::IBufferProcessor, global::TUnit.Mocks.IRaisable + { + private readonly global::TUnit.Mocks.MockEngine _engine; + + internal IBufferProcessor_MockImpl(global::TUnit.Mocks.MockEngine engine) + { + _engine = engine; + } + + public void Process(global::System.ReadOnlySpan data) + { + _engine.HandleCall(0, "Process", global::System.Array.Empty()); + } + + public int Parse(global::System.ReadOnlySpan text) + { + return _engine.HandleCallWithReturn(1, "Parse", global::System.Array.Empty(), default); + } + + public string GetName() + { + return _engine.HandleCallWithReturn(2, "GetName", global::System.Array.Empty(), ""); + } + + [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 SEPARATOR ===== + +// +#nullable enable + +namespace TUnit.Mocks.Generated +{ + public static class IBufferProcessor_MockMemberExtensions + { + public static global::TUnit.Mocks.VoidMockMethodCall Process(this global::TUnit.Mocks.Mock mock) + { + var matchers = global::System.Array.Empty(); + return new global::TUnit.Mocks.VoidMockMethodCall(mock.Engine, 0, "Process", matchers); + } + + public static global::TUnit.Mocks.MockMethodCall Parse(this global::TUnit.Mocks.Mock mock) + { + var matchers = global::System.Array.Empty(); + return new global::TUnit.Mocks.MockMethodCall(mock.Engine, 1, "Parse", matchers); + } + + public static global::TUnit.Mocks.MockMethodCall GetName(this global::TUnit.Mocks.Mock mock) + { + var matchers = global::System.Array.Empty(); + return new global::TUnit.Mocks.MockMethodCall(mock.Engine, 2, "GetName", matchers); + } + } +} diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index 646e8b3bfa..94b4678166 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -238,6 +238,17 @@ private static void GenerateWrapMethodBody(CodeWriter writer, MockMemberModel me writer.AppendLine("}"); writer.AppendLine($"return _wrappedInstance.{method.Name}({argPassList});"); } + else if (method.IsRefStructReturn) + { + writer.AppendLine($"if (_engine.TryHandleCall({method.MemberId}, \"{method.Name}\", {argsArray}))"); + writer.AppendLine("{"); + writer.IncreaseIndent(); + EmitOutRefReadback(writer, method); + writer.AppendLine("return default;"); + writer.DecreaseIndent(); + writer.AppendLine("}"); + writer.AppendLine($"return _wrappedInstance.{method.Name}({argPassList});"); + } else { writer.AppendLine($"if (_engine.TryHandleCallWithReturn<{method.ReturnType}>({method.MemberId}, \"{method.Name}\", {argsArray}, {method.SmartDefault}, out var __result))"); @@ -475,6 +486,18 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel writer.AppendLine("}"); writer.AppendLine($"return base.{method.Name}({argPassList});"); } + else if (method.IsRefStructReturn) + { + // synchronous method returning ref struct — use void dispatch, fall back to base + writer.AppendLine($"if (_engine.TryHandleCall({method.MemberId}, \"{method.Name}\", {argsArray}))"); + writer.AppendLine("{"); + writer.IncreaseIndent(); + EmitOutRefReadback(writer, method); + writer.AppendLine("return default;"); + writer.DecreaseIndent(); + writer.AppendLine("}"); + writer.AppendLine($"return base.{method.Name}({argPassList});"); + } else { // synchronous method with return value @@ -566,6 +589,15 @@ private static void GenerateEngineDispatchBody(CodeWriter writer, MockMemberMode } } } + else if (method.IsRefStructReturn) + { + // Synchronous method returning a ref struct — can't use HandleCallWithReturn because + // ref structs can't be generic type arguments. Use void dispatch for call tracking, + // callbacks, and throws. Return default (e.g. ReadOnlySpan.Empty). + writer.AppendLine($"_engine.HandleCall({method.MemberId}, \"{method.Name}\", {argsArray});"); + EmitOutRefReadback(writer, method); + writer.AppendLine("return default;"); + } else { // Synchronous method with return value — need to read back out/ref before returning @@ -589,12 +621,32 @@ private static void GenerateInterfaceProperty(CodeWriter writer, MockMemberModel if (prop.HasGetter) { - writer.AppendLine($"get => _engine.HandleCallWithReturn<{prop.ReturnType}>({prop.MemberId}, \"get_{prop.Name}\", global::System.Array.Empty(), {prop.SmartDefault});"); + if (prop.IsRefStructReturn) + { + // ref struct property — can't use HandleCallWithReturn, use void dispatch + return default + writer.AppendLine("get"); + writer.OpenBrace(); + writer.AppendLine($"_engine.HandleCall({prop.MemberId}, \"get_{prop.Name}\", global::System.Array.Empty());"); + writer.AppendLine("return default;"); + writer.CloseBrace(); + } + else + { + writer.AppendLine($"get => _engine.HandleCallWithReturn<{prop.ReturnType}>({prop.MemberId}, \"get_{prop.Name}\", global::System.Array.Empty(), {prop.SmartDefault});"); + } } if (prop.HasSetter) { - writer.AppendLine($"set => _engine.HandleCall({prop.SetterMemberId}, \"set_{prop.Name}\", new object?[] {{ value }});"); + if (prop.IsRefStructReturn) + { + // ref struct property — can't box value, use empty args + writer.AppendLine($"set => _engine.HandleCall({prop.SetterMemberId}, \"set_{prop.Name}\", global::System.Array.Empty());"); + } + else + { + writer.AppendLine($"set => _engine.HandleCall({prop.SetterMemberId}, \"set_{prop.Name}\", new object?[] {{ value }});"); + } } writer.CloseBrace(); @@ -838,6 +890,7 @@ private static void EmitOutRefReadback(CodeWriter writer, MockMemberModel method for (int i = 0; i < method.Parameters.Length; i++) { var p = method.Parameters[i]; + if (p.IsRefStruct) continue; // ref structs can't be cast from object if (p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref) { writer.AppendLine($"if (__outRef.TryGetValue({i}, out var __v{i})) {p.Name} = ({p.FullyQualifiedType})__v{i}!;"); @@ -848,8 +901,9 @@ private static void EmitOutRefReadback(CodeWriter writer, MockMemberModel method private static string GetArgsArrayExpression(MockMemberModel method) { - // Only include non-out parameters in args array - var matchableParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList(); + // Only include non-out, non-ref-struct parameters in args array + // (ref structs cannot be boxed into object?[]) + var matchableParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList(); if (matchableParams.Count == 0) return "global::System.Array.Empty()"; var args = string.Join(", ", matchableParams.Select(p => p.Name)); return $"new object?[] {{ {args} }}"; diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index 7d0c07328b..725e364b9c 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -48,8 +48,9 @@ public static string Build(MockTypeModel model) } // Properties -- extension properties via C# 14 extension blocks + // (skip ref struct properties — can't use PropertyMockCall) var memberProps = model.Properties - .Where(p => !p.IsIndexer && (p.HasGetter || p.HasSetter)) + .Where(p => !p.IsIndexer && !p.IsRefStructReturn && (p.HasGetter || p.HasSetter)) .ToList(); if (memberProps.Count > 0) { @@ -83,13 +84,15 @@ private static bool ShouldGenerateTypedWrapper(MockMemberModel method, bool hasE { if (method.IsGenericMethod) return false; - var nonOutParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList(); - if (nonOutParams.Count == 0) + // Exclude out params and ref struct params (can't be boxed or used as type args) + var matchableParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList(); + if (matchableParams.Count == 0) { - var hasOutRefParams = method.Parameters.Any(p => p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref); + var hasOutRefParams = method.Parameters.Any(p => + !p.IsRefStruct && (p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref)); return hasEvents || hasOutRefParams; } - return nonOutParams.Count <= MaxTypedParams; + return matchableParams.Count <= MaxTypedParams; } private static string GetWrapperName(string safeName, MockMemberModel method) @@ -106,15 +109,16 @@ private static void GenerateUnifiedSealedClass(CodeWriter writer, MockMemberMode : method.ReturnType; var wrapperName = GetWrapperName(safeName, method); - var nonOutParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList(); + var matchableParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList(); - if (method.IsVoid) + // Ref struct returns use the void wrapper (can't use generic type args with ref structs) + if (method.IsVoid || method.IsRefStructReturn) { - GenerateVoidUnifiedClass(writer, wrapperName, nonOutParams, events, method.Parameters); + GenerateVoidUnifiedClass(writer, wrapperName, matchableParams, events, method.Parameters); } else { - GenerateReturnUnifiedClass(writer, wrapperName, nonOutParams, setupReturnType, events, method.Parameters); + GenerateReturnUnifiedClass(writer, wrapperName, matchableParams, setupReturnType, events, method.Parameters); } } @@ -460,8 +464,9 @@ private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel meth { returnType = GetWrapperName(safeName, method); } - else if (method.IsVoid) + else if (method.IsVoid || method.IsRefStructReturn) { + // Ref struct returns use VoidMockMethodCall (can't use ref struct as generic type arg) returnType = "global::TUnit.Mocks.VoidMockMethodCall"; } else @@ -479,16 +484,17 @@ private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel meth using (writer.Block($"public static {returnType} {safeMemberName}{typeParams}({fullParamList}){constraints}")) { - // Build matchers array - var nonOutParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList(); + // Build matchers array (exclude out and ref struct params) + var matchableParams = method.Parameters + .Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList(); - if (nonOutParams.Count == 0) + if (matchableParams.Count == 0) { writer.AppendLine("var matchers = global::System.Array.Empty();"); } else { - var matcherArgs = string.Join(", ", nonOutParams.Select(p => $"{p.Name}.Matcher")); + var matcherArgs = string.Join(", ", matchableParams.Select(p => $"{p.Name}.Matcher")); writer.AppendLine($"var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] {{ {matcherArgs} }};"); } @@ -497,7 +503,7 @@ private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel meth var wrapperName = GetWrapperName(safeName, method); writer.AppendLine($"return new {wrapperName}(mock.Engine, {method.MemberId}, \"{method.Name}\", matchers);"); } - else if (method.IsVoid) + else if (method.IsVoid || method.IsRefStructReturn) { writer.AppendLine($"return new global::TUnit.Mocks.VoidMockMethodCall(mock.Engine, {method.MemberId}, \"{method.Name}\", matchers);"); } @@ -572,9 +578,10 @@ private static void GenerateRaiseExtensionMethods(CodeWriter writer, MockTypeMod private static string GetArgParameterList(MockMemberModel method) { - // Only include non-out parameters as Arg in setup + // Only include non-out, non-ref-struct parameters as Arg in setup + // (ref structs cannot be used as generic type arguments) return string.Join(", ", method.Parameters - .Where(p => p.Direction != ParameterDirection.Out) + .Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct) .Select(p => $"global::TUnit.Mocks.Arguments.Arg<{p.FullyQualifiedType}> {p.Name}")); } diff --git a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs index d590814790..6c611e8a43 100644 --- a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs +++ b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs @@ -299,7 +299,8 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m FullyQualifiedType = p.Type.GetFullyQualifiedName(), Direction = p.GetParameterDirection(), HasDefaultValue = p.HasExplicitDefaultValue, - DefaultValueExpression = p.HasExplicitDefaultValue ? FormatDefaultValue(p) : null + DefaultValueExpression = p.HasExplicitDefaultValue ? FormatDefaultValue(p) : null, + IsRefStruct = p.Type.IsRefLikeType }).ToImmutableArray() ), TypeParameters = new EquatableArray( @@ -316,7 +317,8 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m IsAbstractMember = method.IsAbstract, IsVirtualMember = method.IsVirtual || method.IsOverride, IsProtected = method.DeclaredAccessibility == Accessibility.Protected - || method.DeclaredAccessibility == Accessibility.ProtectedOrInternal + || method.DeclaredAccessibility == Accessibility.ProtectedOrInternal, + IsRefStructReturn = returnType.IsRefLikeType }; } @@ -365,7 +367,8 @@ private static MockMemberModel CreatePropertyModel(IPropertySymbol property, ref IsAbstractMember = property.IsAbstract, IsVirtualMember = property.IsVirtual || property.IsOverride, IsProtected = property.DeclaredAccessibility == Accessibility.Protected - || property.DeclaredAccessibility == Accessibility.ProtectedOrInternal + || property.DeclaredAccessibility == Accessibility.ProtectedOrInternal, + IsRefStructReturn = property.Type.IsRefLikeType }; } diff --git a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs index 752b094a21..a2ae032191 100644 --- a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs +++ b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs @@ -29,6 +29,7 @@ internal sealed record MockMemberModel : IEquatable public bool IsAbstractMember { get; init; } public bool IsVirtualMember { get; init; } public bool IsProtected { get; init; } + public bool IsRefStructReturn { get; init; } public bool Equals(MockMemberModel? other) { @@ -54,7 +55,8 @@ public bool Equals(MockMemberModel? other) && UnwrappedSmartDefault == other.UnwrappedSmartDefault && IsAbstractMember == other.IsAbstractMember && IsVirtualMember == other.IsVirtualMember - && IsProtected == other.IsProtected; + && IsProtected == other.IsProtected + && IsRefStructReturn == other.IsRefStructReturn; } public override int GetHashCode() diff --git a/TUnit.Mocks.SourceGenerator/Models/MockParameterModel.cs b/TUnit.Mocks.SourceGenerator/Models/MockParameterModel.cs index a44a936377..36817c0fe6 100644 --- a/TUnit.Mocks.SourceGenerator/Models/MockParameterModel.cs +++ b/TUnit.Mocks.SourceGenerator/Models/MockParameterModel.cs @@ -11,6 +11,7 @@ internal sealed record MockParameterModel : IEquatable public bool HasDefaultValue { get; init; } public string? DefaultValueExpression { get; init; } public bool IsValueType { get; init; } + public bool IsRefStruct { get; init; } public bool Equals(MockParameterModel? other) { @@ -19,7 +20,8 @@ public bool Equals(MockParameterModel? other) && Type == other.Type && FullyQualifiedType == other.FullyQualifiedType && Direction == other.Direction - && IsValueType == other.IsValueType; + && IsValueType == other.IsValueType + && IsRefStruct == other.IsRefStruct; } public override int GetHashCode() @@ -31,6 +33,7 @@ public override int GetHashCode() hash = hash * 31 + Type.GetHashCode(); hash = hash * 31 + (int)Direction; hash = hash * 31 + IsValueType.GetHashCode(); + hash = hash * 31 + IsRefStruct.GetHashCode(); return hash; } } diff --git a/TUnit.Mocks.Tests/RefStructTests.cs b/TUnit.Mocks.Tests/RefStructTests.cs new file mode 100644 index 0000000000..d0b554dc13 --- /dev/null +++ b/TUnit.Mocks.Tests/RefStructTests.cs @@ -0,0 +1,182 @@ +using TUnit.Mocks; +using TUnit.Mocks.Arguments; + +namespace TUnit.Mocks.Tests; + +// ─── Interface with ref struct methods ─────────────────────────────────────── + +public interface IBufferProcessor +{ + void Process(ReadOnlySpan data); + int Parse(ReadOnlySpan text); + string GetName(); + void Clear(); +} + +public interface IMixedProcessor +{ + int Compute(int id, ReadOnlySpan data); + void Send(string destination, ReadOnlySpan payload); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +/// +/// Tests that interfaces with ref struct parameters can be mocked. +/// Ref struct params are excluded from argument matching; other methods work normally. +/// +public class RefStructTests +{ + [Test] + public async Task Normal_Method_Returns_Configured_Value() + { + // Arrange + var mock = Mock.Of(); + mock.GetName().Returns("processor-1"); + + // Act + IBufferProcessor processor = mock.Object; + var name = processor.GetName(); + + // Assert + await Assert.That(name).IsEqualTo("processor-1"); + } + + [Test] + public async Task Void_RefStruct_Method_Callback_Fires() + { + // Arrange + var wasCalled = false; + var mock = Mock.Of(); + mock.Process().Callback(() => wasCalled = true); + + // Act + IBufferProcessor processor = mock.Object; + processor.Process(new byte[] { 1, 2, 3 }); + + // Assert + await Assert.That(wasCalled).IsTrue(); + } + + [Test] + public async Task Void_RefStruct_Method_Verification_Works() + { + // Arrange + var mock = Mock.Of(); + IBufferProcessor processor = mock.Object; + + // Act + processor.Process(new byte[] { 1, 2, 3 }); + processor.Process(ReadOnlySpan.Empty); + + // Assert + mock.Process().WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task Void_RefStruct_Method_Throws_Configured_Exception() + { + // Arrange + var mock = Mock.Of(); + mock.Process().Throws(); + + // Act & Assert + IBufferProcessor processor = mock.Object; + var ex = Assert.Throws(() => + { + processor.Process(new byte[] { 1 }); + }); + + await Assert.That(ex).IsNotNull(); + } + + [Test] + public async Task NonVoid_RefStruct_Param_Method_Returns_Configured_Value() + { + // Arrange — Parse takes ReadOnlySpan param but returns int + var mock = Mock.Of(); + mock.Parse().Returns(42); + + // Act + IBufferProcessor processor = mock.Object; + var result = processor.Parse("hello".AsSpan()); + + // Assert + await Assert.That(result).IsEqualTo(42); + } + + [Test] + public async Task NonVoid_RefStruct_Param_Verification() + { + // Arrange + var mock = Mock.Of(); + mock.Parse().Returns(0); + + // Act + IBufferProcessor processor = mock.Object; + processor.Parse("abc".AsSpan()); + processor.Parse("xyz".AsSpan()); + + // Assert + mock.Parse().WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task Void_Normal_Method_Still_Works() + { + // Arrange + var wasCalled = false; + var mock = Mock.Of(); + mock.Clear().Callback(() => wasCalled = true); + + // Act + IBufferProcessor processor = mock.Object; + processor.Clear(); + + // Assert + await Assert.That(wasCalled).IsTrue(); + mock.Clear().WasCalled(Times.Once); + } + + [Test] + public async Task Mixed_Params_ArgMatching_On_NonRefStruct_Params() + { + // Arrange — Compute(int id, ReadOnlySpan data) returns int + // Only 'id' participates in argument matching + var mock = Mock.Of(); + mock.Compute(1).Returns(100); + mock.Compute(2).Returns(200); + + // Act + IMixedProcessor processor = mock.Object; + var result1 = processor.Compute(1, new byte[] { 0xFF }); + var result2 = processor.Compute(2, ReadOnlySpan.Empty); + var result3 = processor.Compute(99, new byte[] { 0x00 }); + + // Assert — argument matching works on the int param + await Assert.That(result1).IsEqualTo(100); + await Assert.That(result2).IsEqualTo(200); + await Assert.That(result3).IsEqualTo(0); // no setup for id=99, returns default + } + + [Test] + public async Task Mixed_Params_Verification_With_Matcher() + { + // Arrange + var mock = Mock.Of(); + IMixedProcessor processor = mock.Object; + + // Act + processor.Send("server-a", new byte[] { 1, 2, 3 }); + processor.Send("server-b", ReadOnlySpan.Empty); + processor.Send("server-a", new byte[] { 4, 5, 6 }); + + // Assert — verify by the string destination (non-ref-struct param) + mock.Send("server-a").WasCalled(Times.Exactly(2)); + mock.Send("server-b").WasCalled(Times.Once); + mock.Send(Arg.Any()).WasCalled(Times.Exactly(3)); + await Assert.That(true).IsTrue(); + } +} From 2775de05ca5b5fe9be424fab547ce791e2128348 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:56:04 +0000 Subject: [PATCH 2/2] fix: address review feedback for ref struct support - Fix GenerateWrapProperty and GeneratePartialProperty to handle IsRefStructReturn (was missing, would cause compile errors for wrap/partial mocks with ref struct properties) - Set IsValueType on method parameters (was only set on constructor params) and IsRefStruct on constructor parameters (was only set on method params) for symmetry --- .../Builders/MockImplBuilder.cs | 68 +++++++++++++++++-- .../Discovery/MemberDiscovery.cs | 4 +- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index 94b4678166..e35f5c568b 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -270,7 +270,31 @@ private static void GenerateWrapProperty(CodeWriter writer, MockMemberModel prop if (prop.HasGetter) { - if (prop.IsAbstractMember) + if (prop.IsRefStructReturn) + { + if (prop.IsAbstractMember) + { + writer.AppendLine("get"); + writer.OpenBrace(); + writer.AppendLine($"_engine.HandleCall({prop.MemberId}, \"get_{prop.Name}\", global::System.Array.Empty());"); + writer.AppendLine("return default;"); + writer.CloseBrace(); + } + else + { + writer.AppendLine("get"); + writer.OpenBrace(); + writer.AppendLine($"if (_engine.TryHandleCall({prop.MemberId}, \"get_{prop.Name}\", global::System.Array.Empty()))"); + writer.AppendLine("{"); + writer.IncreaseIndent(); + writer.AppendLine("return default;"); + writer.DecreaseIndent(); + writer.AppendLine("}"); + writer.AppendLine($"return _wrappedInstance.{prop.Name};"); + writer.CloseBrace(); + } + } + else if (prop.IsAbstractMember) { writer.AppendLine($"get => _engine.HandleCallWithReturn<{prop.ReturnType}>({prop.MemberId}, \"get_{prop.Name}\", global::System.Array.Empty(), {prop.SmartDefault});"); } @@ -291,15 +315,19 @@ private static void GenerateWrapProperty(CodeWriter writer, MockMemberModel prop if (prop.HasSetter) { + var setterArgs = prop.IsRefStructReturn + ? "global::System.Array.Empty()" + : "new object?[] { value }"; + if (prop.IsAbstractMember) { - writer.AppendLine($"set => _engine.HandleCall({prop.SetterMemberId}, \"set_{prop.Name}\", new object?[] {{ value }});"); + writer.AppendLine($"set => _engine.HandleCall({prop.SetterMemberId}, \"set_{prop.Name}\", {setterArgs});"); } else { writer.AppendLine("set"); writer.OpenBrace(); - writer.AppendLine($"if (!_engine.TryHandleCall({prop.SetterMemberId}, \"set_{prop.Name}\", new object?[] {{ value }}))"); + writer.AppendLine($"if (!_engine.TryHandleCall({prop.SetterMemberId}, \"set_{prop.Name}\", {setterArgs}))"); writer.AppendLine("{"); writer.IncreaseIndent(); writer.AppendLine($"_wrappedInstance.{prop.Name} = value;"); @@ -660,7 +688,31 @@ private static void GeneratePartialProperty(CodeWriter writer, MockMemberModel p if (prop.HasGetter) { - if (prop.IsAbstractMember) + if (prop.IsRefStructReturn) + { + if (prop.IsAbstractMember) + { + writer.AppendLine("get"); + writer.OpenBrace(); + writer.AppendLine($"_engine.HandleCall({prop.MemberId}, \"get_{prop.Name}\", global::System.Array.Empty());"); + writer.AppendLine("return default;"); + writer.CloseBrace(); + } + else + { + writer.AppendLine("get"); + writer.OpenBrace(); + writer.AppendLine($"if (_engine.TryHandleCall({prop.MemberId}, \"get_{prop.Name}\", global::System.Array.Empty()))"); + writer.AppendLine("{"); + writer.IncreaseIndent(); + writer.AppendLine("return default;"); + writer.DecreaseIndent(); + writer.AppendLine("}"); + writer.AppendLine($"return base.{prop.Name};"); + writer.CloseBrace(); + } + } + else if (prop.IsAbstractMember) { writer.AppendLine($"get => _engine.HandleCallWithReturn<{prop.ReturnType}>({prop.MemberId}, \"get_{prop.Name}\", global::System.Array.Empty(), {prop.SmartDefault});"); } @@ -682,16 +734,20 @@ private static void GeneratePartialProperty(CodeWriter writer, MockMemberModel p if (prop.HasSetter) { + var setterArgs = prop.IsRefStructReturn + ? "global::System.Array.Empty()" + : "new object?[] { value }"; + if (prop.IsAbstractMember) { - writer.AppendLine($"set => _engine.HandleCall({prop.SetterMemberId}, \"set_{prop.Name}\", new object?[] {{ value }});"); + writer.AppendLine($"set => _engine.HandleCall({prop.SetterMemberId}, \"set_{prop.Name}\", {setterArgs});"); } else { // Virtual property setter: try engine, fall back to base writer.AppendLine("set"); writer.OpenBrace(); - writer.AppendLine($"if (!_engine.TryHandleCall({prop.SetterMemberId}, \"set_{prop.Name}\", new object?[] {{ value }}))"); + writer.AppendLine($"if (!_engine.TryHandleCall({prop.SetterMemberId}, \"set_{prop.Name}\", {setterArgs}))"); writer.AppendLine("{"); writer.IncreaseIndent(); writer.AppendLine($"base.{prop.Name} = value;"); diff --git a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs index 6c611e8a43..586fa50eb5 100644 --- a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs +++ b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs @@ -300,6 +300,7 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m Direction = p.GetParameterDirection(), HasDefaultValue = p.HasExplicitDefaultValue, DefaultValueExpression = p.HasExplicitDefaultValue ? FormatDefaultValue(p) : null, + IsValueType = p.Type.IsValueType, IsRefStruct = p.Type.IsRefLikeType }).ToImmutableArray() ), @@ -394,7 +395,8 @@ public static EquatableArray DiscoverConstructors(INamedTy Direction = p.GetParameterDirection(), HasDefaultValue = p.HasExplicitDefaultValue, DefaultValueExpression = p.HasExplicitDefaultValue ? FormatDefaultValue(p) : null, - IsValueType = p.Type.IsValueType + IsValueType = p.Type.IsValueType, + IsRefStruct = p.Type.IsRefLikeType }).ToImmutableArray() ) });