Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add WillReceive.InOrder() for evaluating call sequence expectations at execution time #863

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/NSubstitute/Core/Arguments/ArgumentMatchInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
public class ArgumentMatchInfo(int index, object? argument, IArgumentSpecification specification)
{
private readonly object? _argument = argument;
private readonly IArgumentSpecification _specification = specification;
public int Index { get; } = index;

public bool IsMatch => _specification.IsSatisfiedBy(_argument);
public bool IsMatch => Specification.IsSatisfiedBy(_argument);

public IArgumentSpecification Specification { get; } = specification;

public string DescribeNonMatch()
{
var describeNonMatch = _specification.DescribeNonMatch(_argument);
var describeNonMatch = Specification.DescribeNonMatch(_argument);
if (string.IsNullOrEmpty(describeNonMatch)) return string.Empty;
var argIndexPrefix = "arg[" + Index + "]: ";
return string.Format("{0}{1}", argIndexPrefix, describeNonMatch.Replace("\n", "\n".PadRight(argIndexPrefix.Length + 1)));
Expand All @@ -20,7 +21,7 @@ public bool Equals(ArgumentMatchInfo? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return other.Index == Index && Equals(other._argument, _argument) && Equals(other._specification, _specification);
return other.Index == Index && Equals(other._argument, _argument) && Equals(other.Specification, Specification);
}

public override bool Equals(object? obj)
Expand All @@ -37,7 +38,7 @@ public override int GetHashCode()
{
int result = Index;
result = (result * 397) ^ (_argument != null ? _argument.GetHashCode() : 0);
result = (result * 397) ^ _specification.GetHashCode();
result = (result * 397) ^ Specification.GetHashCode();
return result;
}
}
Expand Down
13 changes: 13 additions & 0 deletions src/NSubstitute/WillReceive.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using NSubstitute.Core;

namespace NSubstitute;

public static class WillReceive
{
public static WillReceiveExpectation InOrder(Action calls)
{
return new WillReceiveExpectation(
callSpecificationFactory: SubstitutionContext.Current.CallSpecificationFactory,
buildExpectationsAction: calls);
}
}
264 changes: 264 additions & 0 deletions src/NSubstitute/WillReceiveExpectation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
using System.Collections;
using System.Text;
using NSubstitute.Core;
using NSubstitute.Core.Arguments;
using NSubstitute.Core.SequenceChecking;
using NSubstitute.Exceptions;

namespace NSubstitute;

public sealed class WillReceiveExpectation : IQuery
{
private const string _indent = " ";
private readonly List<CallSpecAndTarget> _expectedCallSpecAndTargets = [];
private readonly List<UnexpectedCallData?> _receivedCalls = [];
private readonly ICallSpecificationFactory _callSpecificationFactory;
private readonly InstanceTracker _instanceTracker = new();
private readonly Action _buildExpectationsAction;
private bool _buildingExpectations;

public WillReceiveExpectation(ICallSpecificationFactory callSpecificationFactory, Action buildExpectationsAction)
{
_callSpecificationFactory = callSpecificationFactory;
_buildExpectationsAction = buildExpectationsAction;
}

public void WhileExecuting(Action action)
{
_buildingExpectations = true;

SubstitutionContext.Current.ThreadContext.RunInQueryContext(_buildExpectationsAction, this);

_buildingExpectations = false;

#if NET6_0_OR_GREATER
_receivedCalls.EnsureCapacity(_expectedCallSpecAndTargets.Count);
#endif

SubstitutionContext.Current.ThreadContext.RunInQueryContext(action, this);

AssertReceivedCalls();
}

void IQuery.RegisterCall(ICall call)
{
if (call.GetMethodInfo().GetPropertyFromGetterCallOrNull() != null)
return;

if (_buildingExpectations)
AddCallExpectation(call);
else
AddReceivedAssertionCall(call);
}
private void AddCallExpectation(ICall call)
{
var callSpecification = _callSpecificationFactory.CreateFrom(call, MatchArgs.AsSpecifiedInCall);

_expectedCallSpecAndTargets.Add(new CallSpecAndTarget(callSpecification, call.Target()));
}
private void AddReceivedAssertionCall(ICall call)
{
var instanceNumber = _instanceTracker.InstanceNumber(call.Target());
var expectedCallIndex = _receivedCalls.Count;

if (expectedCallIndex >= _expectedCallSpecAndTargets.Count)
{
_receivedCalls.Add(new UnexpectedCallData(specification: null, call, instanceNumber));
return;
}

var specAndTarget = _expectedCallSpecAndTargets[expectedCallIndex];

var callData = !specAndTarget.CallSpecification.IsSatisfiedBy(call)
? new UnexpectedCallData(specAndTarget.CallSpecification, call, instanceNumber)
: null;

_receivedCalls.Add(callData);
}

private void AssertReceivedCalls()
{
if (_receivedCalls.Any(x => x != null) || _receivedCalls.Count < _expectedCallSpecAndTargets.Count)
throw new CallSequenceNotFoundException(CreateExceptionMessage());
}

private string CreateExceptionMessage()
{
var builder = new StringBuilder();
var includeInstanceNumber = HasMultipleCallsOnSameType();
var multipleInstances = _instanceTracker.NumberOfInstances() > 1;

builder.AppendLine();

var i = 0;

for (; i < _receivedCalls.Count; i++)
{
var callData = _receivedCalls[i];

builder.Append("Call ");
builder.Append(i + 1);
builder.Append(": ");

if (callData == null)
{
builder.AppendLine("Accepted!");
}
else
{
var expectedCall = i < _expectedCallSpecAndTargets.Count
? _expectedCallSpecAndTargets[i]
: null;

AppendUnexpectedCallToExceptionMessage(builder, expectedCall, callData, multipleInstances, includeInstanceNumber);
}
}

AppendNotReceivedCallsToExceptionMessage(builder, nextExpectedCallIndex: i);

return builder.ToString();
}

private bool HasMultipleCallsOnSameType()
{
var lookup = new Dictionary<Type, int>();

foreach (var call in _receivedCalls)
{
if (call == null)
continue;

if (lookup.TryGetValue(call.DeclaringType, out var instanceNumber))
{
if (instanceNumber != call.InstanceNumber)
return true;
}
else
{
lookup.Add(call.DeclaringType, call.InstanceNumber);
}
}

return false;
}

private static void AppendUnexpectedCallToExceptionMessage(StringBuilder builder,
CallSpecAndTarget? expectedCall,
UnexpectedCallData unexpectedCallData,
bool multipleInstances,
bool includeInstanceNumber)
{
// Not matched or unexpected
if (expectedCall != null)
{
builder.AppendLine("Not matched!");
builder.Append($"{_indent}Expected: ");
builder.AppendLine(expectedCall.CallSpecification.ToString());
}
else
{
builder.AppendLine("Unexpected!");
}

builder.Append($"{_indent}But was: ");

// Prepend instance number and type if multiple instances
if (multipleInstances)
{
if (includeInstanceNumber)
{
builder.Append(unexpectedCallData.InstanceNumber);
builder.Append('@');
}

builder.Append(unexpectedCallData.DeclaringType.GetNonMangledTypeName());
builder.Append('.');
}

builder.AppendLine(unexpectedCallData.CallFormat);

// Append non-matching arguments
foreach (var argumentFormat in unexpectedCallData.NonMatchingArgumentFormats)
{
builder.Append(_indent);
builder.Append(_indent);
builder.AppendLine(argumentFormat);
}
}

private void AppendNotReceivedCallsToExceptionMessage(StringBuilder builder, int nextExpectedCallIndex)
{
for (; nextExpectedCallIndex < _expectedCallSpecAndTargets.Count; nextExpectedCallIndex++)
{
builder.AppendLine($"Call {nextExpectedCallIndex + 1}: Not received!");
builder.Append($"{_indent}Expected: ");
builder.AppendLine(_expectedCallSpecAndTargets[nextExpectedCallIndex].CallSpecification.ToString());
}
}

private sealed class UnexpectedCallData
{
public Type DeclaringType { get; }
public string CallFormat { get; }
public IReadOnlyList<string> NonMatchingArgumentFormats { get; }
public int InstanceNumber { get; }

public UnexpectedCallData(ICallSpecification? specification, ICall call, int instanceNumber)
{
DeclaringType = call.GetMethodInfo().DeclaringType!;

CallFormat = FormatCall(call);

NonMatchingArgumentFormats = specification != null
? FormatNonMatchingArguments(specification, call)
: [];

InstanceNumber = instanceNumber;
}

private static string FormatCall(ICall call)
{
// Based on SequenceFormatter, maybe we can refactor this?

var methodInfo = call.GetMethodInfo();

var args = methodInfo.GetParameters()
.Zip(call.GetOriginalArguments(), (info, value) => (info, value))
.SelectMany(x =>
{
var (info, value) = x;

return info.IsParams()
? ((IEnumerable)value!).Cast<object>()
: ToEnumerable(value);

static IEnumerable<T> ToEnumerable<T>(T value)
{
yield return value;
}
})
.Select(x => ArgumentFormatter.Default.Format(x, false))
.ToArray();

return CallFormatter.Default.Format(methodInfo, args);
}
private static string[] FormatNonMatchingArguments(ICallSpecification specification, ICall call)
{
var nonMatchingArguments = specification.NonMatchingArguments(call).ToArray();
var result = new string[nonMatchingArguments.Length];

for (var i = 0; i < nonMatchingArguments.Length; i++)
{
var nonMatchingArgument = nonMatchingArguments[i];
var description = nonMatchingArgument.DescribeNonMatch();

if (string.IsNullOrWhiteSpace(description))
description = $"arg[{nonMatchingArgument.Index}] not matched: {nonMatchingArgument.Specification}";

result[i] = description;
}

return result;
}
}
}
Loading