diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs index a619f90008..252ba90f14 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs @@ -243,8 +243,15 @@ private static void GenerateWrapMethodBody(CodeWriter writer, MockMemberModel me writer.AppendLine($"if (_engine.TryHandleCall({method.MemberId}, \"{method.Name}\", {argsArray}))"); writer.AppendLine("{"); writer.IncreaseIndent(); - EmitOutRefReadback(writer, method); - writer.AppendLine("return default;"); + if (method.SpanReturnElementType is not null) + { + EmitSpanReturnReadback(writer, method); + } + else + { + EmitOutRefReadback(writer, method); + writer.AppendLine("return default;"); + } writer.DecreaseIndent(); writer.AppendLine("}"); writer.AppendLine($"return _wrappedInstance.{method.Name}({argPassList});"); @@ -520,8 +527,15 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel writer.AppendLine($"if (_engine.TryHandleCall({method.MemberId}, \"{method.Name}\", {argsArray}))"); writer.AppendLine("{"); writer.IncreaseIndent(); - EmitOutRefReadback(writer, method); - writer.AppendLine("return default;"); + if (method.SpanReturnElementType is not null) + { + EmitSpanReturnReadback(writer, method); + } + else + { + EmitOutRefReadback(writer, method); + writer.AppendLine("return default;"); + } writer.DecreaseIndent(); writer.AppendLine("}"); writer.AppendLine($"return base.{method.Name}({argPassList});"); @@ -621,10 +635,18 @@ private static void GenerateEngineDispatchBody(CodeWriter writer, MockMemberMode { // 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). + // callbacks, and throws. writer.AppendLine($"_engine.HandleCall({method.MemberId}, \"{method.Name}\", {argsArray});"); - EmitOutRefReadback(writer, method); - writer.AppendLine("return default;"); + if (method.SpanReturnElementType is not null) + { + // Span return: read back out/ref params AND extract return value from OutRefContext index -1 + EmitSpanReturnReadback(writer, method); + } + else + { + EmitOutRefReadback(writer, method); + writer.AppendLine("return default;"); + } } else { @@ -943,11 +965,44 @@ private static void EmitOutRefReadback(CodeWriter writer, MockMemberModel method writer.AppendLine("var __outRef = global::TUnit.Mocks.Setup.OutRefContext.Consume();"); using (writer.Block("if (__outRef is not null)")) { - for (int i = 0; i < method.Parameters.Length; i++) + EmitOutRefParamAssignments(writer, method); + } + } + + /// + /// For ref struct return methods with span support: emits code to consume OutRefContext, + /// read back out/ref params, extract span return value, and return. + /// Always ends with "return default;" as fallback. + /// + private static void EmitSpanReturnReadback(CodeWriter writer, MockMemberModel method) + { + writer.AppendLine("var __outRef = global::TUnit.Mocks.Setup.OutRefContext.Consume();"); + using (writer.Block("if (__outRef is not null)")) + { + EmitOutRefParamAssignments(writer, method); + writer.AppendLine($"if (__outRef.TryGetValue(global::TUnit.Mocks.Setup.OutRefContext.SpanReturnValueIndex, out var __spanRet)) return new {method.ReturnType}(({method.SpanReturnElementType}[])__spanRet!);"); + } + writer.AppendLine("return default;"); + } + + /// + /// Emits individual out/ref parameter assignments from the __outRef dictionary. + /// Shared by and . + /// + private static void EmitOutRefParamAssignments(CodeWriter writer, MockMemberModel method) + { + for (int i = 0; i < method.Parameters.Length; i++) + { + var p = method.Parameters[i]; + if (p.IsRefStruct && p.SpanElementType is null) continue; // non-span ref structs can't be cast from object + if (p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref) { - 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) + if (p.SpanElementType is not null) + { + // Span types: reconstruct from stored array + writer.AppendLine($"if (__outRef.TryGetValue({i}, out var __v{i})) {p.Name} = new {p.FullyQualifiedType}(({p.SpanElementType}[])__v{i}!);"); + } + else { writer.AppendLine($"if (__outRef.TryGetValue({i}, out var __v{i})) {p.Name} = ({p.FullyQualifiedType})__v{i}!;"); } diff --git a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs index 362b94ef9b..b2181b3ad2 100644 --- a/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs +++ b/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs @@ -88,9 +88,13 @@ private static bool ShouldGenerateTypedWrapper(MockMemberModel method, bool hasE var matchableParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList(); if (matchableParams.Count == 0) { + // Include span-type ref struct out/ref params (supported via array conversion) var hasOutRefParams = method.Parameters.Any(p => - !p.IsRefStruct && (p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref)); - return hasEvents || hasOutRefParams; + (!p.IsRefStruct || p.SpanElementType is not null) && + (p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref)); + // Span return types need a typed wrapper for the generated Returns(SpanType) method + var hasSpanReturn = method.SpanReturnElementType is not null; + return hasEvents || hasOutRefParams || hasSpanReturn; } return matchableParams.Count <= MaxTypedParams; } @@ -116,7 +120,7 @@ private static void GenerateUnifiedSealedClass(CodeWriter writer, MockMemberMode // 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, hasRefStructParams, allNonOutParams); + GenerateVoidUnifiedClass(writer, wrapperName, matchableParams, events, method.Parameters, hasRefStructParams, allNonOutParams, method.SpanReturnElementType, method.ReturnType); } else { @@ -260,7 +264,8 @@ private static void GenerateReturnUnifiedClass(CodeWriter writer, string wrapper private static void GenerateVoidUnifiedClass(CodeWriter writer, string wrapperName, List nonOutParams, EquatableArray events, - EquatableArray allParameters, bool hasRefStructParams, List allNonOutParams) + EquatableArray allParameters, bool hasRefStructParams, List allNonOutParams, + string? spanReturnElementType = null, string? spanReturnType = null) { var builderType = "global::TUnit.Mocks.Setup.VoidMethodSetupBuilder"; var hasOutRef = allParameters.Any(p => p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref); @@ -324,6 +329,14 @@ private static void GenerateVoidUnifiedClass(CodeWriter writer, string wrapperNa writer.AppendLine($"/// "); writer.AppendLine($"public {wrapperName} Then() {{ EnsureSetup().Then(); return this; }}"); + // Span return support: generate Returns(SpanType) that stores via SetsOutParameter(-1, ...) + if (spanReturnElementType is not null && spanReturnType is not null) + { + writer.AppendLine(); + writer.AppendLine($"/// Configure the return value for this span-returning method."); + writer.AppendLine($"public {wrapperName} Returns({spanReturnType} value) {{ EnsureSetup().SetsOutParameter(global::TUnit.Mocks.Setup.OutRefContext.SpanReturnValueIndex, value.ToArray()); return this; }}"); + } + // Typed parameter overloads (only for methods with typed params) if (nonOutParams.Count >= 1) { @@ -466,12 +479,24 @@ private static void GenerateTypedOutRefMethods(CodeWriter writer, EquatableArray if (param.Direction != ParameterDirection.Out && param.Direction != ParameterDirection.Ref) continue; + // Skip non-span ref structs (can't be boxed) + if (param.IsRefStruct && param.SpanElementType is null) + continue; + var prefix = param.Direction == ParameterDirection.Out ? "SetsOut" : "SetsRef"; var methodName = prefix + ToPascalCase(param.Name); var dirLabel = param.Direction == ParameterDirection.Out ? "out" : "ref"; writer.AppendLine($"/// Sets the '{param.Name}' {dirLabel} parameter to the specified value when this setup matches."); - writer.AppendLine($"public {wrapperName} {methodName}({param.FullyQualifiedType} {param.Name}) {{ EnsureSetup().SetsOutParameter({i}, {param.Name}); return this; }}"); + if (param.SpanElementType is not null) + { + // Span types: convert to array for storage, reconstruct at invocation time + writer.AppendLine($"public {wrapperName} {methodName}({param.FullyQualifiedType} {param.Name}) {{ EnsureSetup().SetsOutParameter({i}, {param.Name}.ToArray()); return this; }}"); + } + else + { + writer.AppendLine($"public {wrapperName} {methodName}({param.FullyQualifiedType} {param.Name}) {{ EnsureSetup().SetsOutParameter({i}, {param.Name}); return this; }}"); + } } } diff --git a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs index 586fa50eb5..53e157ad2e 100644 --- a/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs +++ b/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs @@ -301,7 +301,8 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m HasDefaultValue = p.HasExplicitDefaultValue, DefaultValueExpression = p.HasExplicitDefaultValue ? FormatDefaultValue(p) : null, IsValueType = p.Type.IsValueType, - IsRefStruct = p.Type.IsRefLikeType + IsRefStruct = p.Type.IsRefLikeType, + SpanElementType = GetSpanElementType(p.Type) }).ToImmutableArray() ), TypeParameters = new EquatableArray( @@ -319,7 +320,8 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m IsVirtualMember = method.IsVirtual || method.IsOverride, IsProtected = method.DeclaredAccessibility == Accessibility.Protected || method.DeclaredAccessibility == Accessibility.ProtectedOrInternal, - IsRefStructReturn = returnType.IsRefLikeType + IsRefStructReturn = returnType.IsRefLikeType, + SpanReturnElementType = returnType.IsRefLikeType ? GetSpanElementType(returnType) : null }; } @@ -369,7 +371,8 @@ private static MockMemberModel CreatePropertyModel(IPropertySymbol property, ref IsVirtualMember = property.IsVirtual || property.IsOverride, IsProtected = property.DeclaredAccessibility == Accessibility.Protected || property.DeclaredAccessibility == Accessibility.ProtectedOrInternal, - IsRefStructReturn = property.Type.IsRefLikeType + IsRefStructReturn = property.Type.IsRefLikeType, + SpanReturnElementType = property.Type.IsRefLikeType ? GetSpanElementType(property.Type) : null }; } @@ -545,4 +548,25 @@ private static string GetMethodKey(IMethodSymbol method) if (value is char c) return $"'{c}'"; return value.ToString(); } + + /// + /// For ReadOnlySpan<T> or Span<T> types, returns the fully qualified element type. + /// Returns null for all other types. + /// + private static string? GetSpanElementType(ITypeSymbol type) + { + if (type is not INamedTypeSymbol { IsGenericType: true, TypeArguments.Length: 1 } namedType) + return null; + + var constructed = namedType.ConstructedFrom; + var ns = constructed.ContainingNamespace?.ToDisplayString(); + var name = constructed.MetadataName; + + if (ns == "System" && name is "ReadOnlySpan`1" or "Span`1") + { + return namedType.TypeArguments[0].GetFullyQualifiedName(); + } + + return null; + } } diff --git a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs index e8b1a0b67d..69fa10234b 100644 --- a/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs +++ b/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs @@ -32,6 +32,12 @@ internal sealed record MockMemberModel : IEquatable public bool IsProtected { get; init; } public bool IsRefStructReturn { get; init; } + /// + /// For methods returning ReadOnlySpan<T> or Span<T>, the fully qualified element type. + /// Null for non-span return types. Used to support configurable span return values via array conversion. + /// + public string? SpanReturnElementType { get; init; } + /// /// Returns true if the method has any non-out ref struct parameters. /// Computed from — does not participate in equality. @@ -63,7 +69,8 @@ public bool Equals(MockMemberModel? other) && IsAbstractMember == other.IsAbstractMember && IsVirtualMember == other.IsVirtualMember && IsProtected == other.IsProtected - && IsRefStructReturn == other.IsRefStructReturn; + && IsRefStructReturn == other.IsRefStructReturn + && SpanReturnElementType == other.SpanReturnElementType; } public override int GetHashCode() diff --git a/TUnit.Mocks.SourceGenerator/Models/MockParameterModel.cs b/TUnit.Mocks.SourceGenerator/Models/MockParameterModel.cs index 36817c0fe6..5397bbb4c8 100644 --- a/TUnit.Mocks.SourceGenerator/Models/MockParameterModel.cs +++ b/TUnit.Mocks.SourceGenerator/Models/MockParameterModel.cs @@ -13,6 +13,12 @@ internal sealed record MockParameterModel : IEquatable public bool IsValueType { get; init; } public bool IsRefStruct { get; init; } + /// + /// For ReadOnlySpan<T> or Span<T> parameters, the fully qualified element type (e.g. "byte"). + /// Null for non-span parameters. Used to support out/ref span parameters via array conversion. + /// + public string? SpanElementType { get; init; } + public bool Equals(MockParameterModel? other) { if (other is null) return false; @@ -21,7 +27,8 @@ public bool Equals(MockParameterModel? other) && FullyQualifiedType == other.FullyQualifiedType && Direction == other.Direction && IsValueType == other.IsValueType - && IsRefStruct == other.IsRefStruct; + && IsRefStruct == other.IsRefStruct + && SpanElementType == other.SpanElementType; } public override int GetHashCode() @@ -34,6 +41,7 @@ public override int GetHashCode() hash = hash * 31 + (int)Direction; hash = hash * 31 + IsValueType.GetHashCode(); hash = hash * 31 + IsRefStruct.GetHashCode(); + hash = hash * 31 + (SpanElementType?.GetHashCode() ?? 0); return hash; } } diff --git a/TUnit.Mocks.Tests/ComprehensiveOutRefSpanTests.cs b/TUnit.Mocks.Tests/ComprehensiveOutRefSpanTests.cs new file mode 100644 index 0000000000..1c8f2bb0f3 --- /dev/null +++ b/TUnit.Mocks.Tests/ComprehensiveOutRefSpanTests.cs @@ -0,0 +1,1369 @@ +using TUnit.Mocks; +using TUnit.Mocks.Arguments; + +namespace TUnit.Mocks.Tests; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Interfaces +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Out Span<byte> (mutable span) parameters. +public interface IMutableSpanOutput +{ + void Fill(out Span buffer); + int Write(string text, out Span written); +} + +/// Out ReadOnlySpan<char> (different element type). +public interface ICharSpanReader +{ + bool TryReadLine(out ReadOnlySpan line); + int GetToken(string input, out ReadOnlySpan token); +} + +/// Multiple out params: regular int + ReadOnlySpan. +public interface IMultiOutput +{ + bool Extract(string input, out int count, out ReadOnlySpan data); +} + +/// Ref int + out ReadOnlySpan combo. +public interface ICodec +{ + bool Decode(ref int offset, out ReadOnlySpan decoded); +} + +/// ReadOnlySpan input + ReadOnlySpan output on the same method. +public interface ITransformer +{ + void Transform(ReadOnlySpan input, out ReadOnlySpan output); +} + +/// Ref with returns and multiple params. +public interface ICounter +{ + int Increment(ref int value, int step); + void Clear(ref int value); + bool TryAdvance(ref int position, int limit); +} + +/// Ref + out (non-span) on the same method. +public interface ISwapper +{ + void SwapAndReport(ref int value, out string report); +} + +/// Multiple ref struct out params of different element types. +public interface IDualSpanOutput +{ + void Split(string input, out ReadOnlySpan bytes, out ReadOnlySpan chars); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Out Span (mutable) tests +// ═══════════════════════════════════════════════════════════════════════════════ + +public class OutMutableSpanTests +{ + [Test] + public async Task Out_Span_Empty() + { + var mock = Mock.Of(); + mock.Fill().SetsOutBuffer(Span.Empty); + + mock.Object.Fill(out var buffer); + var length = buffer.Length; + + await Assert.That(length).IsEqualTo(0); + } + + [Test] + public async Task Out_Span_With_Data() + { + var mock = Mock.Of(); + mock.Fill().SetsOutBuffer(new Span([10, 20, 30])); + + mock.Object.Fill(out var buffer); + var length = buffer.Length; + var b0 = buffer[0]; + var b1 = buffer[1]; + var b2 = buffer[2]; + + await Assert.That(length).IsEqualTo(3); + await Assert.That(b0).IsEqualTo((byte)10); + await Assert.That(b1).IsEqualTo((byte)20); + await Assert.That(b2).IsEqualTo((byte)30); + } + + [Test] + public async Task Out_Span_With_Returns_And_Mixed_Params() + { + var mock = Mock.Of(); + mock.Write("hello") + .Returns(5) + .SetsOutWritten(new Span([0x68, 0x65, 0x6C])); + + var result = mock.Object.Write("hello", out var written); + var length = written.Length; + var w0 = written[0]; + + await Assert.That(result).IsEqualTo(5); + await Assert.That(length).IsEqualTo(3); + await Assert.That(w0).IsEqualTo((byte)0x68); + } + + [Test] + public async Task Out_Span_Callback_Fires() + { + var wasCalled = false; + var mock = Mock.Of(); + mock.Fill() + .Callback(() => wasCalled = true) + .SetsOutBuffer(new Span([1])); + + mock.Object.Fill(out _); + + await Assert.That(wasCalled).IsTrue(); + } + + [Test] + public async Task Out_Span_Throws() + { + var mock = Mock.Of(); + mock.Fill().Throws(); + + var ex = Assert.Throws(() => mock.Object.Fill(out _)); + + await Assert.That(ex).IsNotNull(); + } + + [Test] + public async Task Out_Span_Verification() + { + var mock = Mock.Of(); + mock.Object.Fill(out _); + mock.Object.Fill(out _); + + mock.Fill().WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task Out_Span_Never_Called() + { + var mock = Mock.Of(); + + mock.Fill().WasNeverCalled(); + await Assert.That(true).IsTrue(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Out ReadOnlySpan tests +// ═══════════════════════════════════════════════════════════════════════════════ + +public class OutReadOnlySpanCharTests +{ + [Test] + public async Task Out_ReadOnlySpan_Char_With_Data() + { + var mock = Mock.Of(); + mock.TryReadLine() + .Returns(true) + .SetsOutLine("hello world".AsSpan()); + + var success = mock.Object.TryReadLine(out var line); + var length = line.Length; + var c0 = line[0]; + var c4 = line[4]; + + await Assert.That(success).IsTrue(); + await Assert.That(length).IsEqualTo(11); + await Assert.That(c0).IsEqualTo('h'); + await Assert.That(c4).IsEqualTo('o'); + } + + [Test] + public async Task Out_ReadOnlySpan_Char_Empty() + { + var mock = Mock.Of(); + mock.TryReadLine() + .Returns(false) + .SetsOutLine(ReadOnlySpan.Empty); + + var success = mock.Object.TryReadLine(out var line); + var length = line.Length; + + await Assert.That(success).IsFalse(); + await Assert.That(length).IsEqualTo(0); + } + + [Test] + public async Task Out_ReadOnlySpan_Char_Mixed_Params_With_Matchers() + { + var mock = Mock.Of(); + mock.GetToken("CSV") + .Returns(3) + .SetsOutToken("foo".AsSpan()); + + mock.GetToken("JSON") + .Returns(4) + .SetsOutToken("data".AsSpan()); + + // First call + var r1 = mock.Object.GetToken("CSV", out var t1); + var t1Len = t1.Length; + var t1c0 = t1[0]; + + // Second call + var r2 = mock.Object.GetToken("JSON", out var t2); + var t2Len = t2.Length; + var t2c0 = t2[0]; + + await Assert.That(r1).IsEqualTo(3); + await Assert.That(t1Len).IsEqualTo(3); + await Assert.That(t1c0).IsEqualTo('f'); + await Assert.That(r2).IsEqualTo(4); + await Assert.That(t2Len).IsEqualTo(4); + await Assert.That(t2c0).IsEqualTo('d'); + } + + [Test] + public async Task Out_ReadOnlySpan_Char_Arg_Any_Matcher() + { + var mock = Mock.Of(); + mock.GetToken(Arg.Any()) + .Returns(1) + .SetsOutToken("x".AsSpan()); + + var r1 = mock.Object.GetToken("anything", out var t1); + var r2 = mock.Object.GetToken("else", out var t2); + var t1Len = t1.Length; + var t2Len = t2.Length; + + await Assert.That(r1).IsEqualTo(1); + await Assert.That(r2).IsEqualTo(1); + await Assert.That(t1Len).IsEqualTo(1); + await Assert.That(t2Len).IsEqualTo(1); + } + + [Test] + public async Task Out_ReadOnlySpan_Char_Arg_Is_Predicate() + { + var mock = Mock.Of(); + mock.GetToken(Arg.Is(s => s.StartsWith("J"))) + .Returns(42) + .SetsOutToken("json".AsSpan()); + + var r1 = mock.Object.GetToken("JSON", out var t1); + var t1Len = t1.Length; + var r2 = mock.Object.GetToken("CSV", out var t2); + var t2Len = t2.Length; + + await Assert.That(r1).IsEqualTo(42); + await Assert.That(t1Len).IsEqualTo(4); + // "CSV" doesn't match predicate, returns default + await Assert.That(r2).IsEqualTo(0); + await Assert.That(t2Len).IsEqualTo(0); + } + + [Test] + public async Task Out_ReadOnlySpan_Char_Verification_Multiple() + { + var mock = Mock.Of(); + mock.GetToken(Arg.Any()).Returns(0); + + mock.Object.GetToken("a", out _); + mock.Object.GetToken("b", out _); + mock.Object.GetToken("a", out _); + + mock.GetToken("a").WasCalled(Times.Exactly(2)); + mock.GetToken("b").WasCalled(Times.Once); + mock.GetToken(Arg.Any()).WasCalled(Times.Exactly(3)); + mock.GetToken("z").WasNeverCalled(); + await Assert.That(true).IsTrue(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Multiple out params (int + ReadOnlySpan) tests +// ═══════════════════════════════════════════════════════════════════════════════ + +public class MultipleOutParamsTests +{ + [Test] + public async Task Multiple_Out_Int_And_Span() + { + var mock = Mock.Of(); + mock.Extract("test") + .Returns(true) + .SetsOutCount(3) + .SetsOutData(new ReadOnlySpan([0xAA, 0xBB, 0xCC])); + + var success = mock.Object.Extract("test", out var count, out var data); + var dataLen = data.Length; + var d0 = data[0]; + var d2 = data[2]; + + await Assert.That(success).IsTrue(); + await Assert.That(count).IsEqualTo(3); + await Assert.That(dataLen).IsEqualTo(3); + await Assert.That(d0).IsEqualTo((byte)0xAA); + await Assert.That(d2).IsEqualTo((byte)0xCC); + } + + [Test] + public async Task Multiple_Out_Only_Int_Set() + { + // Only set the int out param, span stays default + var mock = Mock.Of(); + mock.Extract("partial") + .Returns(true) + .SetsOutCount(7); + + var success = mock.Object.Extract("partial", out var count, out var data); + var dataLen = data.Length; + + await Assert.That(success).IsTrue(); + await Assert.That(count).IsEqualTo(7); + await Assert.That(dataLen).IsEqualTo(0); + } + + [Test] + public async Task Multiple_Out_Only_Span_Set() + { + // Only set the span out param, int stays default + var mock = Mock.Of(); + mock.Extract("data-only") + .Returns(false) + .SetsOutData(new ReadOnlySpan([1, 2])); + + var success = mock.Object.Extract("data-only", out var count, out var data); + var dataLen = data.Length; + + await Assert.That(success).IsFalse(); + await Assert.That(count).IsEqualTo(0); // default int + await Assert.That(dataLen).IsEqualTo(2); + } + + [Test] + public async Task Multiple_Out_Chain_Order_SetsOut_Before_Returns() + { + var mock = Mock.Of(); + mock.Extract(Arg.Any()) + .SetsOutCount(99) + .SetsOutData(new ReadOnlySpan([0xFF])) + .Returns(true); + + var success = mock.Object.Extract("any", out var count, out var data); + var dataLen = data.Length; + + await Assert.That(success).IsTrue(); + await Assert.That(count).IsEqualTo(99); + await Assert.That(dataLen).IsEqualTo(1); + } + + [Test] + public async Task Multiple_Out_Different_Setups_Per_Input() + { + var mock = Mock.Of(); + mock.Extract("alpha") + .Returns(true) + .SetsOutCount(1) + .SetsOutData(new ReadOnlySpan([0x01])); + + mock.Extract("beta") + .Returns(true) + .SetsOutCount(2) + .SetsOutData(new ReadOnlySpan([0x02, 0x03])); + + // alpha + mock.Object.Extract("alpha", out var c1, out var d1); + var c1Val = c1; + var d1Len = d1.Length; + var d1b0 = d1[0]; + + // beta + mock.Object.Extract("beta", out var c2, out var d2); + var c2Val = c2; + var d2Len = d2.Length; + + await Assert.That(c1Val).IsEqualTo(1); + await Assert.That(d1Len).IsEqualTo(1); + await Assert.That(d1b0).IsEqualTo((byte)0x01); + await Assert.That(c2Val).IsEqualTo(2); + await Assert.That(d2Len).IsEqualTo(2); + } + + [Test] + public async Task Multiple_Out_Callback_With_Args() + { + string? capturedInput = null; + var mock = Mock.Of(); + mock.Extract(Arg.Any()) + .Callback((Action)(args => capturedInput = (string?)args[0])) + .Returns(true) + .SetsOutCount(1) + .SetsOutData(new ReadOnlySpan([0xDE])); + + mock.Object.Extract("captured", out _, out _); + + await Assert.That(capturedInput).IsEqualTo("captured"); + } + + [Test] + public async Task Multiple_Out_Throws_Exception() + { + var mock = Mock.Of(); + mock.Extract("bad").Throws(); + + var ex = Assert.Throws(() => + mock.Object.Extract("bad", out _, out _)); + + await Assert.That(ex).IsNotNull(); + } + + [Test] + public async Task Multiple_Out_Verification() + { + var mock = Mock.Of(); + mock.Extract(Arg.Any()).Returns(false); + + mock.Object.Extract("a", out _, out _); + mock.Object.Extract("b", out _, out _); + mock.Object.Extract("a", out _, out _); + + mock.Extract("a").WasCalled(Times.Exactly(2)); + mock.Extract("b").WasCalled(Times.Once); + mock.Extract(Arg.Any()).WasCalled(Times.Exactly(3)); + mock.Extract("c").WasNeverCalled(); + await Assert.That(true).IsTrue(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Ref + out span combo tests +// ═══════════════════════════════════════════════════════════════════════════════ + +public class RefAndOutSpanTests +{ + [Test] + public async Task Ref_Int_And_Out_Span() + { + var mock = Mock.Of(); + mock.Decode(0) + .Returns(true) + .SetsRefOffset(10) + .SetsOutDecoded(new ReadOnlySpan([0xCA, 0xFE])); + + int offset = 0; + var success = mock.Object.Decode(ref offset, out var decoded); + var decodedLen = decoded.Length; + var d0 = decoded[0]; + var d1 = decoded[1]; + + await Assert.That(success).IsTrue(); + await Assert.That(offset).IsEqualTo(10); + await Assert.That(decodedLen).IsEqualTo(2); + await Assert.That(d0).IsEqualTo((byte)0xCA); + await Assert.That(d1).IsEqualTo((byte)0xFE); + } + + [Test] + public async Task Ref_Int_And_Out_Span_Different_Offsets() + { + var mock = Mock.Of(); + mock.Decode(0) + .Returns(true) + .SetsRefOffset(5) + .SetsOutDecoded(new ReadOnlySpan([1, 2, 3, 4, 5])); + + mock.Decode(5) + .Returns(true) + .SetsRefOffset(8) + .SetsOutDecoded(new ReadOnlySpan([6, 7, 8])); + + mock.Decode(8) + .Returns(false) + .SetsRefOffset(8); + + // First decode + int pos = 0; + var r1 = mock.Object.Decode(ref pos, out var d1); + var d1Len = d1.Length; + + // Second decode + var r2 = mock.Object.Decode(ref pos, out var d2); + var d2Len = d2.Length; + + // Third decode (end) + var r3 = mock.Object.Decode(ref pos, out var d3); + var d3Len = d3.Length; + + await Assert.That(r1).IsTrue(); + await Assert.That(pos).IsEqualTo(8); // modified by second decode + await Assert.That(d1Len).IsEqualTo(5); + await Assert.That(r2).IsTrue(); + await Assert.That(d2Len).IsEqualTo(3); + await Assert.That(r3).IsFalse(); + await Assert.That(d3Len).IsEqualTo(0); + } + + [Test] + public async Task Ref_And_Out_Span_With_Any_Matcher() + { + var mock = Mock.Of(); + mock.Decode(Arg.Any()) + .Returns(true) + .SetsRefOffset(100) + .SetsOutDecoded(new ReadOnlySpan([0xFF])); + + int pos = 42; + var success = mock.Object.Decode(ref pos, out var decoded); + var dLen = decoded.Length; + + await Assert.That(success).IsTrue(); + await Assert.That(pos).IsEqualTo(100); + await Assert.That(dLen).IsEqualTo(1); + } + + [Test] + public async Task Ref_And_Out_Span_Verification() + { + var mock = Mock.Of(); + mock.Decode(Arg.Any()).Returns(false); + + int p1 = 0, p2 = 5; + mock.Object.Decode(ref p1, out _); + mock.Object.Decode(ref p2, out _); + + mock.Decode(0).WasCalled(Times.Once); + mock.Decode(5).WasCalled(Times.Once); + mock.Decode(Arg.Any()).WasCalled(Times.Exactly(2)); + mock.Decode(99).WasNeverCalled(); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task Ref_And_Out_Span_Throws() + { + var mock = Mock.Of(); + mock.Decode(-1).Throws(); + + int bad = -1; + var ex = Assert.Throws(() => + mock.Object.Decode(ref bad, out _)); + + await Assert.That(ex).IsNotNull(); + } + + [Test] + public async Task Ref_And_Out_Span_Callback() + { + var wasCalled = false; + var mock = Mock.Of(); + mock.Decode(Arg.Any()) + .Callback(() => wasCalled = true) + .Returns(true) + .SetsOutDecoded(new ReadOnlySpan([1])); + + int pos = 0; + mock.Object.Decode(ref pos, out _); + + await Assert.That(wasCalled).IsTrue(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ReadOnlySpan input + ReadOnlySpan output tests +// ═══════════════════════════════════════════════════════════════════════════════ + +public class SpanInputAndOutputTests +{ +#if NET9_0_OR_GREATER + [Test] + public async Task RefStructArg_Input_With_Out_Span() + { + var mock = Mock.Of(); + mock.Transform(RefStructArg>.Any) + .SetsOutOutput(new ReadOnlySpan([0xDE, 0xAD])); + + mock.Object.Transform(new byte[] { 1, 2, 3 }, out var output); + var len = output.Length; + var o0 = output[0]; + var o1 = output[1]; + + await Assert.That(len).IsEqualTo(2); + await Assert.That(o0).IsEqualTo((byte)0xDE); + await Assert.That(o1).IsEqualTo((byte)0xAD); + } + + [Test] + public async Task RefStructArg_Input_With_Out_Span_Callback() + { + var wasCalled = false; + var mock = Mock.Of(); + mock.Transform(RefStructArg>.Any) + .Callback(() => wasCalled = true) + .SetsOutOutput(new ReadOnlySpan([1])); + + mock.Object.Transform(new byte[] { 0xFF }, out _); + + await Assert.That(wasCalled).IsTrue(); + } + + [Test] + public async Task RefStructArg_Input_With_Out_Span_Throws() + { + var mock = Mock.Of(); + mock.Transform(RefStructArg>.Any) + .Throws(); + + var ex = Assert.Throws(() => + mock.Object.Transform(new byte[] { 1 }, out _)); + + await Assert.That(ex).IsNotNull(); + } + + [Test] + public async Task RefStructArg_Input_With_Out_Span_Verification() + { + var mock = Mock.Of(); + mock.Object.Transform(new byte[] { 1 }, out _); + mock.Object.Transform(ReadOnlySpan.Empty, out _); + + mock.Transform(RefStructArg>.Any).WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); + } +#else + [Test] + public async Task PreNet9_Span_Input_With_Out_Span() + { + // Pre-NET9: ref struct input excluded from matching + var mock = Mock.Of(); + mock.Transform() + .SetsOutOutput(new ReadOnlySpan([0xBE, 0xEF])); + + mock.Object.Transform(new byte[] { 1 }, out var output); + var len = output.Length; + var o0 = output[0]; + + await Assert.That(len).IsEqualTo(2); + await Assert.That(o0).IsEqualTo((byte)0xBE); + } + + [Test] + public async Task PreNet9_Span_Input_Verification() + { + var mock = Mock.Of(); + mock.Object.Transform(new byte[] { 1 }, out _); + mock.Object.Transform(ReadOnlySpan.Empty, out _); + + mock.Transform().WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); + } +#endif +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Comprehensive ref parameter tests +// ═══════════════════════════════════════════════════════════════════════════════ + +public class ComprehensiveRefTests +{ + [Test] + public async Task Ref_With_Return_Value() + { + var mock = Mock.Of(); + mock.Increment(Arg.Any(), 1) + .Returns(1) + .SetsRefValue(11); + + int val = 10; + var result = mock.Object.Increment(ref val, 1); + + await Assert.That(result).IsEqualTo(1); + await Assert.That(val).IsEqualTo(11); + } + + [Test] + public async Task Ref_Exact_Value_Matching() + { + var mock = Mock.Of(); + mock.Increment(10, 1).Returns(11).SetsRefValue(11); + mock.Increment(20, 1).Returns(21).SetsRefValue(21); + + int v1 = 10; + var r1 = mock.Object.Increment(ref v1, 1); + + int v2 = 20; + var r2 = mock.Object.Increment(ref v2, 1); + + await Assert.That(r1).IsEqualTo(11); + await Assert.That(v1).IsEqualTo(11); + await Assert.That(r2).IsEqualTo(21); + await Assert.That(v2).IsEqualTo(21); + } + + [Test] + public async Task Ref_Predicate_Matching() + { + var mock = Mock.Of(); + mock.TryAdvance(Arg.Is(v => v >= 0), 100) + .Returns(true) + .SetsRefPosition(50); + + mock.TryAdvance(Arg.Is(v => v < 0), Arg.Any()) + .Returns(false); + + // Positive position + int pos = 0; + var r1 = mock.Object.TryAdvance(ref pos, 100); + await Assert.That(r1).IsTrue(); + await Assert.That(pos).IsEqualTo(50); + + // Negative position + int negPos = -1; + var r2 = mock.Object.TryAdvance(ref negPos, 100); + await Assert.That(r2).IsFalse(); + } + + [Test] + public async Task Ref_Void_Method() + { + var mock = Mock.Of(); + mock.Clear(Arg.Any()).SetsRefValue(0); + + int val = 42; + mock.Object.Clear(ref val); + + await Assert.That(val).IsEqualTo(0); + } + + [Test] + public async Task Ref_Not_Modified_Without_Setup() + { + var mock = Mock.Of(); + // No SetsRefValue configured + + int val = 42; + mock.Object.Clear(ref val); + + // Value stays unchanged when no setter is configured + await Assert.That(val).IsEqualTo(42); + } + + [Test] + public async Task Ref_Callback_Fires() + { + var wasCalled = false; + var mock = Mock.Of(); + mock.Clear(Arg.Any()) + .Callback(() => wasCalled = true) + .SetsRefValue(0); + + int val = 10; + mock.Object.Clear(ref val); + + await Assert.That(wasCalled).IsTrue(); + await Assert.That(val).IsEqualTo(0); + } + + [Test] + public async Task Ref_Throws() + { + var mock = Mock.Of(); + mock.Increment(Arg.Any(), 0).Throws(); + + int val = 1; + var ex = Assert.Throws(() => mock.Object.Increment(ref val, 0)); + + await Assert.That(ex).IsNotNull(); + } + + [Test] + public async Task Ref_Verification_With_Exact_Value() + { + var mock = Mock.Of(); + mock.Increment(Arg.Any(), Arg.Any()).Returns(0); + + int v1 = 5, v2 = 10; + mock.Object.Increment(ref v1, 1); + mock.Object.Increment(ref v2, 2); + mock.Object.Increment(ref v1, 1); + + mock.Increment(5, 1).WasCalled(Times.Exactly(2)); + mock.Increment(10, 2).WasCalled(Times.Once); + mock.Increment(Arg.Any(), Arg.Any()).WasCalled(Times.Exactly(3)); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task Ref_Verification_AtLeast_AtMost() + { + var mock = Mock.Of(); + mock.Clear(Arg.Any()); + + int v = 1; + mock.Object.Clear(ref v); + mock.Object.Clear(ref v); + mock.Object.Clear(ref v); + + mock.Clear(Arg.Any()).WasCalled(Times.AtLeast(2)); + mock.Clear(Arg.Any()).WasCalled(Times.AtMost(5)); + mock.Clear(Arg.Any()).WasCalled(Times.Between(2, 4)); + await Assert.That(true).IsTrue(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Ref + out (non-span) combined tests +// ═══════════════════════════════════════════════════════════════════════════════ + +public class RefAndOutCombinedTests +{ + [Test] + public async Task Ref_And_Out_Both_Set() + { + var mock = Mock.Of(); + mock.SwapAndReport(42) + .SetsRefValue(0) + .SetsOutReport("swapped 42 to 0"); + + int val = 42; + mock.Object.SwapAndReport(ref val, out var report); + + await Assert.That(val).IsEqualTo(0); + await Assert.That(report).IsEqualTo("swapped 42 to 0"); + } + + [Test] + public async Task Ref_And_Out_Only_Ref_Set() + { + var mock = Mock.Of(); + mock.SwapAndReport(Arg.Any()).SetsRefValue(99); + + int val = 1; + mock.Object.SwapAndReport(ref val, out var report); + + await Assert.That(val).IsEqualTo(99); + await Assert.That(report).IsNull(); + } + + [Test] + public async Task Ref_And_Out_Only_Out_Set() + { + var mock = Mock.Of(); + mock.SwapAndReport(Arg.Any()).SetsOutReport("report"); + + int val = 50; + mock.Object.SwapAndReport(ref val, out var report); + + // ref not configured → stays unchanged + await Assert.That(val).IsEqualTo(50); + await Assert.That(report).IsEqualTo("report"); + } + + [Test] + public async Task Ref_And_Out_Callback() + { + var wasCalled = false; + var mock = Mock.Of(); + mock.SwapAndReport(Arg.Any()) + .Callback(() => wasCalled = true) + .SetsRefValue(0) + .SetsOutReport("done"); + + int val = 10; + mock.Object.SwapAndReport(ref val, out _); + + await Assert.That(wasCalled).IsTrue(); + } + + [Test] + public async Task Ref_And_Out_Throws() + { + var mock = Mock.Of(); + mock.SwapAndReport(-1).Throws(); + + int val = -1; + var ex = Assert.Throws(() => + mock.Object.SwapAndReport(ref val, out _)); + + await Assert.That(ex).IsNotNull(); + } + + [Test] + public async Task Ref_And_Out_Verification() + { + var mock = Mock.Of(); + + int v1 = 1, v2 = 2; + mock.Object.SwapAndReport(ref v1, out _); + mock.Object.SwapAndReport(ref v2, out _); + mock.Object.SwapAndReport(ref v1, out _); + + mock.SwapAndReport(1).WasCalled(Times.Exactly(2)); + mock.SwapAndReport(2).WasCalled(Times.Once); + mock.SwapAndReport(Arg.Any()).WasCalled(Times.Exactly(3)); + await Assert.That(true).IsTrue(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Multiple ref struct out params of different element types +// ═══════════════════════════════════════════════════════════════════════════════ + +public class DualSpanOutputTests +{ + [Test] + public async Task Two_Span_Out_Params_Both_Set() + { + var mock = Mock.Of(); + mock.Split("hello") + .SetsOutBytes(new ReadOnlySpan([0x68, 0x65])) + .SetsOutChars("hi".AsSpan()); + + mock.Object.Split("hello", out var bytes, out var chars); + var bLen = bytes.Length; + var cLen = chars.Length; + var b0 = bytes[0]; + var c0 = chars[0]; + + await Assert.That(bLen).IsEqualTo(2); + await Assert.That(cLen).IsEqualTo(2); + await Assert.That(b0).IsEqualTo((byte)0x68); + await Assert.That(c0).IsEqualTo('h'); + } + + [Test] + public async Task Two_Span_Out_Only_First_Set() + { + var mock = Mock.Of(); + mock.Split("partial") + .SetsOutBytes(new ReadOnlySpan([1, 2, 3])); + + mock.Object.Split("partial", out var bytes, out var chars); + var bLen = bytes.Length; + var cLen = chars.Length; + + await Assert.That(bLen).IsEqualTo(3); + await Assert.That(cLen).IsEqualTo(0); // default + } + + [Test] + public async Task Two_Span_Out_Only_Second_Set() + { + var mock = Mock.Of(); + mock.Split("chars-only") + .SetsOutChars("abc".AsSpan()); + + mock.Object.Split("chars-only", out var bytes, out var chars); + var bLen = bytes.Length; + var cLen = chars.Length; + var c2 = chars[2]; + + await Assert.That(bLen).IsEqualTo(0); // default + await Assert.That(cLen).IsEqualTo(3); + await Assert.That(c2).IsEqualTo('c'); + } + + [Test] + public async Task Two_Span_Out_Verification() + { + var mock = Mock.Of(); + + mock.Object.Split("a", out _, out _); + mock.Object.Split("b", out _, out _); + + mock.Split("a").WasCalled(Times.Once); + mock.Split("b").WasCalled(Times.Once); + mock.Split(Arg.Any()).WasCalled(Times.Exactly(2)); + mock.Split("c").WasNeverCalled(); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task Two_Span_Out_Throws() + { + var mock = Mock.Of(); + mock.Split("").Throws(); + + var ex = Assert.Throws(() => + mock.Object.Split("", out _, out _)); + + await Assert.That(ex).IsNotNull(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Sequential and advanced setup patterns with out span params +// ═══════════════════════════════════════════════════════════════════════════════ + +public class SequentialSpanSetupTests +{ + [Test] + public async Task Then_Returns_Sequence_With_Out_Span() + { + // SetsOut applies at setup level; Then() sequences Returns/Callback/Throws + var mock = Mock.Of(); + mock.TryParse(Arg.Any()) + .SetsOutData(new ReadOnlySpan([0xAA])) + .Returns(true) + .Then() + .Returns(false); + + // First call: returns true, span data set + var r1 = mock.Object.TryParse("a", out var d1); + var d1Len = d1.Length; + var d1b0 = d1[0]; + + // Second call: returns false (Then sequencing), span data still set + var r2 = mock.Object.TryParse("b", out var d2); + var d2Len = d2.Length; + + await Assert.That(r1).IsTrue(); + await Assert.That(d1Len).IsEqualTo(1); + await Assert.That(d1b0).IsEqualTo((byte)0xAA); + await Assert.That(r2).IsFalse(); + await Assert.That(d2Len).IsEqualTo(1); // span still applied + } + + [Test] + public async Task Then_Throws_After_Success_With_Out_Span() + { + var mock = Mock.Of(); + mock.TryParse("data") + .Returns(true) + .SetsOutData(new ReadOnlySpan([0xAA])) + .Then() + .Throws(); + + // First call succeeds with span data + var r1 = mock.Object.TryParse("data", out var d1); + var d1Len = d1.Length; + + // Second call throws + var ex = Assert.Throws(() => + mock.Object.TryParse("data", out _)); + + await Assert.That(r1).IsTrue(); + await Assert.That(d1Len).IsEqualTo(1); + await Assert.That(ex).IsNotNull(); + } + + [Test] + public async Task Then_Callback_Sequence_With_Out_Span() + { + // Callbacks sequence correctly with Then(), span stays constant + var callCount = 0; + var mock = Mock.Of(); + mock.Do() + .Callback(() => callCount++) + .SetsOutBuffer(new ReadOnlySpan([0xFF])) + .Then() + .Callback(() => callCount += 10); + + mock.Object.Do(out var d1); + var d1b0 = d1[0]; + mock.Object.Do(out var d2); + var d2b0 = d2[0]; + + await Assert.That(callCount).IsEqualTo(11); + // Both calls get the same span data + await Assert.That(d1b0).IsEqualTo((byte)0xFF); + await Assert.That(d2b0).IsEqualTo((byte)0xFF); + } + + [Test] + public async Task ReturnsSequentially_With_Out_Span() + { + var mock = Mock.Of(); + mock.TryParse(Arg.Any()) + .ReturnsSequentially(true, true, false) + .SetsOutData(new ReadOnlySpan([0x01, 0x02])); + + var r1 = mock.Object.TryParse("a", out var d1); + var d1Len = d1.Length; + var r2 = mock.Object.TryParse("b", out var d2); + var d2Len = d2.Length; + var r3 = mock.Object.TryParse("c", out var d3); + var d3Len = d3.Length; + + await Assert.That(r1).IsTrue(); + await Assert.That(r2).IsTrue(); + await Assert.That(r3).IsFalse(); + // All calls get the same span data + await Assert.That(d1Len).IsEqualTo(2); + await Assert.That(d2Len).IsEqualTo(2); + await Assert.That(d3Len).IsEqualTo(2); + } + + [Test] + public async Task Separate_Setups_For_Different_Span_Data() + { + // Use separate setups per input value instead of Then() for different span data + var mock = Mock.Of(); + mock.TryParse("first") + .Returns(true) + .SetsOutData(new ReadOnlySpan([0x01])); + + mock.TryParse("second") + .Returns(true) + .SetsOutData(new ReadOnlySpan([0x02, 0x03])); + + var r1 = mock.Object.TryParse("first", out var d1); + var d1Len = d1.Length; + var d1b0 = d1[0]; + + var r2 = mock.Object.TryParse("second", out var d2); + var d2Len = d2.Length; + var d2b0 = d2[0]; + + await Assert.That(r1).IsTrue(); + await Assert.That(d1Len).IsEqualTo(1); + await Assert.That(d1b0).IsEqualTo((byte)0x01); + await Assert.That(r2).IsTrue(); + await Assert.That(d2Len).IsEqualTo(2); + await Assert.That(d2b0).IsEqualTo((byte)0x02); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ReadOnlySpan comprehensive out parameter tests (extends OutRefSpanTests) +// ═══════════════════════════════════════════════════════════════════════════════ + +public class ReadOnlySpanByteOutComprehensiveTests +{ + [Test] + public async Task Callback_With_Void_Out_Span_Method() + { + var wasCalled = false; + var mock = Mock.Of(); + mock.Do() + .Callback(() => wasCalled = true) + .SetsOutBuffer(new ReadOnlySpan([0xFF])); + + mock.Object.Do(out var buffer); + var len = buffer.Length; + + await Assert.That(wasCalled).IsTrue(); + await Assert.That(len).IsEqualTo(1); + } + + [Test] + public async Task Throws_With_Void_Out_Span_Method() + { + var mock = Mock.Of(); + mock.Do().Throws(); + + var ex = Assert.Throws(() => + mock.Object.Do(out _)); + + await Assert.That(ex).IsNotNull(); + } + + [Test] + public async Task Throws_Instance_With_Out_Span() + { + var mock = Mock.Of(); + mock.Do().Throws(new InvalidOperationException("custom message")); + + var ex = Assert.Throws(() => + mock.Object.Do(out _)); + + await Assert.That(ex).IsNotNull(); + await Assert.That(ex!.Message).IsEqualTo("custom message"); + } + + [Test] + public async Task Verification_Once() + { + var mock = Mock.Of(); + mock.Object.Do(out _); + + mock.Do().WasCalled(Times.Once); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task Verification_Multiple() + { + var mock = Mock.Of(); + mock.Object.Do(out _); + mock.Object.Do(out _); + mock.Object.Do(out _); + + mock.Do().WasCalled(Times.Exactly(3)); + mock.Do().WasCalled(Times.AtLeast(2)); + mock.Do().WasCalled(Times.AtMost(5)); + mock.Do().WasCalled(Times.Between(1, 4)); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task Verification_Never() + { + var mock = Mock.Of(); + + mock.Do().WasNeverCalled(); + mock.Do().WasCalled(Times.Never); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task Out_Span_Large_Data() + { + var largeData = new byte[1024]; + for (int i = 0; i < largeData.Length; i++) + largeData[i] = (byte)(i % 256); + + var mock = Mock.Of(); + mock.Do().SetsOutBuffer(new ReadOnlySpan(largeData)); + + mock.Object.Do(out var buffer); + var len = buffer.Length; + var first = buffer[0]; + var last = buffer[1023]; + + await Assert.That(len).IsEqualTo(1024); + await Assert.That(first).IsEqualTo((byte)0); + await Assert.That(last).IsEqualTo((byte)(1023 % 256)); + } + + [Test] + public async Task Out_Span_Chaining_Returns_Then_SetsOut() + { + var mock = Mock.Of(); + mock.TryParse("a") + .Returns(true) + .SetsOutData(new ReadOnlySpan([1])); + + var success = mock.Object.TryParse("a", out var data); + var len = data.Length; + + await Assert.That(success).IsTrue(); + await Assert.That(len).IsEqualTo(1); + } + + [Test] + public async Task Out_Span_Chaining_SetsOut_Then_Returns() + { + var mock = Mock.Of(); + mock.TryParse("b") + .SetsOutData(new ReadOnlySpan([2, 3])) + .Returns(true); + + var success = mock.Object.TryParse("b", out var data); + var len = data.Length; + + await Assert.That(success).IsTrue(); + await Assert.That(len).IsEqualTo(2); + } + + [Test] + public async Task Out_Span_Untyped_SetsOutParameter_Still_Works() + { + // Backward compat: use index-based API with a byte array + var mock = Mock.Of(); + mock.TryParse("key") + .Returns(true) + .SetsOutParameter(1, new byte[] { 0xDE, 0xAD }); + + var success = mock.Object.TryParse("key", out var data); + var len = data.Length; + var d0 = data[0]; + + await Assert.That(success).IsTrue(); + await Assert.That(len).IsEqualTo(2); + await Assert.That(d0).IsEqualTo((byte)0xDE); + } + + [Test] + public async Task Out_Span_Different_Values_Per_Input() + { + var mock = Mock.Of(); + mock.TryParse("alpha") + .Returns(true) + .SetsOutData(new ReadOnlySpan([0x01])); + + mock.TryParse("beta") + .Returns(true) + .SetsOutData(new ReadOnlySpan([0x02, 0x03])); + + mock.TryParse("gamma") + .Returns(false); + + // alpha + var r1 = mock.Object.TryParse("alpha", out var d1); + var d1Len = d1.Length; + + // beta + var r2 = mock.Object.TryParse("beta", out var d2); + var d2Len = d2.Length; + + // gamma — no span set + var r3 = mock.Object.TryParse("gamma", out var d3); + var d3Len = d3.Length; + + // unknown — no setup at all + var r4 = mock.Object.TryParse("unknown", out var d4); + var d4Len = d4.Length; + + await Assert.That(r1).IsTrue(); + await Assert.That(d1Len).IsEqualTo(1); + await Assert.That(r2).IsTrue(); + await Assert.That(d2Len).IsEqualTo(2); + await Assert.That(r3).IsFalse(); + await Assert.That(d3Len).IsEqualTo(0); + await Assert.That(r4).IsFalse(); // default + await Assert.That(d4Len).IsEqualTo(0); + } + + [Test] + public async Task Out_Span_Verification_With_Mixed_Params() + { + var mock = Mock.Of(); + mock.TryParse(Arg.Any()).Returns(false); + + mock.Object.TryParse("x", out _); + mock.Object.TryParse("y", out _); + mock.Object.TryParse("x", out _); + + mock.TryParse("x").WasCalled(Times.Exactly(2)); + mock.TryParse("y").WasCalled(Times.Once); + mock.TryParse(Arg.Any()).WasCalled(Times.Exactly(3)); + mock.TryParse("z").WasNeverCalled(); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task Out_Span_Callback_With_Args() + { + string? capturedInput = null; + var mock = Mock.Of(); + mock.TryParse(Arg.Any()) + .Callback((Action)(args => capturedInput = (string?)args[0])) + .Returns(true) + .SetsOutData(new ReadOnlySpan([1])); + + mock.Object.TryParse("captured-input", out _); + + await Assert.That(capturedInput).IsEqualTo("captured-input"); + } + + [Test] + public async Task Out_Span_Throws_Exception_Factory() + { + var mock = Mock.Of(); + mock.TryParse(Arg.Any()) + .Throws((Func)(args => + new ArgumentException($"Bad input: {args[0]}"))); + + var ex = Assert.Throws(() => + mock.Object.TryParse("bad", out _)); + + await Assert.That(ex).IsNotNull(); + await Assert.That(ex!.Message).IsEqualTo("Bad input: bad"); + } +} diff --git a/TUnit.Mocks.Tests/InParameterTests.cs b/TUnit.Mocks.Tests/InParameterTests.cs new file mode 100644 index 0000000000..e13c04c6c7 --- /dev/null +++ b/TUnit.Mocks.Tests/InParameterTests.cs @@ -0,0 +1,225 @@ +using TUnit.Mocks; + +namespace TUnit.Mocks.Tests; + +// ─── Interfaces with in parameters ────────────────────────────────────────── + +public interface ICalculatorWithIn +{ + int Add(in int a, in int b); + void Log(in string message); + double Compute(in int value, double factor); +} + +public readonly struct Point +{ + public int X { get; init; } + public int Y { get; init; } +} + +public interface IGeometry +{ + double Distance(in Point a, in Point b); + bool Contains(in Point point, int radius); +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +/// +/// Tests for 'in' (readonly ref) parameters — verifying argument matching, +/// returns, callbacks, throws, and verification all work correctly. +/// +public class InParameterTests +{ + [Test] + public async Task In_Params_Returns_Value() + { + var mock = Mock.Of(); + mock.Add(1, 2).Returns(3); + + var result = mock.Object.Add(1, 2); + + await Assert.That(result).IsEqualTo(3); + } + + [Test] + public async Task In_Params_Arg_Any_Matching() + { + var mock = Mock.Of(); + mock.Add(Arg.Any(), Arg.Any()).Returns(42); + + var result = mock.Object.Add(10, 20); + + await Assert.That(result).IsEqualTo(42); + } + + [Test] + public async Task In_Params_Specific_Value_Matching() + { + var mock = Mock.Of(); + mock.Add(5, 10).Returns(15); + mock.Add(1, 1).Returns(2); + + var r1 = mock.Object.Add(5, 10); + var r2 = mock.Object.Add(1, 1); + + await Assert.That(r1).IsEqualTo(15); + await Assert.That(r2).IsEqualTo(2); + } + + [Test] + public async Task In_Params_Void_Method() + { + var wasCalled = false; + var mock = Mock.Of(); + mock.Log(Arg.Any()).Callback(() => wasCalled = true); + + mock.Object.Log("hello"); + + await Assert.That(wasCalled).IsTrue(); + } + + [Test] + public void In_Params_Throws() + { + var mock = Mock.Of(); + mock.Add(Arg.Any(), Arg.Any()).Throws(); + + Assert.Throws(() => mock.Object.Add(1, 2)); + } + + [Test] + public async Task In_Params_Verify_WasCalled() + { + var mock = Mock.Of(); + mock.Add(Arg.Any(), Arg.Any()).Returns(0); + + mock.Object.Add(1, 2); + mock.Object.Add(3, 4); + + mock.Add(Arg.Any(), Arg.Any()).WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task In_Params_Verify_WasNeverCalled() + { + var mock = Mock.Of(); + + mock.Add(1, 2).WasNeverCalled(); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task In_Params_Mixed_With_Regular_Params() + { + var mock = Mock.Of(); + mock.Compute(Arg.Any(), Arg.Any()).Returns(99.5); + + var result = mock.Object.Compute(42, 2.0); + + await Assert.That(result).IsEqualTo(99.5); + } + + [Test] + public async Task In_Params_Specific_Mixed_Matching() + { + var mock = Mock.Of(); + mock.Compute(10, 2.5).Returns(25.0); + mock.Compute(20, 3.0).Returns(60.0); + + var r1 = mock.Object.Compute(10, 2.5); + var r2 = mock.Object.Compute(20, 3.0); + + await Assert.That(r1).IsEqualTo(25.0); + await Assert.That(r2).IsEqualTo(60.0); + } + + [Test] + public async Task In_Struct_Params() + { + var mock = Mock.Of(); + mock.Distance(Arg.Any(), Arg.Any()).Returns(5.0); + + var origin = new Point { X = 0, Y = 0 }; + var target = new Point { X = 3, Y = 4 }; + var result = mock.Object.Distance(origin, target); + + await Assert.That(result).IsEqualTo(5.0); + } + + [Test] + public async Task In_Struct_Mixed_With_Regular() + { + var mock = Mock.Of(); + mock.Contains(Arg.Any(), Arg.Any()).Returns(true); + + var center = new Point { X = 5, Y = 5 }; + var result = mock.Object.Contains(center, 10); + + await Assert.That(result).IsTrue(); + } + + [Test] + public async Task In_Params_Callback_With_Args() + { + int capturedA = 0, capturedB = 0; + var mock = Mock.Of(); + mock.Add(Arg.Any(), Arg.Any()) + .Callback((object?[] args) => + { + capturedA = (int)args[0]!; + capturedB = (int)args[1]!; + }) + .Returns(0); + + mock.Object.Add(7, 8); + + await Assert.That(capturedA).IsEqualTo(7); + await Assert.That(capturedB).IsEqualTo(8); + } + + [Test] + public async Task In_Params_Verify_Specific_Values() + { + var mock = Mock.Of(); + mock.Add(Arg.Any(), Arg.Any()).Returns(0); + + mock.Object.Add(1, 2); + mock.Object.Add(3, 4); + + mock.Add(1, 2).WasCalled(Times.Once); + mock.Add(3, 4).WasCalled(Times.Once); + mock.Add(5, 6).WasNeverCalled(); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task In_String_Param_Matching() + { + var messages = new List(); + var mock = Mock.Of(); + mock.Log(Arg.Any()).Callback((object?[] args) => messages.Add((string)args[0]!)); + + mock.Object.Log("first"); + mock.Object.Log("second"); + + await Assert.That(messages).HasCount().EqualTo(2); + await Assert.That(messages[0]).IsEqualTo("first"); + await Assert.That(messages[1]).IsEqualTo("second"); + } + + [Test] + public async Task In_Params_Arg_Is_Predicate() + { + var mock = Mock.Of(); + mock.Add(Arg.Is(x => x > 0), Arg.Is(x => x > 0)).Returns(100); + + var r1 = mock.Object.Add(5, 10); + await Assert.That(r1).IsEqualTo(100); + + // Negative values don't match the predicate — returns default (0) + var r2 = mock.Object.Add(-1, 5); + await Assert.That(r2).IsEqualTo(0); + } +} diff --git a/TUnit.Mocks.Tests/OutRefSpanTests.cs b/TUnit.Mocks.Tests/OutRefSpanTests.cs new file mode 100644 index 0000000000..0856b90d02 --- /dev/null +++ b/TUnit.Mocks.Tests/OutRefSpanTests.cs @@ -0,0 +1,98 @@ +using TUnit.Mocks; + +namespace TUnit.Mocks.Tests; + +// ─── Interfaces with out/ref span parameters ──────────────────────────────── + +public interface ISpanWriter +{ + void Do(out ReadOnlySpan buffer); +} + +public interface ISpanParser +{ + bool TryParse(string input, out ReadOnlySpan data); +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +/// +/// Tests for out parameters of ReadOnlySpan/Span types, which are ref structs +/// and require array-based conversion for storage. +/// +public class OutRefSpanTests +{ + [Test] + public async Task Out_ReadOnlySpan_Default_Works() + { + // Arrange + var mock = Mock.Of(); + mock.Do().SetsOutBuffer(new ReadOnlySpan()); + + // Act + mock.Object.Do(out var buffer); + var length = buffer.Length; + + // Assert — empty span + await Assert.That(length).IsEqualTo(0); + } + + [Test] + public async Task Out_ReadOnlySpan_With_Data() + { + // Arrange + var mock = Mock.Of(); + mock.Do().SetsOutBuffer(new ReadOnlySpan([1, 2, 3])); + + // Act + mock.Object.Do(out var buffer); + var length = buffer.Length; + var b0 = buffer[0]; + var b1 = buffer[1]; + var b2 = buffer[2]; + + // Assert + await Assert.That(length).IsEqualTo(3); + await Assert.That(b0).IsEqualTo((byte)1); + await Assert.That(b1).IsEqualTo((byte)2); + await Assert.That(b2).IsEqualTo((byte)3); + } + + [Test] + public async Task Out_ReadOnlySpan_Mixed_Params_With_Matching() + { + // Arrange — TryParse has a regular string param + out ReadOnlySpan + var mock = Mock.Of(); + mock.TryParse("hello") + .Returns(true) + .SetsOutData(new ReadOnlySpan([0xCA, 0xFE])); + + // Act + var success = mock.Object.TryParse("hello", out var data); + var length = data.Length; + var d0 = data[0]; + var d1 = data[1]; + + // Assert + await Assert.That(success).IsTrue(); + await Assert.That(length).IsEqualTo(2); + await Assert.That(d0).IsEqualTo((byte)0xCA); + await Assert.That(d1).IsEqualTo((byte)0xFE); + } + + [Test] + public async Task Out_ReadOnlySpan_No_Setup_Stays_Default() + { + // Arrange — no SetsOut call + var mock = Mock.Of(); + mock.TryParse("key").Returns(false); + + // Act + var success = mock.Object.TryParse("key", out var data); + var length = data.Length; + + // Assert — out param stays default (empty span) + await Assert.That(success).IsFalse(); + await Assert.That(length).IsEqualTo(0); + } +} diff --git a/TUnit.Mocks.Tests/SpanReturnTests.cs b/TUnit.Mocks.Tests/SpanReturnTests.cs new file mode 100644 index 0000000000..e876498b80 --- /dev/null +++ b/TUnit.Mocks.Tests/SpanReturnTests.cs @@ -0,0 +1,233 @@ +using TUnit.Mocks; +using TUnit.Mocks.Arguments; + +namespace TUnit.Mocks.Tests; + +// ─── Interfaces with span-returning methods ───────────────────────────────── + +public interface ISpanProducer +{ + ReadOnlySpan GetBytes(); + ReadOnlySpan GetBytes(string key); + ReadOnlySpan GetChars(int id); + Span GetMutableBuffer(); +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +/// +/// Tests for methods returning ReadOnlySpan/Span types, verifying .Returns() support, +/// callbacks, throws, verification, and default behavior. +/// +public class SpanReturnTests +{ + [Test] + public async Task Returns_ReadOnlySpan_Byte_With_Data() + { + var mock = Mock.Of(); + mock.GetBytes().Returns(new ReadOnlySpan([1, 2, 3])); + + var result = mock.Object.GetBytes(); + var len = result.Length; + var b0 = result[0]; + var b1 = result[1]; + var b2 = result[2]; + + await Assert.That(len).IsEqualTo(3); + await Assert.That(b0).IsEqualTo((byte)1); + await Assert.That(b1).IsEqualTo((byte)2); + await Assert.That(b2).IsEqualTo((byte)3); + } + + [Test] + public async Task Returns_ReadOnlySpan_Byte_Empty() + { + var mock = Mock.Of(); + mock.GetBytes().Returns(ReadOnlySpan.Empty); + + var result = mock.Object.GetBytes(); + var len = result.Length; + + await Assert.That(len).IsEqualTo(0); + } + + [Test] + public async Task Returns_ReadOnlySpan_No_Setup_Returns_Default() + { + // No .Returns() call — should return empty span (default) + var mock = Mock.Of(); + + var result = mock.Object.GetBytes(); + var len = result.Length; + + await Assert.That(len).IsEqualTo(0); + } + + [Test] + public async Task Returns_ReadOnlySpan_With_Arg_Matching() + { + var mock = Mock.Of(); + mock.GetBytes("hello").Returns(new ReadOnlySpan([0xCA, 0xFE])); + mock.GetBytes("world").Returns(new ReadOnlySpan([0xDE, 0xAD])); + + var r1 = mock.Object.GetBytes("hello"); + var r1Len = r1.Length; + var r1b0 = r1[0]; + var r1b1 = r1[1]; + + var r2 = mock.Object.GetBytes("world"); + var r2Len = r2.Length; + var r2b0 = r2[0]; + var r2b1 = r2[1]; + + await Assert.That(r1Len).IsEqualTo(2); + await Assert.That(r1b0).IsEqualTo((byte)0xCA); + await Assert.That(r1b1).IsEqualTo((byte)0xFE); + + await Assert.That(r2Len).IsEqualTo(2); + await Assert.That(r2b0).IsEqualTo((byte)0xDE); + await Assert.That(r2b1).IsEqualTo((byte)0xAD); + } + + [Test] + public async Task Returns_ReadOnlySpan_With_Arg_Any() + { + var mock = Mock.Of(); + mock.GetBytes(Arg.Any()).Returns(new ReadOnlySpan([0xFF])); + + var result = mock.Object.GetBytes("anything"); + var len = result.Length; + var b0 = result[0]; + + await Assert.That(len).IsEqualTo(1); + await Assert.That(b0).IsEqualTo((byte)0xFF); + } + + [Test] + public async Task Returns_ReadOnlySpan_Char() + { + var mock = Mock.Of(); + mock.GetChars(42).Returns(new ReadOnlySpan(['a', 'b', 'c'])); + + var result = mock.Object.GetChars(42); + var len = result.Length; + var c0 = result[0]; + var c1 = result[1]; + var c2 = result[2]; + + await Assert.That(len).IsEqualTo(3); + await Assert.That(c0).IsEqualTo('a'); + await Assert.That(c1).IsEqualTo('b'); + await Assert.That(c2).IsEqualTo('c'); + } + + [Test] + public async Task Returns_Mutable_Span() + { + var mock = Mock.Of(); + mock.GetMutableBuffer().Returns(new Span([10, 20, 30])); + + var result = mock.Object.GetMutableBuffer(); + var len = result.Length; + var b0 = result[0]; + var b1 = result[1]; + var b2 = result[2]; + + await Assert.That(len).IsEqualTo(3); + await Assert.That(b0).IsEqualTo((byte)10); + await Assert.That(b1).IsEqualTo((byte)20); + await Assert.That(b2).IsEqualTo((byte)30); + } + + [Test] + public void Span_Return_Throws_Exception() + { + var mock = Mock.Of(); + mock.GetBytes().Throws(); + + Assert.Throws(() => mock.Object.GetBytes()); + } + + [Test] + public async Task Span_Return_Callback_Is_Invoked() + { + var wasCalled = false; + var mock = Mock.Of(); + mock.GetBytes().Callback(() => wasCalled = true) + .Returns(new ReadOnlySpan([1])); + + mock.Object.GetBytes(); + + await Assert.That(wasCalled).IsTrue(); + } + + [Test] + public async Task Span_Return_Verify_WasCalled() + { + var mock = Mock.Of(); + mock.GetBytes().Returns(new ReadOnlySpan([1])); + + mock.Object.GetBytes(); + mock.Object.GetBytes(); + + mock.GetBytes().WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); // if we get here, verification passed + } + + [Test] + public async Task Span_Return_Verify_WasNeverCalled() + { + var mock = Mock.Of(); + + mock.GetBytes().WasNeverCalled(); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task Span_Return_Verify_With_Specific_Args() + { + var mock = Mock.Of(); + mock.GetBytes(Arg.Any()).Returns(new ReadOnlySpan([1])); + + mock.Object.GetBytes("hello"); + mock.Object.GetBytes("world"); + + mock.GetBytes("hello").WasCalled(Times.Once); + mock.GetBytes("world").WasCalled(Times.Once); + mock.GetBytes(Arg.Any()).WasCalled(Times.Exactly(2)); + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task Span_Return_Large_Data() + { + var largeData = new byte[1024]; + for (int i = 0; i < largeData.Length; i++) + largeData[i] = (byte)(i % 256); + + var mock = Mock.Of(); + mock.GetBytes().Returns(new ReadOnlySpan(largeData)); + + var result = mock.Object.GetBytes(); + var len = result.Length; + var first = result[0]; + var last = result[1023]; + + await Assert.That(len).IsEqualTo(1024); + await Assert.That(first).IsEqualTo((byte)0); + await Assert.That(last).IsEqualTo((byte)255); + } + + [Test] + public async Task Span_Return_Unmatched_Args_Returns_Default() + { + var mock = Mock.Of(); + mock.GetBytes("specific").Returns(new ReadOnlySpan([1, 2, 3])); + + // Call with different arg — should return default (empty span) + var result = mock.Object.GetBytes("other"); + var len = result.Length; + + await Assert.That(len).IsEqualTo(0); + } +} diff --git a/TUnit.Mocks/Setup/OutRefContext.cs b/TUnit.Mocks/Setup/OutRefContext.cs index 604c197c51..5e429686f0 100644 --- a/TUnit.Mocks/Setup/OutRefContext.cs +++ b/TUnit.Mocks/Setup/OutRefContext.cs @@ -10,6 +10,12 @@ namespace TUnit.Mocks.Setup; [EditorBrowsable(EditorBrowsableState.Never)] public static class OutRefContext { + /// + /// Reserved index used to store the return value for span-returning methods. + /// Parameter indices are always >= 0, so -1 is safe from collision. + /// + public const int SpanReturnValueIndex = -1; + [ThreadStatic] private static Dictionary? _assignments;