From ba5b5e7f6f6e58f2b78ebb9a9749bb3313d21528 Mon Sep 17 00:00:00 2001 From: Yegor Date: Mon, 11 Sep 2017 09:46:42 -0700 Subject: [PATCH] only tap on widgets reachable by hit testing (#11767) * only tap on widgets reachable by hit testing * use FractionalOffset * added tests * check finder finds correct widget * undo unintentional changes * address comments * style fix * add Directionality in test * fix analysis warning --- dev/devicelab/lib/tasks/integration_ui.dart | 9 +++- dev/integration_tests/ui/lib/driver.dart | 33 ++++++++++++++ .../ui/test_driver/driver_test.dart | 16 +++++++ .../flutter_driver/lib/src/extension.dart | 5 ++- packages/flutter_test/lib/src/finders.dart | 35 +++++++++++++++ packages/flutter_test/test/finders_test.dart | 44 +++++++++++++++++++ 6 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 packages/flutter_test/test/finders_test.dart diff --git a/dev/devicelab/lib/tasks/integration_ui.dart b/dev/devicelab/lib/tasks/integration_ui.dart index 50fe7f5ea7..694be05c82 100644 --- a/dev/devicelab/lib/tasks/integration_ui.dart +++ b/dev/devicelab/lib/tasks/integration_ui.dart @@ -21,7 +21,14 @@ Future runEndToEndTests() async { if (deviceOperatingSystem == DeviceOperatingSystem.ios) await prepareProvisioningCertificates(testDirectory.path); - await flutter('drive', options: ['--verbose', '-d', deviceId, 'lib/keyboard_resize.dart']); + const List entryPoints = const [ + 'lib/keyboard_resize.dart', + 'lib/driver.dart', + ]; + + for (final String entryPoint in entryPoints) { + await flutter('drive', options: ['--verbose', '-d', deviceId, entryPoint]); + } }); return new TaskResult.success({}); diff --git a/dev/integration_tests/ui/lib/driver.dart b/dev/integration_tests/ui/lib/driver.dart index f21e9bfd85..d23a2764d3 100644 --- a/dev/integration_tests/ui/lib/driver.dart +++ b/dev/integration_tests/ui/lib/driver.dart @@ -23,6 +23,7 @@ class DriverTestApp extends StatefulWidget { class DriverTestAppState extends State { bool present = true; + Letter _selectedValue = Letter.a; @override Widget build(BuildContext context) { @@ -52,9 +53,41 @@ class DriverTestAppState extends State { ), ], ), + new Row( + children: [ + const Expanded( + child: const Text('hit testability'), + ), + new DropdownButton( + key: const ValueKey('dropdown'), + value: _selectedValue, + onChanged: (Letter newValue) { + setState(() { + _selectedValue = newValue; + }); + }, + items: >[ + const DropdownMenuItem( + value: Letter.a, + child: const Text('Aaa', key: const ValueKey('a')), + ), + const DropdownMenuItem( + value: Letter.b, + child: const Text('Bbb', key: const ValueKey('b')), + ), + const DropdownMenuItem( + value: Letter.c, + child: const Text('Ccc', key: const ValueKey('c')), + ), + ], + ), + ], + ), ], ), ), ); } } + +enum Letter { a, b, c } diff --git a/dev/integration_tests/ui/test_driver/driver_test.dart b/dev/integration_tests/ui/test_driver/driver_test.dart index b44ba955c2..bca3c3d575 100644 --- a/dev/integration_tests/ui/test_driver/driver_test.dart +++ b/dev/integration_tests/ui/test_driver/driver_test.dart @@ -77,5 +77,21 @@ void main() { test('waitForAbsent resolves immediately when the element does not exist', () async { await driver.waitForAbsent(find.text('that does not exist')); }); + + test('uses hit test to determine tappable elements', () async { + final SerializableFinder a = find.byValueKey('a'); + final SerializableFinder menu = find.byType('_DropdownMenu'); + + // Dropdown is closed + await driver.waitForAbsent(menu); + + // Open dropdown + await driver.tap(a); + await driver.waitFor(menu); + + // Close it again + await driver.tap(a); + await driver.waitForAbsent(menu); + }); }); } diff --git a/packages/flutter_driver/lib/src/extension.dart b/packages/flutter_driver/lib/src/extension.dart index 36218e8cba..60ed3dac43 100644 --- a/packages/flutter_driver/lib/src/extension.dart +++ b/packages/flutter_driver/lib/src/extension.dart @@ -262,7 +262,10 @@ class FlutterDriverExtension { Future _tap(Command command) async { final Tap tapCommand = command; - await _prober.tap(await _waitForElement(_createFinder(tapCommand.finder))); + final Finder computedFinder = await _waitForElement( + _createFinder(tapCommand.finder).hitTestable() + ); + await _prober.tap(computedFinder); return new TapResult(); } diff --git a/packages/flutter_test/lib/src/finders.dart b/packages/flutter_test/lib/src/finders.dart index da7984b305..5f6a2f6495 100644 --- a/packages/flutter_test/lib/src/finders.dart +++ b/packages/flutter_test/lib/src/finders.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; @@ -284,6 +285,13 @@ abstract class Finder { /// matched by this finder. Finder get last => new _LastFinder(this); + /// Returns a variant of this finder that only matches elements reachable by + /// a hit test. + /// + /// The [at] parameter specifies the location relative to the size of the + /// target element where the hit test is performed. + Finder hitTestable({ FractionalOffset at: FractionalOffset.center }) => new _HitTestableFinder(this, at); + @override String toString() { final String additional = skipOffstage ? ' (ignoring offstage widgets)' : ''; @@ -327,6 +335,33 @@ class _LastFinder extends Finder { } } +class _HitTestableFinder extends Finder { + _HitTestableFinder(this.parent, this.offset); + + final Finder parent; + final FractionalOffset offset; + + @override + String get description => '${parent.description} (considering only hit-testable ones)'; + + @override + Iterable apply(Iterable candidates) sync* { + for (final Element candidate in parent.apply(candidates)) { + final RenderBox box = candidate.renderObject; + assert(box != null); + final Offset absoluteOffset = box.localToGlobal(offset.alongSize(box.size)); + final HitTestResult hitResult = new HitTestResult(); + WidgetsBinding.instance.hitTest(hitResult, absoluteOffset); + for (final HitTestEntry entry in hitResult.path) { + if (entry.target == candidate.renderObject) { + yield candidate; + break; + } + } + } + } +} + /// Searches a widget tree and returns nodes that match a particular /// pattern. abstract class MatchFinder extends Finder { diff --git a/packages/flutter_test/test/finders_test.dart b/packages/flutter_test/test/finders_test.dart new file mode 100644 index 0000000000..94082c54d2 --- /dev/null +++ b/packages/flutter_test/test/finders_test.dart @@ -0,0 +1,44 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('hitTestable', () { + testWidgets('excludes non-hit-testable widgets', (WidgetTester tester) async { + await tester.pumpWidget( + _boilerplate(new IndexedStack( + sizing: StackFit.expand, + children: [ + new GestureDetector( + key: const ValueKey(0), + behavior: HitTestBehavior.opaque, + onTap: () { }, + child: const SizedBox.expand(), + ), + new GestureDetector( + key: const ValueKey(1), + behavior: HitTestBehavior.opaque, + onTap: () { }, + child: const SizedBox.expand(), + ), + ], + )), + ); + expect(find.byType(GestureDetector), findsNWidgets(2)); + final Finder hitTestable = find.byType(GestureDetector).hitTestable(at: const FractionalOffset(0.5, 0.5)); + expect(hitTestable, findsOneWidget); + expect(tester.widget(hitTestable).key, const ValueKey(0)); + }); + }); +} + +Widget _boilerplate(Widget child) { + return new Directionality( + textDirection: TextDirection.ltr, + child: child, + ); +}