From d2c8c82f4b96080fd4e2cb206fa8a96340448065 Mon Sep 17 00:00:00 2001 From: Ian Hickson Date: Tue, 17 May 2016 17:12:03 -0700 Subject: [PATCH] Some cleanup of the test framework (#4001) * Add a "build" phase to EnginePhase for completeness. * Ignore events from the device during test execution. * More dartdocs * Slightly more helpful messages about Timers in verifyInvariants. * Add widgetList, elementList, stateList, renderObjectList. * Send test events asynchronously for consistency with other APIs. * Fix a test that was depending on test events being synchronous (or rather, scheduled in a microtask that came before the microtask for the completer of the future that the tap() function returned). --- .../flutter/test/material/drop_down_test.dart | 9 ++- packages/flutter_test/lib/src/binding.dart | 70 ++++++++++++++-- packages/flutter_test/lib/src/controller.dart | 80 +++++++++++++++++-- .../flutter_test/lib/src/widget_tester.dart | 9 +++ 4 files changed, 152 insertions(+), 16 deletions(-) diff --git a/packages/flutter/test/material/drop_down_test.dart b/packages/flutter/test/material/drop_down_test.dart index d9c55754e1c..f2d4173a5f2 100644 --- a/packages/flutter/test/material/drop_down_test.dart +++ b/packages/flutter/test/material/drop_down_test.dart @@ -27,7 +27,7 @@ void main() { home: new Material( child: new Align( alignment: FractionalOffset.topCenter, - child:button + child: button ) ) ) @@ -39,13 +39,16 @@ void main() { // We should have two copies of item 5, one in the menu and one in the // button itself. - expect(find.text('5').evaluate().length, 2); + expect(tester.elementList(find.text('5')), hasLength(2)); // We should only have one copy of item 19, which is in the button itself. // The copy in the menu shouldn't be in the tree because it's off-screen. - expect(find.text('19').evaluate().length, 1); + expect(tester.elementList(find.text('19')), hasLength(1)); + expect(value, 4); await tester.tap(find.byConfig(button)); + expect(value, 4); + await tester.idle(); // this waits for the route's completer to complete, which calls handleChanged // Ideally this would be 4 because the menu would be overscrolled to the // correct position, but currently we just reposition the menu so that it diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart index a76ae86c629..7f102c5e4c7 100644 --- a/packages/flutter_test/lib/src/binding.dart +++ b/packages/flutter_test/lib/src/binding.dart @@ -20,16 +20,51 @@ import 'package:vector_math/vector_math_64.dart'; import 'test_async_utils.dart'; import 'stack_manipulation.dart'; -/// Enumeration of possible phases to reach in -/// [WidgetTester.pumpWidget] and [TestWidgetsFlutterBinding.pump]. -// TODO(ianh): Merge with identical code in the rendering test code. +/// Phases that can be reached by [WidgetTester.pumpWidget] and +/// [TestWidgetsFlutterBinding.pump]. +// TODO(ianh): Merge with near-identical code in the rendering test code. enum EnginePhase { + /// The build phase in the widgets library. See [BuildOwner.buildDirtyElements]. + build, + + /// The layout phase in the rendering library. See [PipelineOwner.flushLayout]. layout, + + /// The compositing bits update phase in the rendering library. See + /// [PipelineOwner.flushCompositingBits]. compositingBits, + + /// The paint phase in the rendering library. See [PipelineOwner.flushPaint]. paint, + + /// The compositing phase in the rendering library. See + /// [RenderView.compositeFrame]. This is the phase in which data is sent to + /// the GPU. If semantics are not enabled, then this is the last phase. composite, + + /// The semantics building phase in the rendering library. See + /// [PipelineOwner.flushSemantics]. flushSemantics, - sendSemanticsTree + + /// The final phase in the rendering library, wherein semantics information is + /// sent to the embedder. See [SemanticsNode.sendSemanticsTree]. + sendSemanticsTree, +} + +/// Parts of the system that can generate pointer events that reach the test +/// binding. +/// +/// This is used to identify how to handle events in the +/// [LiveTestWidgetsFlutterBinding]. See +/// [TestWidgetsFlutterBinding.dispatchEvent]. +enum TestBindingEventSource { + /// The pointer event came from the test framework itself, e.g. from a + /// [TestGesture] created by [WidgetTester.startGesture]. + test, + + /// The pointer event came from the system, presumably as a result of the user + /// interactive directly with the device while the test was running. + device, } const Size _kTestViewportSize = const Size(800.0, 600.0); @@ -74,6 +109,7 @@ abstract class TestWidgetsFlutterBinding extends BindingBase super.initInstances(); } + /// Whether there is currently a test executing. bool get inTest; /// The default test timeout for tests when using this binding. @@ -112,6 +148,14 @@ abstract class TestWidgetsFlutterBinding extends BindingBase return new Future.value(); } + @override + void dispatchEvent(PointerEvent event, HitTestResult result, { + TestBindingEventSource source: TestBindingEventSource.device + }) { + assert(source == TestBindingEventSource.test); + super.dispatchEvent(event, result); + } + /// Returns the exception most recently caught by the Flutter framework. /// /// Call this if you expect an exception during a test. If an exception is @@ -385,6 +429,8 @@ class AutomatedTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { void beginFrame() { assert(inTest); buildOwner.buildDirtyElements(); + if (_phase == EnginePhase.build) + return; assert(renderView != null); pipelineOwner.flushLayout(); if (_phase == EnginePhase.layout) @@ -439,11 +485,11 @@ class AutomatedTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { void _verifyInvariants() { super._verifyInvariants(); assert(() { - 'A Timer is still running even after the widget tree was disposed.'; + 'A periodic Timer is still running even after the widget tree was disposed.'; return _fakeAsync.periodicTimerCount == 0; }); assert(() { - 'A Timer is still running even after the widget tree was disposed.'; + 'A Timer is still pending even after the widget tree was disposed.'; return _fakeAsync.nonPeriodicTimerCount == 0; }); assert(_fakeAsync.microtaskCount == 0); // Shouldn't be possible. @@ -534,6 +580,18 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { } } + @override + void dispatchEvent(PointerEvent event, HitTestResult result, { + TestBindingEventSource source: TestBindingEventSource.device + }) { + if (source == TestBindingEventSource.test) { + super.dispatchEvent(event, result, source: source); + return; + } + // we eat all device events for now + // TODO(ianh): do something useful with device events + } + @override Future pump([ Duration duration, EnginePhase newPhase = EnginePhase.sendSemanticsTree ]) { assert(newPhase == EnginePhase.sendSemanticsTree); diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart index f75f952f19c..590bfcfd030 100644 --- a/packages/flutter_test/lib/src/controller.dart +++ b/packages/flutter_test/lib/src/controller.dart @@ -32,6 +32,7 @@ class WidgetController { return finder.evaluate().isNotEmpty; } + /// All widgets currently in the widget tree (lazy pre-order traversal). /// /// Can contain duplicates, since widgets can be used in multiple @@ -46,6 +47,9 @@ class WidgetController { /// /// Throws a [StateError] if `finder` is empty or matches more than /// one widget. + /// + /// * Use [firstWidget] if you expect to match several widgets but only want the first. + /// * Use [widgetList] if you expect to match several widgets and want all of them. Widget/*=T*/ widget/**/(Finder finder) { TestAsyncUtils.guardSync(); return finder.evaluate().single.widget; @@ -55,11 +59,23 @@ class WidgetController { /// traversal of the widget tree. /// /// Throws a [StateError] if `finder` is empty. + /// + /// * Use [widget] if you only expect to match one widget. Widget/*=T*/ firstWidget/**/(Finder finder) { TestAsyncUtils.guardSync(); return finder.evaluate().first.widget; } + /// The matching widgets in the widget tree. + /// + /// * Use [widget] if you only expect to match one widget. + /// * Use [firstWidget] if you expect to match several but only want the first. + Iterable widgetList/**/(Finder finder) { + TestAsyncUtils.guardSync(); + return finder.evaluate().map((Element element) => element.widget); + } + + /// All elements currently in the widget tree (lazy pre-order traversal). /// /// The returned iterable is lazy. It does not walk the entire widget tree @@ -74,6 +90,9 @@ class WidgetController { /// /// Throws a [StateError] if `finder` is empty or matches more than /// one element. + /// + /// * Use [firstElement] if you expect to match several elements but only want the first. + /// * Use [elementList] if you expect to match several elements and want all of them. Element/*=T*/ element/**/(Finder finder) { TestAsyncUtils.guardSync(); return finder.evaluate().single; @@ -83,11 +102,23 @@ class WidgetController { /// traversal of the widget tree. /// /// Throws a [StateError] if `finder` is empty. + /// + /// * Use [element] if you only expect to match one element. Element/*=T*/ firstElement/**/(Finder finder) { TestAsyncUtils.guardSync(); return finder.evaluate().first; } + /// The matching elements in the widget tree. + /// + /// * Use [element] if you only expect to match one element. + /// * Use [firstElement] if you expect to match several but only want the first. + Iterable elementList/**/(Finder finder) { + TestAsyncUtils.guardSync(); + return finder.evaluate(); + } + + /// All states currently in the widget tree (lazy pre-order traversal). /// /// The returned iterable is lazy. It does not walk the entire widget tree @@ -104,6 +135,9 @@ class WidgetController { /// /// Throws a [StateError] if `finder` is empty, matches more than /// one state, or matches a widget that has no state. + /// + /// * Use [firstState] if you expect to match several states but only want the first. + /// * Use [stateList] if you expect to match several states and want all of them. State/*=T*/ state/**/(Finder finder) { TestAsyncUtils.guardSync(); return _stateOf/**/(finder.evaluate().single, finder); @@ -114,11 +148,25 @@ class WidgetController { /// /// Throws a [StateError] if `finder` is empty or if the first /// matching widget has no state. + /// + /// * Use [state] if you only expect to match one state. State/*=T*/ firstState/**/(Finder finder) { TestAsyncUtils.guardSync(); return _stateOf/**/(finder.evaluate().first, finder); } + /// The matching states in the widget tree. + /// + /// Throws a [StateError] if any of the elements in `finder` match a widget + /// that has no state. + /// + /// * Use [state] if you only expect to match one state. + /// * Use [firstState] if you expect to match several but only want the first. + Iterable stateList/**/(Finder finder) { + TestAsyncUtils.guardSync(); + return finder.evaluate().map((Element element) => _stateOf/**/(element, finder)); + } + State/*=T*/ _stateOf/**/(Element element, Finder finder) { TestAsyncUtils.guardSync(); if (element is StatefulElement) @@ -126,6 +174,7 @@ class WidgetController { throw new StateError('Widget of type ${element.widget.runtimeType}, with ${finder.description}, is not a StatefulWidget.'); } + /// Render objects of all the widgets currently in the widget tree /// (lazy pre-order traversal). /// @@ -143,6 +192,9 @@ class WidgetController { /// /// Throws a [StateError] if `finder` is empty or matches more than /// one widget (even if they all have the same render object). + /// + /// * Use [firstRenderObject] if you expect to match several render objects but only want the first. + /// * Use [renderObjectList] if you expect to match several render objects and want all of them. RenderObject/*=T*/ renderObject/**/(Finder finder) { TestAsyncUtils.guardSync(); return finder.evaluate().single.renderObject; @@ -152,11 +204,22 @@ class WidgetController { /// depth-first pre-order traversal of the widget tree. /// /// Throws a [StateError] if `finder` is empty. + /// + /// * Use [renderObject] if you only expect to match one render object. RenderObject/*=T*/ firstRenderObject/**/(Finder finder) { TestAsyncUtils.guardSync(); return finder.evaluate().first.renderObject; } + /// The render objects of the matching widgets in the widget tree. + /// + /// * Use [renderObject] if you only expect to match one render object. + /// * Use [firstRenderObject] if you expect to match several but only want the first. + Iterable renderObjectList/**/(Finder finder) { + TestAsyncUtils.guardSync(); + return finder.evaluate().map((Element element) => element.renderObject); + } + /// Returns a list of all the [Layer] objects in the rendering. List get layers => _walkLayers(binding.renderView.layer).toList(); @@ -214,13 +277,13 @@ class WidgetController { const int kMoveCount = 50; // Needs to be >= kHistorySize, see _LeastSquaresVelocityTrackerStrategy final double timeStampDelta = 1000.0 * offset.distance / (kMoveCount * velocity); double timeStamp = 0.0; - await _dispatchEvent(p.down(startLocation, timeStamp: new Duration(milliseconds: timeStamp.round())), result); + await sendEventToBinding(p.down(startLocation, timeStamp: new Duration(milliseconds: timeStamp.round())), result); for (int i = 0; i <= kMoveCount; i++) { final Point location = startLocation + Offset.lerp(Offset.zero, offset, i / kMoveCount); - await _dispatchEvent(p.move(location, timeStamp: new Duration(milliseconds: timeStamp.round())), result); + await sendEventToBinding(p.move(location, timeStamp: new Duration(milliseconds: timeStamp.round())), result); timeStamp += timeStampDelta; } - await _dispatchEvent(p.up(timeStamp: new Duration(milliseconds: timeStamp.round())), result); + await sendEventToBinding(p.up(timeStamp: new Duration(milliseconds: timeStamp.round())), result); return null; }); } @@ -248,7 +311,7 @@ class WidgetController { /// Begins a gesture at a particular point, and returns the /// [TestGesture] object which you can use to continue the gesture. Future startGesture(Point downLocation, { int pointer: 1 }) { - return TestGesture.down(downLocation, pointer: pointer, dispatcher: _dispatchEvent); + return TestGesture.down(downLocation, pointer: pointer, dispatcher: sendEventToBinding); } HitTestResult _hitTest(Point location) { @@ -257,9 +320,12 @@ class WidgetController { return result; } - Future _dispatchEvent(PointerEvent event, HitTestResult result) { - binding.dispatchEvent(event, result); - return new Future.value(); + /// Forwards the given pointer event to the binding. + Future sendEventToBinding(PointerEvent event, HitTestResult result) { + return TestAsyncUtils.guard(() async { + binding.dispatchEvent(event, result); + return null; + }); } diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index abeb230bd63..75776bd8a8c 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:test/test.dart' as test_package; @@ -156,6 +157,14 @@ class WidgetTester extends WidgetController { return TestAsyncUtils.guard(() => binding.pump(duration, phase)); } + @override + Future sendEventToBinding(PointerEvent event, HitTestResult result) { + return TestAsyncUtils.guard(() async { + binding.dispatchEvent(event, result, source: TestBindingEventSource.test); + return null; + }); + } + /// Returns the exception most recently caught by the Flutter framework. /// /// See [TestWidgetsFlutterBinding.takeException] for details.