add a matcher for area covered by paths (#14469)


diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart
index b674584..df411d0 100644
--- a/packages/flutter_test/lib/src/matchers.dart
+++ b/packages/flutter_test/lib/src/matchers.dart
@@ -224,6 +224,18 @@
   return new _IsMethodCall(name, arguments);
 }
 
+/// Asserts that 2 paths cover the same area by sampling multiple points.
+///
+/// Samples at least [sampleSize]^2 points inside [areaToCompare], and asserts
+/// that the [Path.contains] method returns the same value for each of the
+/// points for both paths.
+///
+/// When using this matcher you typically want to use a rectangle larger than
+/// the area you expect to paint in for [areaToCompare] to catch errors where
+/// the path draws outside the expected area.
+Matcher coversSameAreaAs(Path expectedPath, {@required Rect areaToCompare, int sampleSize = 20})
+  => new _CoversSameAreaAs(expectedPath, areaToCompare: areaToCompare, sampleSize: sampleSize); 
+
 class _FindsWidgetMatcher extends Matcher {
   const _FindsWidgetMatcher(this.min, this.max);
 
@@ -1090,3 +1102,75 @@
   Description describe(Description description) =>
     description.add('clips with shape: $shape');
 }
+
+class _CoversSameAreaAs extends Matcher {
+  _CoversSameAreaAs(
+    this.expectedPath, {
+    @required this.areaToCompare,
+    this.sampleSize = 20,
+  }) : maxHorizontalNoise = areaToCompare.width / sampleSize,
+       maxVerticalNoise = areaToCompare.height / sampleSize {
+    // Use a fixed random seed to make sure tests are deterministic.
+    random = new math.Random(1);
+  }
+
+  final Path expectedPath;
+  final Rect areaToCompare;
+  final int sampleSize;
+  final double maxHorizontalNoise;
+  final double maxVerticalNoise;
+  math.Random random;
+
+  @override
+  bool matches(covariant Path actualPath, Map<dynamic, dynamic> matchState) {
+    for (int i = 0; i < sampleSize; i += 1) {
+      for (int j = 0; j < sampleSize; j += 1) {
+        final Offset offset = new Offset(
+          i * (areaToCompare.width / sampleSize),
+          j * (areaToCompare.height / sampleSize)
+        );
+
+        if (!_samplePoint(matchState, actualPath, offset))
+          return false;
+
+        final Offset noise = new Offset(
+          maxHorizontalNoise * random.nextDouble(),
+          maxVerticalNoise * random.nextDouble(),
+        );
+
+        if (!_samplePoint(matchState, actualPath, offset + noise))
+          return false;
+      }
+    }
+    return true;
+  }
+
+  bool _samplePoint(Map<dynamic, dynamic> matchState, Path actualPath, Offset offset) {
+    if (expectedPath.contains(offset) == actualPath.contains(offset))
+      return true;
+
+    if (actualPath.contains(offset))
+      return failWithDescription(matchState, '$offset is contained in the actual path but not in the expected path');
+    else
+      return failWithDescription(matchState, '$offset is contained in the expected path but not in the actual path');
+  }
+
+  bool failWithDescription(Map<dynamic, dynamic> matchState, String description) {
+    matchState['failure'] = description;
+    return false;
+  }
+
+  @override
+  Description describeMismatch(
+    dynamic item,
+    Description mismatchDescription,
+    Map<dynamic, dynamic> matchState,
+    bool verbose
+  ) {
+    return mismatchDescription.add(matchState['failure']);
+  }
+
+  @override
+  Description describe(Description description) =>
+    description.add('covers expected area and only expected area');
+}
diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart
index f01376a..11d8159 100644
--- a/packages/flutter_test/test/matchers_test.dart
+++ b/packages/flutter_test/test/matchers_test.dart
@@ -209,4 +209,77 @@
       throwsArgumentError,
     );
   });
+
+  group('coversSameAreaAs', () {
+    test('empty Paths', () {
+      expect(
+        new Path(),
+        coversSameAreaAs(
+          new Path(),
+          areaToCompare: new Rect.fromLTRB(0.0, 0.0, 10.0, 10.0)
+        ),
+      );
+    });
+
+    test('mismatch', () {
+      final Path rectPath = new Path()
+        ..addRect(new Rect.fromLTRB(5.0, 5.0, 6.0, 6.0));
+      expect(
+        new Path(),
+        isNot(coversSameAreaAs(
+          rectPath,
+          areaToCompare: new Rect.fromLTRB(0.0, 0.0, 10.0, 10.0)
+        )),
+      );
+    });
+
+    test('mismatch out of examined area', () {
+      final Path rectPath = new Path()
+        ..addRect(new Rect.fromLTRB(5.0, 5.0, 6.0, 6.0));
+      rectPath.addRect(new Rect.fromLTRB(5.0, 5.0, 6.0, 6.0));
+      expect(
+        new Path(),
+        coversSameAreaAs(
+          rectPath,
+          areaToCompare: new Rect.fromLTRB(0.0, 0.0, 4.0, 4.0)
+        ),
+      );
+    });
+
+    test('differently constructed rects match', () {
+      final Path rectPath = new Path()
+        ..addRect(new Rect.fromLTRB(5.0, 5.0, 6.0, 6.0));
+      final Path linePath = new Path()
+        ..moveTo(5.0, 5.0)
+        ..lineTo(5.0, 6.0)
+        ..lineTo(6.0, 6.0)
+        ..lineTo(6.0, 5.0)
+        ..close();
+      expect(
+        linePath,
+        coversSameAreaAs(
+          rectPath,
+          areaToCompare: new Rect.fromLTRB(0.0, 0.0, 10.0, 10.0)
+        ),
+      );
+    });
+
+     test('partially overlapping paths', () {
+      final Path rectPath = new Path()
+        ..addRect(new Rect.fromLTRB(5.0, 5.0, 6.0, 6.0));
+      final Path linePath = new Path()
+        ..moveTo(5.0, 5.0)
+        ..lineTo(5.0, 6.0)
+        ..lineTo(6.0, 6.0)
+        ..lineTo(6.0, 5.5)
+        ..close();
+      expect(
+        linePath,
+        isNot(coversSameAreaAs(
+          rectPath,
+          areaToCompare: new Rect.fromLTRB(0.0, 0.0, 10.0, 10.0)
+        )),
+      );
+    });
+  });
 }