Merge pull request #26 from srawlins/typed-api

Add strong mode-compliant 'typed' API
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c1f150..9534f53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,14 @@
+## 1.0.0
+
+* Add a new `typed` API that is compatible with Dart Dev Compiler; documented in
+  README.md.
+
+## 0.11.1
+
+* Move the reflection-based `spy` code into a private source file. Now
+  `package:mockito/mockito.dart` includes this reflection-based API, and a new
+  `package:mockito/mockito_no_mirrors.dart` doesn't require mirrors.
+
 ## 0.11.0
 
 * Equality matcher used by default to simplify matching collections as arguments. Should be non-breaking change in most cases, otherwise consider using `argThat(identical(arg))`.
diff --git a/README.md b/README.md
index 36e9316..d5e362c 100644
--- a/README.md
+++ b/README.md
@@ -126,11 +126,11 @@
 ```dart
 //simple capture
 cat.eatFood("Fish");
-expect(verify(cat.eatFood(capture)).captured.single, "Fish");
+expect(verify(cat.eatFood(captureAny)).captured.single, "Fish");
 //capture multiple calls
 cat.eatFood("Milk");
 cat.eatFood("Fish");
-expect(verify(cat.eatFood(capture)).captured, ["Milk", "Fish"]);
+expect(verify(cat.eatFood(captureAny)).captured, ["Milk", "Fish"]);
 //conditional capture
 cat.eatFood("Milk");
 cat.eatFood("Fish");
@@ -147,6 +147,88 @@
 //using real object
 expect(cat.lives, 9);   
 ```
+
+## Strong mode compliance
+
+Unfortunately, the use of the arg matchers in mock method calls (like `cat.eatFood(any)`)
+violates the [Strong mode] type system. Specifically, if the method signature of a mocked
+method has a parameter with a parameterized type (like `List<int>`), then passing `any` or
+`argThat` will result in a Strong mode warning:
+
+> [warning] Unsound implicit cast from dynamic to List&lt;int>
+
+In order to write Strong mode-compliant tests with Mockito, you might need to use `typed`,
+annotating it with a type parameter comment. Let's use a slightly different `Cat` class to
+show some examples:
+
+```dart
+class Cat {
+  bool eatFood(List<String> foods, [List<String> mixins]) => true;
+  int walk(List<String> places, {Map<String, String> gaits}) => 0;
+}
+
+class MockCat extends Mock implements Cat {}
+
+var cat = new MockCat();
+```
+
+OK, what if we try to stub using `any`:
+
+```dart
+when(cat.eatFood(any)).thenReturn(true);
+```
+
+Let's analyze this code:
+
+```
+$ dartanalyzer --strong test/cat_test.dart
+Analyzing [lib/cat_test.dart]...
+[warning] Unsound implicit cast from dynamic to List<String> (test/cat_test.dart, line 12, col 20)
+1 warning found.
+```
+
+This code is not Strong mode-compliant. Let's change it to use `typed`:
+
+```dart
+when(cat.eatFood(typed(any)))
+```
+
+```
+$ dartanalyzer --strong test/cat_test.dart
+Analyzing [lib/cat_test.dart]...
+No issues found
+```
+
+Great! A little ugly, but it works. Here are some more examples:
+
+```dart
+when(cat.eatFood(typed(any), typed(any))).thenReturn(true);
+when(cat.eatFood(typed(argThat(contains("fish"))))).thenReturn(true);
+```
+
+Named args require one more component: `typed` needs to know what named argument it is
+being passed into:
+
+```dart
+when(cat.walk(typed(any), gaits: typed(any, named: 'gaits')))
+    .thenReturn(true);
+```
+
+Note the `named` argument. Mockito should fail gracefully if you forget to name a `typed`
+call passed in as a named argument, or name the argument incorrectly.
+
+One more note about the `typed` API: you cannot mix `typed` arguments with `null`
+arguments:
+
+```dart
+when(cat.eatFood(null, typed(any))).thenReturn(true); // Throws!
+when(cat.eatFood(
+    argThat(equals(null)),
+    typed(any))).thenReturn(true); // Works.
+```
+
+[Strong mode]: https://github.com/dart-lang/dev_compiler/blob/master/STRONG_MODE.md
+
 ## How it works
 The basics of the `Mock` class are nothing special: It uses `noSuchMethod` to catch
 all method invocations, and returns the value that you have configured beforehand with
diff --git a/lib/src/mock.dart b/lib/src/mock.dart
index 71b8d9c..c1bf386 100644
--- a/lib/src/mock.dart
+++ b/lib/src/mock.dart
@@ -10,6 +10,8 @@
 final List<_VerifyCall> _verifyCalls = <_VerifyCall>[];
 final _TimeStampProvider _timer = new _TimeStampProvider();
 final List _capturedArgs = [];
+final List<_ArgMatcher> _typedArgs = <_ArgMatcher>[];
+final Map<String, _ArgMatcher> _typedNamedArgs = <String, _ArgMatcher>{};
 
 // Hidden from the public API, used by spy.dart.
 void setDefaultResponse(Mock mock, dynamic defaultResponse) {
@@ -31,6 +33,9 @@
   }
 
   dynamic noSuchMethod(Invocation invocation) {
+    if (_typedArgs.isNotEmpty || _typedNamedArgs.isNotEmpty) {
+      invocation = new _InvocationForTypedArguments(invocation);
+    }
     if (_whenInProgress) {
       _whenCall = new _WhenCall(this, invocation);
       return null;
@@ -55,6 +60,137 @@
   String toString() => _givenName != null ? _givenName : runtimeType.toString();
 }
 
+/// An Invocation implementation that takes arguments from [_typedArgs] and
+/// [_typedNamedArgs].
+class _InvocationForTypedArguments extends Invocation {
+  final Symbol memberName;
+  final Map<Symbol, dynamic> namedArguments;
+  final List<dynamic> positionalArguments;
+  final bool isGetter;
+  final bool isMethod;
+  final bool isSetter;
+
+  factory _InvocationForTypedArguments(Invocation invocation) {
+    if (_typedArgs.isEmpty && _typedNamedArgs.isEmpty) {
+      throw new StateError(
+          "_InvocationForTypedArguments called when no typed calls have been saved.");
+    }
+
+    // Handle named arguments first, so that we can provide useful errors for
+    // the various bad states. If all is well with the named arguments, then we
+    // can process the positional arguments, and resort to more general errors
+    // if the state is still bad.
+    var namedArguments = _reconstituteNamedArgs(invocation);
+    var positionalArguments = _reconstitutePositionalArgs(invocation);
+
+    _typedArgs.clear();
+    _typedNamedArgs.clear();
+
+    return new _InvocationForTypedArguments._(
+        invocation.memberName,
+        positionalArguments,
+        namedArguments,
+        invocation.isGetter,
+        invocation.isMethod,
+        invocation.isSetter);
+  }
+
+  // Reconstitutes the named arguments in an invocation from [_typedNamedArgs].
+  //
+  // The namedArguments in [invocation] which are null should be represented
+  // by a stored value in [_typedNamedArgs]. The null presumably came from
+  // [typed].
+  static Map<Symbol,dynamic> _reconstituteNamedArgs(Invocation invocation) {
+    var namedArguments = <Symbol, dynamic>{};
+    var _typedNamedArgSymbols = _typedNamedArgs.keys.map((name) => new Symbol(name));
+
+    // Iterate through [invocation]'s named args, validate them, and add them
+    // to the return map.
+    invocation.namedArguments.forEach((name, arg) {
+      if (arg == null) {
+        if (!_typedNamedArgSymbols.contains(name)) {
+          // Incorrect usage of [typed], something like:
+          // `when(obj.fn(a: typed(any)))`.
+          throw new ArgumentError(
+              'A typed argument was passed in as a named argument named "$name", '
+              'but did not pass a value for `named`. Each typed argument that is '
+              'passed as a named argument needs to specify the `named` argument. '
+              'For example: `when(obj.fn(x: typed(any, named: "x")))`.');
+        }
+      } else {
+        // Add each real named argument that was _not_ passed with [typed].
+        namedArguments[name] = arg;
+      }
+    });
+
+    // Iterate through the stored named args (stored with [typed]), validate
+    // them, and add them to the return map.
+    _typedNamedArgs.forEach((name, arg) {
+      Symbol nameSymbol = new Symbol(name);
+      if (!invocation.namedArguments.containsKey(nameSymbol)) {
+        throw new ArgumentError(
+            'A typed argument was declared as named $name, but was not passed '
+            'as an argument named $name.\n\n'
+            'BAD:  when(obj.fn(typed(any, named: "a")))\n'
+            'GOOD: when(obj.fn(a: typed(any, named: "a")))');
+      }
+      if (invocation.namedArguments[nameSymbol] != null) {
+        throw new ArgumentError(
+            'A typed argument was declared as named $name, but a different '
+            'value (${invocation.namedArguments[nameSymbol]}) was passed as '
+            '$name.\n\n'
+            'BAD:  when(obj.fn(b: typed(any, name: "a")))\n'
+            'GOOD: when(obj.fn(b: typed(any, name: "b")))');
+      }
+      namedArguments[nameSymbol] = arg;
+    });
+
+    return namedArguments;
+  }
+
+  static List<dynamic> _reconstitutePositionalArgs(Invocation invocation) {
+    var positionalArguments = <dynamic>[];
+    var nullPositionalArguments =
+        invocation.positionalArguments.where((arg) => arg == null);
+    if (_typedArgs.length != nullPositionalArguments.length) {
+      throw new ArgumentError(
+          'null arguments are not allowed alongside typed(); use '
+          '"typed(eq(null))"');
+    }
+    int typedIndex = 0;
+    int positionalIndex = 0;
+    while (typedIndex < _typedArgs.length &&
+        positionalIndex < invocation.positionalArguments.length) {
+      var arg = _typedArgs[typedIndex];
+      if (invocation.positionalArguments[positionalIndex] == null) {
+        // [typed] was used; add the [_ArgMatcher] given to [typed].
+        positionalArguments.add(arg);
+        typedIndex++;
+        positionalIndex++;
+      } else {
+        // [typed] was not used; add the [_ArgMatcher] from [invocation].
+        positionalArguments.add(invocation.positionalArguments[positionalIndex]);
+        positionalIndex++;
+      }
+    }
+    while (positionalIndex < invocation.positionalArguments.length) {
+      // Some trailing non-[typed] arguments.
+      positionalArguments.add(invocation.positionalArguments[positionalIndex]);
+      positionalIndex++;
+    }
+
+    return positionalArguments;
+  }
+
+  _InvocationForTypedArguments._(
+      this.memberName,
+      this.positionalArguments,
+      this.namedArguments,
+      this.isGetter,
+      this.isMethod,
+      this.isSetter);
+}
+
 named(var mock, {String name, int hashCode}) => mock
   .._givenName = name
   .._givenHashCode = hashCode;
@@ -300,6 +436,15 @@
 captureThat(Matcher matcher) => new _ArgMatcher(matcher, true);
 argThat(Matcher matcher) => new _ArgMatcher(matcher, false);
 
+/*=T*/ typed/*<T>*/(_ArgMatcher matcher, {String named}) {
+  if (named == null) {
+    _typedArgs.add(matcher);
+  } else {
+    _typedNamedArgs[named] = matcher;
+  }
+  return null;
+}
+
 class VerificationResult {
   List captured = [];
   int callCount;
@@ -413,3 +558,14 @@
     print(inv.toString());
   });
 }
+
+/// Should only be used during Mockito testing.
+void resetMockitoState() {
+  _whenInProgress = false;
+  _verificationInProgress = false;
+  _whenCall = null;
+  _verifyCalls.clear();
+  _capturedArgs.clear();
+  _typedArgs.clear();
+  _typedNamedArgs.clear();
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 4cdcff5..be8576c 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: mockito
-version: 0.11.1
+version: 1.0.0
 author: Dmitriy Fibulwinter <fibulwinter@gmail.com>
 description: A mock framework inspired by Mockito.
 homepage: https://github.com/fibulwinter/dart-mockito
diff --git a/test/mockito_test.dart b/test/mockito_test.dart
index 03a2ed9..fe20df2 100644
--- a/test/mockito_test.dart
+++ b/test/mockito_test.dart
@@ -9,6 +9,14 @@
   String methodWithNamedArgs(int x, {int y}) => "Real";
   String methodWithTwoNamedArgs(int x, {int y, int z}) => "Real";
   String methodWithObjArgs(RealClass x) => "Real";
+  // "SpecialArgs" here means type-parameterized args. But that makes for a long
+  // method name.
+  String typeParameterizedFn(
+      List<int> w, List<int> x, [List<int> y, List<int> z]) => "Real";
+  // "SpecialNamedArgs" here means type-parameterized, named args. But that
+  // makes for a long method name.
+  String typeParameterizedNamedFn(List<int> w, List<int> x, {List<int> y, List<int> z}) =>
+      "Real";
   String get getter => "Real";
   void set setter(String arg) {
     throw new StateError("I must be mocked");
@@ -55,6 +63,12 @@
     mock = new MockedClass();
   });
 
+  tearDown(() {
+    // In some of the tests that expect an Error to be thrown, Mockito's
+    // global state can become invalid. Reset it.
+    resetMockitoState();
+  });
+
   group("spy", () {
     setUp(() {
       mock = spy(new MockedClass(), new RealClass());
@@ -204,6 +218,81 @@
       when(mock.methodWithNormalArgs(argThat(equals(42)))).thenReturn("42");
       expect(mock.methodWithNormalArgs(43), equals("43"));
     });
+    test("should mock method with typed arg matchers", () {
+      when(mock.typeParameterizedFn(typed(any), typed(any)))
+          .thenReturn("A lot!");
+      expect(mock.typeParameterizedFn([42], [43]), equals("A lot!"));
+      expect(mock.typeParameterizedFn([43], [44]), equals("A lot!"));
+    });
+    test("should mock method with an optional typed arg matcher", () {
+      when(mock.typeParameterizedFn(typed(any), typed(any), typed(any)))
+          .thenReturn("A lot!");
+      expect(mock.typeParameterizedFn([42], [43], [44]), equals("A lot!"));
+    });
+    test("should mock method with an optional typed arg matcher and an optional real arg", () {
+      when(mock.typeParameterizedFn(typed(any), typed(any), [44], typed(any)))
+          .thenReturn("A lot!");
+      expect(mock.typeParameterizedFn([42], [43], [44], [45]), equals("A lot!"));
+    });
+    test("should mock method with only some typed arg matchers", () {
+      when(mock.typeParameterizedFn(typed(any), [43], typed(any)))
+          .thenReturn("A lot!");
+      expect(mock.typeParameterizedFn([42], [43], [44]), equals("A lot!"));
+      when(mock.typeParameterizedFn(typed(any), [43]))
+          .thenReturn("A bunch!");
+      expect(mock.typeParameterizedFn([42], [43]), equals("A bunch!"));
+    });
+    test("should throw when [typed] used alongside [null].", () {
+      expect(() => when(mock.typeParameterizedFn(typed(any), null, typed(any))),
+          throwsArgumentError);
+      expect(() => when(mock.typeParameterizedFn(typed(any), typed(any), null)),
+          throwsArgumentError);
+    });
+    test("should mock method when [typed] used alongside matched [null].", () {
+      when(mock.typeParameterizedFn(
+          typed(any), argThat(equals(null)), typed(any)))
+          .thenReturn("A lot!");
+      expect(mock.typeParameterizedFn([42], null, [44]), equals("A lot!"));
+    });
+    test("should mock method with named, typed arg matcher", () {
+      when(mock.typeParameterizedNamedFn(
+          typed(any), [43], y: typed(any, named: "y")))
+          .thenReturn("A lot!");
+      expect(mock.typeParameterizedNamedFn([42], [43], y: [44]), equals("A lot!"));
+    });
+    test("should mock method with named, typed arg matcher and an arg matcher", () {
+      when(
+          mock.typeParameterizedNamedFn(
+              typed(any), [43],
+              y: typed(any, named: "y"), z: argThat(contains(45))))
+          .thenReturn("A lot!");
+      expect(mock.typeParameterizedNamedFn([42], [43], y: [44], z: [45]),
+          equals("A lot!"));
+    });
+    test("should mock method with named, typed arg matcher and a regular arg", () {
+      when(
+          mock.typeParameterizedNamedFn(
+              typed(any), [43],
+              y: typed(any, named: "y"), z: [45]))
+          .thenReturn("A lot!");
+      expect(mock.typeParameterizedNamedFn([42], [43], y: [44], z: [45]),
+          equals("A lot!"));
+    });
+    test("should throw when [typed] used as a named arg, without `named:`", () {
+      expect(() => when(mock.typeParameterizedNamedFn(
+          typed(any), [43], y: typed(any))),
+          throwsArgumentError);
+    });
+    test("should throw when [typed] used as a positional arg, with `named:`", () {
+      expect(() => when(mock.typeParameterizedNamedFn(
+          typed(any), typed(any, named: "y"))),
+          throwsArgumentError);
+    });
+    test("should throw when [typed] used as a named arg, with the wrong `named:`", () {
+      expect(() => when(mock.typeParameterizedNamedFn(
+          typed(any), [43], y: typed(any, named: "z"))),
+          throwsArgumentError);
+    });
   });
 
   group("verify()", () {
@@ -319,6 +408,17 @@
       });
       verify(mock.setter = "A");
     });
+    test("should verify method with typed arg matchers", () {
+      mock.typeParameterizedFn([42], [43]);
+      verify(mock.typeParameterizedFn(typed(any), typed(any)));
+    });
+    test("should verify method with argument capturer", () {
+      mock.typeParameterizedFn([50], [17]);
+      mock.typeParameterizedFn([100], [17]);
+      expect(verify(mock.typeParameterizedFn(
+          typed(captureAny), [17])).captured,
+          equals([[50], [100]]));
+    });
   });
   group("verify() qualifies", () {
     group("unqualified as at least one", () {
@@ -478,7 +578,9 @@
     });
     test("should captureOut list arguments", () {
       mock.methodWithListArgs([42]);
-      expect(verify(mock.methodWithListArgs(captureAny)).captured.single, equals([42]));
+      expect(verify(
+          mock.methodWithListArgs(captureAny)).captured.single,
+          equals([42]));
     });
     test("should captureOut multiple arguments", () {
       mock.methodWithPositionalArgs(1, 2);