From e5aa6f254c9e89a138a0686f6ab3afe97a116f3e Mon Sep 17 00:00:00 2001
From: "rnystrom@google.com" <rnystrom@google.com>
Date: Fri, 28 Feb 2014 17:30:56 +0000
Subject: [PATCH] Support nested matchers in deep equality matching.

R=kevmoo@google.com, sigmund@google.com

Review URL: https://codereview.chromium.org//184103002

git-svn-id: https://dart.googlecode.com/svn/branches/bleeding_edge/dart/pkg/unittest@33164 260f80e4-7a28-3924-810f-c04153c831b5
---
 CHANGELOG.md                |   1 +
 lib/src/core_matchers.dart  | 179 ++++++++++++++++++------------------
 lib/src/pretty_print.dart   |   8 ++
 test/matchers_test.dart     |  20 ++++
 test/pretty_print_test.dart |  15 +++
 5 files changed, 133 insertions(+), 90 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index be796e4b..316b851b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,5 +19,6 @@ and `protectAsync2`
 * `runTests`, `tearDown`, `setUp`, `test`, `group`, `solo_test`, and
   `solo_group` now throw a `StateError` if called while tests are running.
 * `rerunTests` has been removed.
+* `equals` now allows a nested matcher as an expected list element or map value when doing deep matching.
 
 ##0.9.3 - 2014-01-13
diff --git a/lib/src/core_matchers.dart b/lib/src/core_matchers.dart
index 86f51f36..e55738d4 100644
--- a/lib/src/core_matchers.dart
+++ b/lib/src/core_matchers.dart
@@ -67,7 +67,7 @@ class _IsFalse extends Matcher {
 
 /**
  * Returns a matches that matches if the value is the same instance
- * as [object] (`===`).
+ * as [expected], using [identical].
  */
 Matcher same(expected) => new _IsSameAs(expected);
 
@@ -81,9 +81,15 @@ class _IsSameAs extends Matcher {
 }
 
 /**
- * Returns a matcher that does a deep recursive match. This only works
- * with scalars, Maps and Iterables. To handle cyclic structures a
- * recursion depth [limit] can be provided. The default limit is 100.
+ * Returns a matcher that matches if the value is structurally equal to
+ * [expected].
+ *
+ * If [expected] is a [Matcher], then it matches using that. Otherwise it tests
+ * for equality using `==` on the expected value.
+ *
+ * For [Iterable]s and [Map]s, this will recursively match the elements. To
+ * handle cyclic structures a recursion depth [limit] can be provided. The
+ * default limit is 100.
  */
 Matcher equals(expected, [limit=100]) =>
     expected is String
@@ -99,106 +105,99 @@ class _DeepMatcher extends Matcher {
 
   // Returns a pair (reason, location)
   List _compareIterables(expected, actual, matcher, depth, location) {
-    if (actual is! Iterable) {
-      return ['is not Iterable', location];
-    }
+    if (actual is! Iterable) return ['is not Iterable', location];
+
     var expectedIterator = expected.iterator;
     var actualIterator = actual.iterator;
-    var index = 0;
-    while (true) {
+    for (var index = 0;; index++) {
+      // Advance in lockstep.
+      var expectedNext = expectedIterator.moveNext();
+      var actualNext = actualIterator.moveNext();
+
+      // If we reached the end of both, we succeeded.
+      if (!expectedNext && !actualNext) return null;
+
+      // Fail if their lengths are different.
       var newLocation = '${location}[${index}]';
-      if (expectedIterator.moveNext()) {
-        if (actualIterator.moveNext()) {
-          var rp = matcher(expectedIterator.current,
-                           actualIterator.current, newLocation,
-                           depth);
-          if (rp != null) return rp;
-          ++index;
-        } else {
-          return ['shorter than expected', newLocation];
-        }
-      } else if (actualIterator.moveNext()) {
-        return ['longer than expected', newLocation];
-      } else {
-        return null;
-      }
+      if (!expectedNext) return ['longer than expected', newLocation];
+      if (!actualNext) return ['shorter than expected', newLocation];
+
+      // Match the elements.
+      var rp = matcher(expectedIterator.current, actualIterator.current,
+          newLocation, depth);
+      if (rp != null) return rp;
     }
-    return null;
   }
 
   List _recursiveMatch(expected, actual, String location, int depth) {
-    String reason = null;
+    // If the expected value is a matcher, try to match it.
+    if (expected is Matcher) {
+      var matchState = {};
+      if (expected.matches(actual, matchState)) return null;
+
+      var description = new StringDescription();
+      expected.describe(description);
+      return ['does not match $description', location];
+    } else {
+      // Otherwise, test for equality.
+      try {
+        if (expected == actual) return null;
+      } catch (e, s) {
+        // TODO(gram): Add a test for this case.
+        return ['== threw "$e"', location];
+      }
+    }
+
+    if (depth > _limit) return ['recursion depth limit exceeded', location];
+
     // If _limit is 1 we can only recurse one level into object.
     bool canRecurse = depth == 0 || _limit > 1;
-    bool equal;
-    try {
-      equal = (expected == actual);
-    } catch (e, s) {
-      // TODO(gram): Add a test for this case.
-      reason = '== threw "$e"';
-      return [reason, location];
+
+    if (expected is Iterable && canRecurse) {
+      return _compareIterables(expected, actual, _recursiveMatch, depth + 1,
+          location);
     }
-    if (equal) {
-      // Do nothing.
-    } else if (depth > _limit) {
-      reason = 'recursion depth limit exceeded';
-    } else {
-      if (expected is Iterable && canRecurse) {
-        List result = _compareIterables(expected, actual,
-            _recursiveMatch, depth + 1, location);
-        if (result != null) {
-          reason = result[0];
-          location = result[1];
-        }
-      } else if (expected is Map && canRecurse) {
-        if (actual is! Map) {
-          reason = 'expected a map';
-        } else {
-          var err = (expected.length == actual.length) ? '' :
-                    'has different length and ';
-          for (var key in expected.keys) {
-            if (!actual.containsKey(key)) {
-              reason = '${err}is missing map key \'$key\'';
-              break;
-            }
-          }
-          if (reason == null) {
-            for (var key in actual.keys) {
-              if (!expected.containsKey(key)) {
-                reason = '${err}has extra map key \'$key\'';
-                break;
-              }
-            }
-            if (reason == null) {
-              for (var key in expected.keys) {
-                var rp = _recursiveMatch(expected[key], actual[key],
-                    "${location}['${key}']", depth + 1);
-                if (rp != null) {
-                  reason = rp[0];
-                  location = rp[1];
-                  break;
-                }
-              }
-            }
-          }
+
+    if (expected is Map && canRecurse) {
+      if (actual is! Map) return ['expected a map', location];
+
+      var err = (expected.length == actual.length) ? '' :
+                'has different length and ';
+      for (var key in expected.keys) {
+        if (!actual.containsKey(key)) {
+          return ["${err}is missing map key '$key'", location];
         }
-      } else {
-        var description = new StringDescription();
-        // If we have recursed, show the expected value too; if not,
-        // expect() will show it for us.
-        if (depth > 0) {
-          description.add('was ').
-              addDescriptionOf(actual).
-              add(' instead of ').
-              addDescriptionOf(expected);
-          reason = description.toString();
-        } else {
-          reason = ''; // We're not adding any value to the actual value.
+      }
+
+      for (var key in actual.keys) {
+        if (!expected.containsKey(key)) {
+          return ["${err}has extra map key '$key'", location];
         }
       }
+
+      for (var key in expected.keys) {
+        var rp = _recursiveMatch(expected[key], actual[key],
+            "${location}['${key}']", depth + 1);
+        if (rp != null) return rp;
+      }
+
+      return null;
     }
-    if (reason == null) return null;
-    return [reason, location];
+
+    var description = new StringDescription();
+
+    // If we have recursed, show the expected value too; if not, expect() will
+    // show it for us.
+    if (depth > 0) {
+      description.add('was ').
+          addDescriptionOf(actual).
+          add(' instead of ').
+          addDescriptionOf(expected);
+      return [description.toString(), location];
+    }
+
+    // We're not adding any value to the actual value.
+    return ["", location];
   }
 
   String _match(expected, actual, Map matchState) {
diff --git a/lib/src/pretty_print.dart b/lib/src/pretty_print.dart
index 14a0bb1f..a748ec35 100644
--- a/lib/src/pretty_print.dart
+++ b/lib/src/pretty_print.dart
@@ -4,6 +4,7 @@
 
 library unittest.pretty_print;
 
+import '../matcher.dart';
 import 'utils.dart';
 
 /**
@@ -19,6 +20,13 @@ import 'utils.dart';
  */
 String prettyPrint(object, {int maxLineLength, int maxItems}) {
   String _prettyPrint(object, int indent, Set seen, bool top) {
+    // If the object is a matcher, use its description.
+    if (object is Matcher) {
+      var description = new StringDescription();
+      object.describe(description);
+      return "<$description>";
+    }
+
     // Avoid looping infinitely on recursively-nested data structures.
     if (seen.contains(object)) return "(recursive)";
     seen = seen.union(new Set.from([object]));
diff --git a/test/matchers_test.dart b/test/matchers_test.dart
index 9f8e1219..dc2b37ae 100644
--- a/test/matchers_test.dart
+++ b/test/matchers_test.dart
@@ -401,6 +401,15 @@ void main() {
           "Actual: [1, 2]");
     });
 
+    test('equals with matcher element', () {
+      var d = ['foo', 'bar'];
+      shouldPass(d, equals(['foo', startsWith('ba')]));
+      shouldFail(d, equals(['foo', endsWith('ba')]),
+          "Expected: ['foo', <a string ending with 'ba'>] "
+          "Actual: ['foo', 'bar'] "
+          "Which: does not match a string ending with 'ba' at location [1]");
+    });
+
     test('isIn', () {
       var d = [1, 2];
       shouldPass(1, isIn(d));
@@ -593,6 +602,17 @@ void main() {
           "Which: has different length and is missing map key 'foo'");
     });
 
+    test('equals with matcher value', () {
+      var a = new Map();
+      a['foo'] = 'bar';
+      shouldPass(a, equals({'foo': startsWith('ba')}));
+      shouldFail(a, equals({'foo': endsWith('ba')}),
+          "Expected: {'foo': <a string ending with 'ba'>} "
+          "Actual: {'foo': 'bar'} "
+          "Which: does not match a string ending with 'ba' "
+              "at location ['foo']");
+    });
+
     test('contains', () {
       var a = new Map();
       a['foo'] = 'bar';
diff --git a/test/pretty_print_test.dart b/test/pretty_print_test.dart
index a0608ac0..826c3451 100644
--- a/test/pretty_print_test.dart
+++ b/test/pretty_print_test.dart
@@ -48,6 +48,11 @@ void main() {
           "]"));
     });
 
+    test('containing a matcher', () {
+      expect(prettyPrint(['foo', endsWith('qux')]),
+          equals("['foo', <a string ending with 'qux'>]"));
+    });
+
     test("that's under maxLineLength", () {
       expect(prettyPrint([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxLineLength: 30),
           equals("[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]"));
@@ -138,6 +143,16 @@ void main() {
           "}"));
     });
 
+    test('containing a matcher key', () {
+      expect(prettyPrint({endsWith('bar'): 'qux'}),
+          equals("{<a string ending with 'bar'>: 'qux'}"));
+    });
+
+    test('containing a matcher value', () {
+      expect(prettyPrint({'foo': endsWith('qux')}),
+          equals("{'foo': <a string ending with 'qux'>}"));
+    });
+
     test("that's under maxLineLength", () {
       expect(prettyPrint({'0': 1, '2': 3, '4': 5, '6': 7}, maxLineLength: 32),
           equals("{'0': 1, '2': 3, '4': 5, '6': 7}"));
-- 
GitLab