Skip to content

Commit

Permalink
Merge pull request #722 from stakx/fluentmockcontext
Browse files Browse the repository at this point in the history
Enable `FluentMockContext` to record >1 matcher per invocation
  • Loading branch information
stakx authored Nov 11, 2018
2 parents 936a47e + 00b630a commit ce4240a
Show file tree
Hide file tree
Showing 10 changed files with 474 additions and 205 deletions.
215 changes: 215 additions & 0 deletions src/Moq/AmbientObserver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// Copyright (c) 2007, Clarius Consulting, Manas Technology Solutions, InSTEDD.
// All rights reserved. Licensed under the BSD 3-Clause License; see License.txt.

using System;
using System.Collections.Generic;
using System.Diagnostics;

namespace Moq
{
/// <summary>
/// A per-thread observer that records invocations to mocks and matchers for later inspection.
/// </summary>
/// <remarks>
/// <para>
/// This component requires the active cooperation of the respective subsystems.
/// That is, invoked matchers and mocks call into <see cref="OnMatch(Match)"/>
/// or <see cref="OnInvocation(Mock, Invocation)"/> if an ambient observer is
/// active on the current thread.
/// </para>
/// <para>
/// This gets used in Moq's API to work around certain limitations of what kind
/// of constructs the Roslyn compilers allow in in-source LINQ expression trees
/// (e.g., assignment and event (un-)subscription are forbidden). Instead of
/// letting user code provide a LINQ expression tree, Moq accepts a normal lambda.
/// While a lambda cannot be directly inspected like a LINQ expression tree, we
/// can instantiate an <see cref="AmbientObserver"/>, execute the lambda, and then
/// check with the observer what invocations happened; and from there, we can
/// "reverse-engineer" a LINQ expression tree (with some loss of accuracy).
/// </para>
/// </remarks>
internal sealed class AmbientObserver : IDisposable
{
[ThreadStatic]
private static AmbientObserver current;

public static AmbientObserver Activate()
{
Debug.Assert(current == null);

return current = new AmbientObserver();
}

public static bool IsActive(out AmbientObserver observer)
{
var current = AmbientObserver.current;

observer = current;
return current != null;
}

private List<Observation> observations;

private AmbientObserver()
{
}

public void Dispose()
{
if (this.observations != null)
{
for (var i = this.observations.Count - 1; i >= 0; --i)
{
this.observations[i].Dispose();
}
}

current = null;
}

/// <summary>
/// Adds the specified mock invocation as an observation.
/// </summary>
public void OnInvocation(Mock mock, Invocation invocation)
{
if (this.observations == null)
{
this.observations = new List<Observation>();
}

observations.Add(new InvocationObservation(mock, invocation));
}

/// <summary>
/// Adds the specified <see cref="Match"/> as an observation.
/// </summary>
public void OnMatch(Match match)
{
if (this.observations == null)
{
this.observations = new List<Observation>();
}

this.observations.Add(new MatchObservation(match));
}

/// <summary>
/// Checks whether the last observed thing was a mock invocation.
/// If <see langword="true"/>, details about that mock invocation are provided via the <see langword="out"/> parameters.
/// </summary>
/// <param name="mock">The <see cref="Mock"/> on which an invocation was observed.</param>
/// <param name="invocation">The observed <see cref="Invocation"/>.</param>
/// <param name="matches">The <see cref="Match"/>es that were observed just before the invocation.</param>
public bool LastIsInvocation(out Mock mock, out Invocation invocation, out Matches matches)
{
if (this.observations != null)
{
var lastIndex = this.observations.Count - 1;

if (this.observations[lastIndex] is InvocationObservation invocationRecord)
{
// Determine the first index of all recorded matches that immediately precede
// the last invocation; up to the previous recorded invocation or the beginning
// of the recording (whichever comes first):
int offset = lastIndex;
while (offset > 0 && this.observations[offset - 1] is MatchObservation)
{
--offset;
}

mock = invocationRecord.Mock;
invocation = invocationRecord.Invocation;
matches = new Matches(this, offset, lastIndex - offset);
return true;
}
}

mock = default;
invocation = default;
matches = default;
return false;
}

/// <summary>
/// Checks whether the last thing observed was a <see cref="Match"/> matcher.
/// If <see langword="true"/>, details about that matcher are provided via the <see langword="out"/> parameter.
/// </summary>
/// <param name="match">The observed <see cref="Match"/> matcher.</param>
public bool LastIsMatch(out Match match)
{
if (this.observations != null && this.observations[this.observations.Count - 1] is MatchObservation matchRecord)
{
match = matchRecord.Match;
return true;
}

match = default;
return false;
}

/// <summary>
/// Allocation-free pseudo-collection (think `ReadOnlySpan&lt;Match&gt;`)
/// used to access all <see cref="Match"/>es associated with a recorded invocation.
/// </summary>
public readonly struct Matches
{
private readonly AmbientObserver observer;
private readonly int offset;
private readonly int count;

public Matches(AmbientObserver observer, int offset, int count)
{
this.observer = observer;
this.offset = offset;
this.count = count;
}

public int Count => this.count;

public Match this[int index] => ((MatchObservation)this.observer.observations[this.offset + index]).Match;
}

private abstract class Observation : IDisposable
{
protected Observation()
{
}

public virtual void Dispose()
{
}
}

private sealed class InvocationObservation : Observation
{
public readonly Mock Mock;
public readonly Invocation Invocation;

private DefaultValueProvider defaultValueProvider;

public InvocationObservation(Mock mock, Invocation invocation)
{
this.Mock = mock;
this.Invocation = invocation;

this.defaultValueProvider = mock.DefaultValueProvider;
mock.DefaultValueProvider = DefaultValueProvider.Mock;
}

public override void Dispose()
{
this.Mock.DefaultValueProvider = this.defaultValueProvider;
}
}

private sealed class MatchObservation : Observation
{
public readonly Match Match;

public MatchObservation(Match match)
{
this.Match = match;
}
}
}
}
16 changes: 10 additions & 6 deletions src/Moq/ExpressionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ internal static TDelegate CompileUsingExpressionCompiler<TDelegate>(this Express
return ExpressionCompiler.Instance.Compile(expression);
}

public static bool IsMatch(this Expression expression, out Match match)
{
using (var observer = AmbientObserver.Activate())
{
Expression.Lambda<Action>(expression).CompileUsingExpressionCompiler().Invoke();
return observer.LastIsMatch(out match);
}
}

/// <summary>
/// Converts the body of the lambda expression into the <see cref="PropertyInfo"/> referenced by it.
/// </summary>
Expand Down Expand Up @@ -115,12 +124,7 @@ private static bool PartialMatcherAwareEval_ShouldEvaluate(Expression expression

case ExpressionType.Call:
case ExpressionType.MemberAccess:
// Evaluate everything but matchers:
using (var context = new FluentMockContext())
{
Expression.Lambda<Action>(expression).CompileUsingExpressionCompiler().Invoke();
return context.LastMatch == null;
}
return !expression.IsMatch(out _);

default:
return true;
Expand Down
7 changes: 3 additions & 4 deletions src/Moq/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,17 +145,16 @@ public static EventWithTarget GetEventWithTarget<TMock>(this Action<TMock> event
MethodBase addRemove;
Mock target;

using (var context = new FluentMockContext())
using (var observer = AmbientObserver.Activate())
{
eventExpression(mock);

if (context.LastInvocation == null)
if (!observer.LastIsInvocation(out target, out var invocation, out _))
{
throw new ArgumentException(Resources.ExpressionIsNotEventAttachOrDetachOrIsNotVirtual);
}

addRemove = context.LastInvocation.Invocation.Method;
target = context.LastInvocation.Mock;
addRemove = invocation.Method;
}

var ev = addRemove.DeclaringType.GetEvent(
Expand Down
91 changes: 0 additions & 91 deletions src/Moq/FluentMockContext.cs

This file was deleted.

13 changes: 4 additions & 9 deletions src/Moq/Interception/InterceptionAspects.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ internal sealed class FindAndExecuteMatchingSetup : InterceptionAspect

public override InterceptionAction Handle(Invocation invocation, Mock mock)
{
if (FluentMockContext.IsActive)
if (AmbientObserver.IsActive(out _))
{
return InterceptionAction.Continue;
}
Expand Down Expand Up @@ -153,10 +153,9 @@ internal sealed class HandleTracking : InterceptionAspect

public override InterceptionAction Handle(Invocation invocation, Mock mock)
{
// Track current invocation if we're in "record" mode in a fluent invocation context.
if (FluentMockContext.IsActive)
if (AmbientObserver.IsActive(out var observer))
{
FluentMockContext.Current.Add(mock, invocation);
observer.OnInvocation(mock, invocation);
}
return InterceptionAction.Continue;
}
Expand All @@ -168,12 +167,8 @@ internal sealed class RecordInvocation : InterceptionAspect

public override InterceptionAction Handle(Invocation invocation, Mock mock)
{
if (FluentMockContext.IsActive)
if (AmbientObserver.IsActive(out _))
{
// In a fluent invocation context, which is a recorder-like
// mode we use to evaluate delegates by actually running them,
// we don't want to count the invocation, or actually run
// previous setups.
return InterceptionAction.Continue;
}

Expand Down
Loading

0 comments on commit ce4240a

Please sign in to comment.