Fix and refactor NoMatchingInvocationError.toString() (#149)

* Fix NoMatchingInvocationError.toString()

`NoMatchingInvocation.toString()` attempted to avoid printing a comma
after the last argument, but the code was backwards, instead adding a
trailing comma and omitting the comma after the first argument.

* Refactor NoMatchingInvocationError.toString()

Break up `NoMatchingInvocationError.toString()` so that we can
generate human-readable string representations of `Invocation`s
elsewhere for debugging.

* Add test for `describeInvocation`, fix printing strings for named arguments
diff --git a/packages/file/lib/src/backends/record_replay/common.dart b/packages/file/lib/src/backends/record_replay/common.dart
index 00e9ac9..c494e50 100644
--- a/packages/file/lib/src/backends/record_replay/common.dart
+++ b/packages/file/lib/src/backends/record_replay/common.dart
@@ -2,6 +2,7 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
+import 'codecs.dart';
 import 'events.dart';
 
 /// Encoded value of the file system in a recording.
@@ -155,3 +156,29 @@
             deeplyEqual(map1[key], map2[key]);
       });
 }
+
+/// Returns a human-readable representation of an [Invocation].
+String describeInvocation(Invocation invocation) {
+  StringBuffer buf = StringBuffer();
+  buf.write(getSymbolName(invocation.memberName));
+  if (invocation.isMethod) {
+    buf.write('(');
+    int i = 0;
+    for (dynamic arg in invocation.positionalArguments) {
+      if (i++ > 0) {
+        buf.write(', ');
+      }
+      buf.write(Error.safeToString(encode(arg)));
+    }
+    invocation.namedArguments.forEach((Symbol name, dynamic value) {
+      if (i++ > 0) {
+        buf.write(', ');
+      }
+      buf.write('${getSymbolName(name)}: ${Error.safeToString(encode(value))}');
+    });
+    buf.write(')');
+  } else if (invocation.isSetter) {
+    buf.write(Error.safeToString(encode(invocation.positionalArguments[0])));
+  }
+  return buf.toString();
+}
diff --git a/packages/file/lib/src/backends/record_replay/errors.dart b/packages/file/lib/src/backends/record_replay/errors.dart
index d5451f3..3d3fa12 100644
--- a/packages/file/lib/src/backends/record_replay/errors.dart
+++ b/packages/file/lib/src/backends/record_replay/errors.dart
@@ -2,7 +2,6 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
-import 'codecs.dart';
 import 'common.dart';
 
 /// Error thrown during replay when there is no matching invocation in the
@@ -16,31 +15,8 @@
   final Invocation invocation;
 
   @override
-  String toString() {
-    StringBuffer buf = StringBuffer();
-    buf.write('No matching invocation found: ');
-    buf.write(getSymbolName(invocation.memberName));
-    if (invocation.isMethod) {
-      buf.write('(');
-      int i = 0;
-      for (dynamic arg in invocation.positionalArguments) {
-        buf.write(Error.safeToString(encode(arg)));
-        if (i++ > 0) {
-          buf.write(', ');
-        }
-      }
-      invocation.namedArguments.forEach((Symbol name, dynamic value) {
-        if (i++ > 0) {
-          buf.write(', ');
-        }
-        buf.write('${getSymbolName(name)}: ${encode(value)}');
-      });
-      buf.write(')');
-    } else if (invocation.isSetter) {
-      buf.write(Error.safeToString(encode(invocation.positionalArguments[0])));
-    }
-    return buf.toString();
-  }
+  String toString() =>
+      'No matching invocation found: ${describeInvocation(invocation)}';
 }
 
 /// Exception thrown during replay when an invocation recorded error, but we
diff --git a/packages/file/test/replay_test.dart b/packages/file/test/replay_test.dart
index acce6ce..e54f28d 100644
--- a/packages/file/test/replay_test.dart
+++ b/packages/file/test/replay_test.dart
@@ -7,6 +7,8 @@
 import 'package:file/file.dart';
 import 'package:file/memory.dart';
 import 'package:file/record_replay.dart';
+import 'package:file/src/backends/record_replay/common.dart'
+    show describeInvocation;
 import 'package:file_testing/file_testing.dart';
 import 'package:path/path.dart' as path;
 import 'package:test/test.dart';
@@ -202,6 +204,41 @@
       });
     });
   });
+
+  group('describeInvocation', () {
+    test('noArguments', () {
+      expect(
+        describeInvocation(Invocation.method(#foo, [])),
+        'foo()',
+      );
+    });
+
+    test('onlyPositionalArguments', () {
+      expect(
+        describeInvocation(Invocation.method(#foo, [1, 'bar', null])),
+        'foo(1, "bar", null)',
+      );
+    });
+
+    test('onlyNamedArguments', () {
+      expect(
+        describeInvocation(
+            Invocation.method(#foo, [], {#x: 2, #y: 'baz', #z: null})),
+        'foo(x: 2, y: "baz", z: null)',
+      );
+    });
+
+    test('positionalAndNamedArguments', () {
+      expect(
+        describeInvocation(Invocation.method(
+          #foo,
+          [1, 'bar', null],
+          {#x: 2, #y: 'baz', #z: null},
+        )),
+        'foo(1, "bar", null, x: 2, y: "baz", z: null)',
+      );
+    });
+  });
 }
 
 /// Successfully matches against an instance of [Future].