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 index bfb4a1902d..7e9308795d 100644 --- a/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_RefStruct_Parameters.verified.txt +++ b/TUnit.Mocks.SourceGenerator.Tests/Snapshots/Interface_With_RefStruct_Parameters.verified.txt @@ -41,12 +41,22 @@ namespace TUnit.Mocks.Generated public void Process(global::System.ReadOnlySpan data) { - _engine.HandleCall(0, "Process", global::System.Array.Empty()); + #if NET9_0_OR_GREATER + var __args = new object?[] { null }; + #else + var __args = global::System.Array.Empty(); + #endif + _engine.HandleCall(0, "Process", __args); } public int Parse(global::System.ReadOnlySpan text) { - return _engine.HandleCallWithReturn(1, "Parse", global::System.Array.Empty(), default); + #if NET9_0_OR_GREATER + var __args = new object?[] { null }; + #else + var __args = global::System.Array.Empty(); + #endif + return _engine.HandleCallWithReturn(1, "Parse", __args, default); } public string GetName() @@ -72,17 +82,33 @@ namespace TUnit.Mocks.Generated { public static class IBufferProcessor_MockMemberExtensions { + #if NET9_0_OR_GREATER + public static global::TUnit.Mocks.VoidMockMethodCall Process(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.RefStructArg> data) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { data.Matcher }; + return new global::TUnit.Mocks.VoidMockMethodCall(mock.Engine, 0, "Process", matchers); + } + #else 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); } + #endif + #if NET9_0_OR_GREATER + public static global::TUnit.Mocks.MockMethodCall Parse(this global::TUnit.Mocks.Mock mock, global::TUnit.Mocks.Arguments.RefStructArg> text) + { + var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] { text.Matcher }; + return new global::TUnit.Mocks.MockMethodCall(mock.Engine, 1, "Parse", matchers); + } + #else 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); } + #endif public static global::TUnit.Mocks.MockMethodCall GetName(this global::TUnit.Mocks.Mock mock) { diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index e35f5c568b..a619f90008 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -188,7 +188,7 @@ private static void GenerateWrapMethodBody(CodeWriter writer, MockMemberModel me } } - var argsArray = GetArgsArrayExpression(method); + var argsArray = EmitArgsArrayVariable(writer, method); var argPassList = GetArgPassList(method); if (method.IsVoid && !method.IsAsync) @@ -461,7 +461,7 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel } } - var argsArray = GetArgsArrayExpression(method); + var argsArray = EmitArgsArrayVariable(writer, method); var argPassList = GetArgPassList(method); if (method.IsVoid && !method.IsAsync) @@ -551,7 +551,7 @@ private static void GenerateEngineDispatchBody(CodeWriter writer, MockMemberMode } } - var argsArray = GetArgsArrayExpression(method); + var argsArray = EmitArgsArrayVariable(writer, method); var hasOutRef = HasOutRefParams(method); @@ -955,14 +955,32 @@ private static void EmitOutRefReadback(CodeWriter writer, MockMemberModel method } } - private static string GetArgsArrayExpression(MockMemberModel method) + private static string EmitArgsArrayVariable(CodeWriter writer, MockMemberModel method) { - // 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 (!method.HasRefStructParams) + return GetArgsArrayExpression(method, false); + + writer.AppendLine("#if NET9_0_OR_GREATER"); + writer.AppendLine($"var __args = {GetArgsArrayExpression(method, true)};"); + writer.AppendLine("#else"); + writer.AppendLine($"var __args = {GetArgsArrayExpression(method, false)};"); + writer.AppendLine("#endif"); + return "__args"; + } + + private static string GetArgsArrayExpression(MockMemberModel method, bool includeRefStructSentinels) + { + var nonOutParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList(); + if (includeRefStructSentinels) + { + if (nonOutParams.Count == 0) return "global::System.Array.Empty()"; + var args = string.Join(", ", nonOutParams.Select(p => p.IsRefStruct ? "null" : p.Name)); + return $"new object?[] {{ {args} }}"; + } + var matchableParams = nonOutParams.Where(p => !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} }}"; + var argsStr = string.Join(", ", matchableParams.Select(p => p.Name)); + return $"new object?[] {{ {argsStr} }}"; } /// diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index 725e364b9c..362b94ef9b 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -110,21 +110,23 @@ private static void GenerateUnifiedSealedClass(CodeWriter writer, MockMemberMode var wrapperName = GetWrapperName(safeName, method); var matchableParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList(); + var hasRefStructParams = method.HasRefStructParams; + var allNonOutParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList(); // Ref struct returns use the void wrapper (can't use generic type args with ref structs) if (method.IsVoid || method.IsRefStructReturn) { - GenerateVoidUnifiedClass(writer, wrapperName, matchableParams, events, method.Parameters); + GenerateVoidUnifiedClass(writer, wrapperName, matchableParams, events, method.Parameters, hasRefStructParams, allNonOutParams); } else { - GenerateReturnUnifiedClass(writer, wrapperName, matchableParams, setupReturnType, events, method.Parameters); + GenerateReturnUnifiedClass(writer, wrapperName, matchableParams, setupReturnType, events, method.Parameters, hasRefStructParams, allNonOutParams); } } private static void GenerateReturnUnifiedClass(CodeWriter writer, string wrapperName, List nonOutParams, string returnType, EquatableArray events, - EquatableArray allParameters) + EquatableArray allParameters, bool hasRefStructParams, List allNonOutParams) { var builderType = $"global::TUnit.Mocks.Setup.MethodSetupBuilder<{returnType}>"; var hasOutRef = allParameters.Any(p => p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref); @@ -198,11 +200,30 @@ private static void GenerateReturnUnifiedClass(CodeWriter writer, string wrapper if (nonOutParams.Count >= 1) { writer.AppendLine(); - GenerateTypedReturnsOverload(writer, nonOutParams, returnType, wrapperName); - writer.AppendLine(); - GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName); - writer.AppendLine(); - GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName); + if (hasRefStructParams) + { + writer.AppendLine("#if NET9_0_OR_GREATER"); + GenerateTypedReturnsOverload(writer, nonOutParams, returnType, wrapperName, allNonOutParams); + writer.AppendLine(); + GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName, allNonOutParams); + writer.AppendLine(); + GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName, allNonOutParams); + writer.AppendLine("#else"); + GenerateTypedReturnsOverload(writer, nonOutParams, returnType, wrapperName); + writer.AppendLine(); + GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName); + writer.AppendLine(); + GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName); + writer.AppendLine("#endif"); + } + else + { + GenerateTypedReturnsOverload(writer, nonOutParams, returnType, wrapperName); + writer.AppendLine(); + GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName); + writer.AppendLine(); + GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName); + } } // Typed out/ref parameter setters @@ -239,7 +260,7 @@ private static void GenerateReturnUnifiedClass(CodeWriter writer, string wrapper private static void GenerateVoidUnifiedClass(CodeWriter writer, string wrapperName, List nonOutParams, EquatableArray events, - EquatableArray allParameters) + EquatableArray allParameters, bool hasRefStructParams, List allNonOutParams) { var builderType = "global::TUnit.Mocks.Setup.VoidMethodSetupBuilder"; var hasOutRef = allParameters.Any(p => p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref); @@ -307,9 +328,24 @@ private static void GenerateVoidUnifiedClass(CodeWriter writer, string wrapperNa if (nonOutParams.Count >= 1) { writer.AppendLine(); - GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName); - writer.AppendLine(); - GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName); + if (hasRefStructParams) + { + writer.AppendLine("#if NET9_0_OR_GREATER"); + GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName, allNonOutParams); + writer.AppendLine(); + GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName, allNonOutParams); + writer.AppendLine("#else"); + GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName); + writer.AppendLine(); + GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName); + writer.AppendLine("#endif"); + } + else + { + GenerateTypedCallbackOverload(writer, nonOutParams, wrapperName); + writer.AppendLine(); + GenerateTypedThrowsOverload(writer, nonOutParams, wrapperName); + } } // Typed out/ref parameter setters @@ -345,11 +381,11 @@ private static void GenerateVoidUnifiedClass(CodeWriter writer, string wrapperNa } private static void GenerateTypedReturnsOverload(CodeWriter writer, List nonOutParams, - string returnType, string wrapperName) + string returnType, string wrapperName, List? allNonOutParams = null) { var typeList = string.Join(", ", nonOutParams.Select(p => p.FullyQualifiedType)); var funcType = $"global::System.Func<{typeList}, {returnType}>"; - var castArgs = BuildCastArgs(nonOutParams); + var castArgs = BuildCastArgs(nonOutParams, allNonOutParams); writer.AppendLine("/// Configure a typed computed return value using the actual method parameters."); using (writer.Block($"public {wrapperName} Returns({funcType} factory)")) @@ -360,11 +396,11 @@ private static void GenerateTypedReturnsOverload(CodeWriter writer, List nonOutParams, - string wrapperName) + string wrapperName, List? allNonOutParams = null) { var typeList = string.Join(", ", nonOutParams.Select(p => p.FullyQualifiedType)); var actionType = $"global::System.Action<{typeList}>"; - var castArgs = BuildCastArgs(nonOutParams); + var castArgs = BuildCastArgs(nonOutParams, allNonOutParams); writer.AppendLine("/// Execute a typed callback using the actual method parameters."); using (writer.Block($"public {wrapperName} Callback({actionType} callback)")) @@ -375,11 +411,11 @@ private static void GenerateTypedCallbackOverload(CodeWriter writer, List nonOutParams, - string wrapperName) + string wrapperName, List? allNonOutParams = null) { var typeList = string.Join(", ", nonOutParams.Select(p => p.FullyQualifiedType)); var funcType = $"global::System.Func<{typeList}, global::System.Exception>"; - var castArgs = BuildCastArgs(nonOutParams); + var castArgs = BuildCastArgs(nonOutParams, allNonOutParams); writer.AppendLine("/// Configure a typed computed exception using the actual method parameters."); using (writer.Block($"public {wrapperName} Throws({funcType} exceptionFactory)")) @@ -442,13 +478,32 @@ private static void GenerateTypedOutRefMethods(CodeWriter writer, EquatableArray private static string ToPascalCase(string name) => string.IsNullOrEmpty(name) ? name : char.ToUpperInvariant(name[0]) + name[1..]; - private static string BuildCastArgs(List nonOutParams) + private static string BuildCastArgs(List nonOutParams, List? allNonOutParams = null) { - return string.Join(", ", nonOutParams.Select((p, i) => - $"({p.FullyQualifiedType})args[{i}]!")); + if (allNonOutParams is null) + return string.Join(", ", nonOutParams.Select((p, i) => $"({p.FullyQualifiedType})args[{i}]!")); + + var indexMap = allNonOutParams.Select((p, i) => (p, i)).ToDictionary(x => x.p, x => x.i); + return string.Join(", ", nonOutParams.Select(p => $"({p.FullyQualifiedType})args[{indexMap[p]}]!")); } private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName) + { + if (method.HasRefStructParams) + { + writer.AppendLine("#if NET9_0_OR_GREATER"); + EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: true); + writer.AppendLine("#else"); + EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false); + writer.AppendLine("#endif"); + } + else + { + EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false); + } + } + + private static void EmitMemberMethodBody(CodeWriter writer, MockMemberModel method, MockTypeModel model, string safeName, bool includeRefStructArgs) { // For async methods (Task/ValueTask), unwrap the return type so users write .Returns(5) not .Returns(Task.FromResult(5)) // For void-async methods (Task/ValueTask), IsVoid is already true @@ -474,7 +529,7 @@ private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel meth returnType = $"global::TUnit.Mocks.MockMethodCall<{setupReturnType}>"; } - var paramList = GetArgParameterList(method); + var paramList = GetArgParameterList(method, includeRefStructArgs); var typeParams = GetTypeParameterList(method); var constraints = GetConstraintClauses(method); @@ -484,9 +539,10 @@ private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel meth using (writer.Block($"public static {returnType} {safeMemberName}{typeParams}({fullParamList}){constraints}")) { - // Build matchers array (exclude out and ref struct params) - var matchableParams = method.Parameters - .Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList(); + // Build matchers array + var matchableParams = includeRefStructArgs + ? method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList() + : method.Parameters.Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList(); if (matchableParams.Count == 0) { @@ -576,13 +632,23 @@ private static void GenerateRaiseExtensionMethods(CodeWriter writer, MockTypeMod } } - private static string GetArgParameterList(MockMemberModel method) + private static string GetArgParameterList(MockMemberModel method, bool includeRefStructArgs) { - // 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 && !p.IsRefStruct) - .Select(p => $"global::TUnit.Mocks.Arguments.Arg<{p.FullyQualifiedType}> {p.Name}")); + var parts = new List(); + foreach (var p in method.Parameters) + { + if (p.Direction == ParameterDirection.Out) continue; + if (p.IsRefStruct) + { + if (includeRefStructArgs) + parts.Add($"global::TUnit.Mocks.Arguments.RefStructArg<{p.FullyQualifiedType}> {p.Name}"); + } + else + { + parts.Add($"global::TUnit.Mocks.Arguments.Arg<{p.FullyQualifiedType}> {p.Name}"); + } + } + return string.Join(", ", parts); } private static string GetTypeParameterList(MockMemberModel method) diff --git a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs index a2ae032191..e8b1a0b67d 100644 --- a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs +++ b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; namespace TUnit.Mocks.SourceGenerator.Models; @@ -31,6 +32,12 @@ internal sealed record MockMemberModel : IEquatable public bool IsProtected { get; init; } public bool IsRefStructReturn { get; init; } + /// + /// Returns true if the method has any non-out ref struct parameters. + /// Computed from — does not participate in equality. + /// + public bool HasRefStructParams => Parameters.Any(p => p.IsRefStruct && p.Direction != ParameterDirection.Out); + public bool Equals(MockMemberModel? other) { if (other is null) return false; diff --git a/TUnit.Mocks.Tests/RefStructTests.cs b/TUnit.Mocks.Tests/RefStructTests.cs index d0b554dc13..92fef06cb5 100644 --- a/TUnit.Mocks.Tests/RefStructTests.cs +++ b/TUnit.Mocks.Tests/RefStructTests.cs @@ -42,6 +42,8 @@ public async Task Normal_Method_Returns_Configured_Value() await Assert.That(name).IsEqualTo("processor-1"); } +#if !NET9_0_OR_GREATER + [Test] public async Task Void_RefStruct_Method_Callback_Fires() { @@ -123,6 +125,8 @@ public async Task NonVoid_RefStruct_Param_Verification() await Assert.That(true).IsTrue(); } +#endif + [Test] public async Task Void_Normal_Method_Still_Works() { @@ -140,6 +144,8 @@ public async Task Void_Normal_Method_Still_Works() mock.Clear().WasCalled(Times.Once); } +#if !NET9_0_OR_GREATER + [Test] public async Task Mixed_Params_ArgMatching_On_NonRefStruct_Params() { @@ -179,4 +185,172 @@ public async Task Mixed_Params_Verification_With_Matcher() mock.Send(Arg.Any()).WasCalled(Times.Exactly(3)); await Assert.That(true).IsTrue(); } + +#endif + +#if NET9_0_OR_GREATER + + [Test] + public async Task RefStructArg_Any_Matches_Void_Method() + { + // Arrange + var wasCalled = false; + var mock = Mock.Of(); + mock.Process(RefStructArg>.Any).Callback(() => wasCalled = true); + + // Act + mock.Object.Process(new byte[] { 1, 2, 3 }); + + // Assert + await Assert.That(wasCalled).IsTrue(); + } + + [Test] + public async Task RefStructArg_Any_Matches_Return_Method() + { + // Arrange + var mock = Mock.Of(); + mock.Parse(RefStructArg>.Any).Returns(99); + + // Act + var result = mock.Object.Parse("test".AsSpan()); + + // Assert + await Assert.That(result).IsEqualTo(99); + } + + [Test] + public async Task RefStructArg_Mixed_Params_Works() + { + // Arrange — Compute(int id, ReadOnlySpan data) + var mock = Mock.Of(); + mock.Compute(1, RefStructArg>.Any).Returns(100); + mock.Compute(2, RefStructArg>.Any).Returns(200); + + // Act + var result1 = mock.Object.Compute(1, new byte[] { 0xFF }); + var result2 = mock.Object.Compute(2, ReadOnlySpan.Empty); + + // Assert + await Assert.That(result1).IsEqualTo(100); + await Assert.That(result2).IsEqualTo(200); + } + + [Test] + public async Task RefStructArg_Verification_With_Any() + { + // Arrange + var mock = Mock.Of(); + mock.Object.Process(new byte[] { 1, 2, 3 }); + mock.Object.Process(ReadOnlySpan.Empty); + + // Assert + mock.Process(RefStructArg>.Any).WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task RefStructArg_Mixed_Verification() + { + // Arrange — Send(string destination, ReadOnlySpan payload) + var mock = Mock.Of(); + mock.Object.Send("server-a", new byte[] { 1, 2, 3 }); + mock.Object.Send("server-b", ReadOnlySpan.Empty); + + // Assert — verify with both Arg and RefStructArg> + mock.Send("server-a", RefStructArg>.Any).WasCalled(Times.Once); + mock.Send(Arg.Any(), RefStructArg>.Any).WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task RefStructArg_Void_Method_Throws_Configured_Exception() + { + // Arrange + var mock = Mock.Of(); + mock.Process(RefStructArg>.Any).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 RefStructArg_NonVoid_Method_Returns_Configured_Value() + { + // Arrange — Parse takes ReadOnlySpan param but returns int + var mock = Mock.Of(); + mock.Parse(RefStructArg>.Any).Returns(42); + + // Act + IBufferProcessor processor = mock.Object; + var result = processor.Parse("hello".AsSpan()); + + // Assert + await Assert.That(result).IsEqualTo(42); + } + + [Test] + public async Task RefStructArg_NonVoid_Method_Verification() + { + // Arrange + var mock = Mock.Of(); + mock.Parse(RefStructArg>.Any).Returns(0); + + // Act + IBufferProcessor processor = mock.Object; + processor.Parse("abc".AsSpan()); + processor.Parse("xyz".AsSpan()); + + // Assert + mock.Parse(RefStructArg>.Any).WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task RefStructArg_Mixed_Params_ArgMatching_On_NonRefStruct_Params() + { + // Arrange — Compute(int id, ReadOnlySpan data) returns int + // Both params participate in matching on net9.0+ via RefStructArg.Any + var mock = Mock.Of(); + mock.Compute(1, RefStructArg>.Any).Returns(100); + mock.Compute(2, RefStructArg>.Any).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 RefStructArg_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) with RefStructArg.Any + mock.Send("server-a", RefStructArg>.Any).WasCalled(Times.Exactly(2)); + mock.Send("server-b", RefStructArg>.Any).WasCalled(Times.Once); + mock.Send(Arg.Any(), RefStructArg>.Any).WasCalled(Times.Exactly(3)); + await Assert.That(true).IsTrue(); + } + +#endif } diff --git a/TUnit.Mocks/Arguments/RefStructArg.cs b/TUnit.Mocks/Arguments/RefStructArg.cs new file mode 100644 index 0000000000..8abc5e54f9 --- /dev/null +++ b/TUnit.Mocks/Arguments/RefStructArg.cs @@ -0,0 +1,36 @@ +#if NET9_0_OR_GREATER + +namespace TUnit.Mocks.Arguments; + +/// +/// Represents an argument matcher for a ref struct parameter in mock setup and verification expressions. +/// Since ref struct types cannot be used as generic type arguments for , +/// this type uses the allows ref struct anti-constraint (C# 13+) to accept them. +/// +/// +/// Only matching is supported. Exact value matching and predicate matching are not +/// available for ref struct parameters because ref structs cannot be boxed or stored in closures. +/// +/// Ref struct parameters are excluded from the typed Callback, Returns, and Throws +/// delegate overloads because lambdas cannot capture ref struct values. Use the untyped +/// Action/Func<object?[], ...> overloads if you need to react to all arguments. +/// +/// The ref struct parameter type. +public readonly struct RefStructArg where T : allows ref struct +{ + /// Gets the argument matcher. Public for generated code access. Not intended for direct use. + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public IArgumentMatcher Matcher { get; } + + /// Creates a RefStructArg with a matcher. Public for generated code access. Not intended for direct use. + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public RefStructArg(IArgumentMatcher matcher) + { + Matcher = matcher; + } + + /// Matches any value of the ref struct type. This is currently the only supported matcher for ref struct parameters. + public static RefStructArg Any => new(Mocks.Matchers.AnyMatcher.Instance); +} + +#endif diff --git a/TUnit.Mocks/Matchers/AnyMatcher.cs b/TUnit.Mocks/Matchers/AnyMatcher.cs index 5ea02ba145..078256388c 100644 --- a/TUnit.Mocks/Matchers/AnyMatcher.cs +++ b/TUnit.Mocks/Matchers/AnyMatcher.cs @@ -2,6 +2,19 @@ namespace TUnit.Mocks.Matchers; +/// +/// A non-generic argument matcher that matches any value including null. +/// Used for ref struct parameter positions where the generic AnyMatcher<T> cannot be used. +/// +internal sealed class AnyMatcher : IArgumentMatcher +{ + public static AnyMatcher Instance { get; } = new(); + + public bool Matches(object? value) => true; + + public string Describe() => "Any"; +} + /// /// An argument matcher that matches any value of the specified type, including null. /// diff --git a/docs/docs/test-authoring/mocking/argument-matchers.md b/docs/docs/test-authoring/mocking/argument-matchers.md index f41fc10af2..84c39b80b3 100644 --- a/docs/docs/test-authoring/mocking/argument-matchers.md +++ b/docs/docs/test-authoring/mocking/argument-matchers.md @@ -25,6 +25,7 @@ Argument matchers control which calls a setup or verification matches. The same | `Arg.IsIn(values)` | Value in a set | | `Arg.IsNotIn(values)` | Value not in a set | | `Arg.Not(inner)` | Negation of another matcher | +| `RefStructArg.Any` | Any value of a ref struct type (.NET 9+) | ## Basic Matchers @@ -186,6 +187,60 @@ await Assert.That(nameArg.Values).HasCount().EqualTo(3); Capture works in both setup and verification contexts. Store the `Arg` in a variable, then inspect `.Values` or `.Latest` after exercising the code. ::: +## Ref Struct Parameters + +Regular `Arg` matchers cannot be used with ref struct types like `ReadOnlySpan` or `Span` because ref structs cannot be generic type arguments. On **.NET 9+**, TUnit.Mocks provides `RefStructArg` which uses the `allows ref struct` anti-constraint to make these parameters visible in the setup and verification API. + +:::note .NET 9+ Only +`RefStructArg` requires .NET 9 or later. On older target frameworks, ref struct parameters are excluded from the setup/verify API and all calls match regardless of the ref struct argument value. +::: + +### Matching Any Value + +Currently, `RefStructArg.Any` is the only supported matcher — it matches any value passed for that parameter: + +```csharp +public interface IBufferProcessor +{ + void Process(ReadOnlySpan data); + int Parse(ReadOnlySpan text); +} + +var mock = Mock.Of(); + +// Setup — ref struct param is visible in the API +mock.Process(RefStructArg>.Any).Callback(() => Console.WriteLine("called")); +mock.Parse(RefStructArg>.Any).Returns(42); + +// Verification +mock.Process(RefStructArg>.Any).WasCalled(Times.Once); +``` + +### Mixed Parameters + +When a method has both regular and ref struct parameters, use `Arg` for the regular ones and `RefStructArg` for the ref struct ones. Argument matching works on the regular parameters while the ref struct parameter matches any value: + +```csharp +public interface IMixedProcessor +{ + int Compute(int id, ReadOnlySpan data); +} + +var mock = Mock.Of(); + +// Match on 'id', accept any span value +mock.Compute(1, RefStructArg>.Any).Returns(100); +mock.Compute(2, RefStructArg>.Any).Returns(200); + +var result = mock.Object.Compute(1, new byte[] { 0xFF }); // returns 100 +``` + +### Limitations + +- **Only `.Any` matching** — exact value and predicate matching are not supported because ref struct values cannot be stored on the heap +- **No argument capture** — `RefStructArg` does not support `.Values` or `.Latest` like `Arg` does +- **Not available in typed callbacks** — ref struct parameters are excluded from the typed `Callback`/`Returns`/`Throws` delegate overloads (use the `Action` overload instead) + ## Custom Matchers Implement `IArgumentMatcher` for reusable matching logic: