diff --git a/dev/integration_tests/flutter_gallery/test/live_smoketest.dart b/dev/integration_tests/flutter_gallery/test/live_smoketest.dart index 26aae16dec9..44afcc9b0d0 100644 --- a/dev/integration_tests/flutter_gallery/test/live_smoketest.dart +++ b/dev/integration_tests/flutter_gallery/test/live_smoketest.dart @@ -126,11 +126,11 @@ class _LiveWidgetController extends LiveWidgetController { } /// Runs `finder` repeatedly until it finds one or more [Element]s. - Future _waitForElement(Finder finder) async { + Future> _waitForElement(FinderBase finder) async { if (frameSync) { await _waitUntilFrame(() => binding.transientCallbackCount == 0); } - await _waitUntilFrame(() => finder.precache()); + await _waitUntilFrame(() => finder.tryEvaluate()); if (frameSync) { await _waitUntilFrame(() => binding.transientCallbackCount == 0); } @@ -138,12 +138,12 @@ class _LiveWidgetController extends LiveWidgetController { } @override - Future tap(Finder finder, { int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = true }) async { + Future tap(FinderBase finder, { int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = true }) async { await super.tap(await _waitForElement(finder), pointer: pointer, buttons: buttons, warnIfMissed: warnIfMissed); } - Future scrollIntoView(Finder finder, {required double alignment}) async { - final Finder target = await _waitForElement(finder); + Future scrollIntoView(FinderBase finder, {required double alignment}) async { + final FinderBase target = await _waitForElement(finder); await Scrollable.ensureVisible(target.evaluate().single, duration: const Duration(milliseconds: 100), alignment: alignment); } } diff --git a/dev/integration_tests/flutter_gallery/test_memory/image_cache_memory.dart b/dev/integration_tests/flutter_gallery/test_memory/image_cache_memory.dart index 0533a5fca34..ccac85a2ce1 100644 --- a/dev/integration_tests/flutter_gallery/test_memory/image_cache_memory.dart +++ b/dev/integration_tests/flutter_gallery/test_memory/image_cache_memory.dart @@ -56,7 +56,7 @@ Future main() async { do { await controller.drag(list, const Offset(0.0, -30.0)); await Future.delayed(const Duration(milliseconds: 20)); - } while (!lastItem.precache()); + } while (!lastItem.tryEvaluate()); debugPrint('==== MEMORY BENCHMARK ==== DONE ===='); } diff --git a/dev/integration_tests/flutter_gallery/test_memory/memory_nav.dart b/dev/integration_tests/flutter_gallery/test_memory/memory_nav.dart index d83f61f9d3d..5992b64b1aa 100644 --- a/dev/integration_tests/flutter_gallery/test_memory/memory_nav.dart +++ b/dev/integration_tests/flutter_gallery/test_memory/memory_nav.dart @@ -60,7 +60,7 @@ Future main() async { do { await controller.drag(demoList, const Offset(0.0, -300.0)); await Future.delayed(const Duration(milliseconds: 20)); - } while (!demoItem.precache()); + } while (!demoItem.tryEvaluate()); // Ensure that the center of the "Text fields" item is visible // because that's where we're going to tap diff --git a/packages/flutter/test/widgets/list_body_test.dart b/packages/flutter/test/widgets/list_body_test.dart index 6fcc17e1b2c..6ac39df8d32 100644 --- a/packages/flutter/test/widgets/list_body_test.dart +++ b/packages/flutter/test/widgets/list_body_test.dart @@ -15,13 +15,14 @@ const List children = [ void expectRects(WidgetTester tester, List expected) { final Finder finder = find.byType(SizedBox); - finder.precache(); final List actual = []; - for (int i = 0; i < expected.length; ++i) { - final Finder current = finder.at(i); - expect(current, findsOneWidget); - actual.add(tester.getRect(finder.at(i))); - } + finder.runCached(() { + for (int i = 0; i < expected.length; ++i) { + final Finder current = finder.at(i); + expect(current, findsOneWidget); + actual.add(tester.getRect(finder.at(i))); + } + }); expect(() => finder.at(expected.length), throwsRangeError); expect(actual, equals(expected)); } diff --git a/packages/flutter_test/lib/flutter_test.dart b/packages/flutter_test/lib/flutter_test.dart index 642f6973114..9d76239f037 100644 --- a/packages/flutter_test/lib/flutter_test.dart +++ b/packages/flutter_test/lib/flutter_test.dart @@ -58,7 +58,6 @@ export 'dart:async' show Future; export 'src/_goldens_io.dart' if (dart.library.html) 'src/_goldens_web.dart'; export 'src/_matchers_io.dart' if (dart.library.html) 'src/_matchers_web.dart'; export 'src/accessibility.dart'; -export 'src/all_elements.dart'; export 'src/animation_sheet.dart'; export 'src/binding.dart'; export 'src/controller.dart'; @@ -83,5 +82,6 @@ export 'src/test_exception_reporter.dart'; export 'src/test_pointer.dart'; export 'src/test_text_input.dart'; export 'src/test_vsync.dart'; +export 'src/tree_traversal.dart'; export 'src/widget_tester.dart'; export 'src/window.dart'; diff --git a/packages/flutter_test/lib/src/all_elements.dart b/packages/flutter_test/lib/src/all_elements.dart deleted file mode 100644 index 76e62eb94b0..00000000000 --- a/packages/flutter_test/lib/src/all_elements.dart +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright 2014 The Flutter 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/foundation.dart'; -import 'package:flutter/widgets.dart'; - -/// Provides an iterable that efficiently returns all the elements -/// rooted at the given element. See [CachingIterable] for details. -/// -/// This method must be called again if the tree changes. You cannot -/// call this function once, then reuse the iterable after having -/// changed the state of the tree, because the iterable returned by -/// this function caches the results and only walks the tree once. -/// -/// The same applies to any iterable obtained indirectly through this -/// one, for example the results of calling `where` on this iterable -/// are also cached. -Iterable collectAllElementsFrom( - Element rootElement, { - required bool skipOffstage, -}) { - return CachingIterable(_DepthFirstChildIterator(rootElement, skipOffstage)); -} - -/// Provides a recursive, efficient, depth first search of an element tree. -/// -/// [Element.visitChildren] does not guarantee order, but does guarantee stable -/// order. This iterator also guarantees stable order, and iterates in a left -/// to right order: -/// -/// 1 -/// / \ -/// 2 3 -/// / \ / \ -/// 4 5 6 7 -/// -/// Will iterate in order 2, 4, 5, 3, 6, 7. -/// -/// Performance is important here because this method is on the critical path -/// for flutter_driver and package:integration_test performance tests. -/// Performance is measured in the all_elements_bench microbenchmark. -/// Any changes to this implementation should check the before and after numbers -/// on that benchmark to avoid regressions in general performance test overhead. -/// -/// If we could use RTL order, we could save on performance, but numerous tests -/// have been written (and developers clearly expect) that LTR order will be -/// respected. -class _DepthFirstChildIterator implements Iterator { - _DepthFirstChildIterator(Element rootElement, this.skipOffstage) { - _fillChildren(rootElement); - } - - final bool skipOffstage; - - late Element _current; - - final List _stack = []; - - @override - Element get current => _current; - - @override - bool moveNext() { - if (_stack.isEmpty) { - return false; - } - - _current = _stack.removeLast(); - _fillChildren(_current); - - return true; - } - - void _fillChildren(Element element) { - // If we did not have to follow LTR order and could instead use RTL, - // we could avoid reversing this and the operation would be measurably - // faster. Unfortunately, a lot of tests depend on LTR order. - final List reversed = []; - if (skipOffstage) { - element.debugVisitOnstageChildren(reversed.add); - } else { - element.visitChildren(reversed.add); - } - // This is faster than _stack.addAll(reversed.reversed), presumably since - // we don't actually care about maintaining an iteration pointer. - while (reversed.isNotEmpty) { - _stack.add(reversed.removeLast()); - } - } -} diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart index 3a89d3cdaf9..e1f668de245 100644 --- a/packages/flutter_test/lib/src/controller.dart +++ b/packages/flutter_test/lib/src/controller.dart @@ -9,11 +9,11 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; -import 'all_elements.dart'; import 'event_simulation.dart'; import 'finders.dart'; import 'test_async_utils.dart'; import 'test_pointer.dart'; +import 'tree_traversal.dart'; import 'window.dart'; /// The default drag touch slop used to break up a large drag into multiple @@ -74,7 +74,7 @@ class SemanticsController { /// /// Will throw a [StateError] if the finder returns more than one element or /// if no semantics are found or are not enabled. - SemanticsNode find(Finder finder) { + SemanticsNode find(FinderBase finder) { TestAsyncUtils.guardSync(); if (!_controller.binding.semanticsEnabled) { throw StateError('Semantics are not enabled.'); @@ -149,7 +149,7 @@ class SemanticsController { /// parts of the traversal. /// * [orderedEquals], which can be given an [Iterable] to exactly /// match the order of the traversal. - Iterable simulatedAccessibilityTraversal({Finder? start, Finder? end, FlutterView? view}) { + Iterable simulatedAccessibilityTraversal({FinderBase? start, FinderBase? end, FlutterView? view}) { TestAsyncUtils.guardSync(); FlutterView? startView; FlutterView? endView; @@ -158,7 +158,7 @@ class SemanticsController { if (view != null && startView != view) { throw StateError( 'The start node is not part of the provided view.\n' - 'Finder: ${start.description}\n' + 'Finder: ${start.toString(describeSelf: true)}\n' 'View of start node: $startView\n' 'Specified view: $view' ); @@ -169,7 +169,7 @@ class SemanticsController { if (view != null && endView != view) { throw StateError( 'The end node is not part of the provided view.\n' - 'Finder: ${end.description}\n' + 'Finder: ${end.toString(describeSelf: true)}\n' 'View of end node: $endView\n' 'Specified view: $view' ); @@ -178,8 +178,8 @@ class SemanticsController { if (endView != null && startView != null && endView != startView) { throw StateError( 'The start and end node are in different views.\n' - 'Start finder: ${start!.description}\n' - 'End finder: ${end!.description}\n' + 'Start finder: ${start!.toString(describeSelf: true)}\n' + 'End finder: ${end!.toString(describeSelf: true)}\n' 'View of start node: $startView\n' 'View of end node: $endView' ); @@ -200,7 +200,7 @@ class SemanticsController { if (startIndex == -1) { throw StateError( 'The expected starting node was not found.\n' - 'Finder: ${start.description}\n\n' + 'Finder: ${start.toString(describeSelf: true)}\n\n' 'Expected Start Node: $startNode\n\n' 'Traversal: [\n ${traversal.join('\n ')}\n]'); } @@ -212,7 +212,7 @@ class SemanticsController { if (endIndex == -1) { throw StateError( 'The expected ending node was not found.\n' - 'Finder: ${end.description}\n\n' + 'Finder: ${end.toString(describeSelf: true)}\n\n' 'Expected End Node: $endNode\n\n' 'Traversal: [\n ${traversal.join('\n ')}\n]'); } @@ -342,11 +342,11 @@ abstract class WidgetController { /// /// * [view] which returns the [TestFlutterView] used when only a single /// view is being used. - TestFlutterView viewOf(Finder finder) { + TestFlutterView viewOf(FinderBase finder) { return _viewOf(finder) as TestFlutterView; } - FlutterView _viewOf(Finder finder) { + FlutterView _viewOf(FinderBase finder) { return firstWidget( find.ancestor( of: finder, @@ -356,7 +356,7 @@ abstract class WidgetController { } /// Checks if `finder` exists in the tree. - bool any(Finder finder) { + bool any(FinderBase finder) { TestAsyncUtils.guardSync(); return finder.evaluate().isNotEmpty; } @@ -377,7 +377,7 @@ abstract class WidgetController { /// /// * 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. - T widget(Finder finder) { + T widget(FinderBase finder) { TestAsyncUtils.guardSync(); return finder.evaluate().single.widget as T; } @@ -388,7 +388,7 @@ abstract class WidgetController { /// Throws a [StateError] if `finder` is empty. /// /// * Use [widget] if you only expect to match one widget. - T firstWidget(Finder finder) { + T firstWidget(FinderBase finder) { TestAsyncUtils.guardSync(); return finder.evaluate().first.widget as T; } @@ -397,7 +397,7 @@ abstract class WidgetController { /// /// * 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) { + Iterable widgetList(FinderBase finder) { TestAsyncUtils.guardSync(); return finder.evaluate().map((Element element) { final T result = element.widget as T; @@ -408,7 +408,7 @@ abstract class WidgetController { /// Find all layers that are children of the provided [finder]. /// /// The [finder] must match exactly one element. - Iterable layerListOf(Finder finder) { + Iterable layerListOf(FinderBase finder) { TestAsyncUtils.guardSync(); final Element element = finder.evaluate().single; final RenderObject object = element.renderObject!; @@ -437,7 +437,7 @@ abstract class WidgetController { /// /// * 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. - T element(Finder finder) { + T element(FinderBase finder) { TestAsyncUtils.guardSync(); return finder.evaluate().single as T; } @@ -448,7 +448,7 @@ abstract class WidgetController { /// Throws a [StateError] if `finder` is empty. /// /// * Use [element] if you only expect to match one element. - T firstElement(Finder finder) { + T firstElement(FinderBase finder) { TestAsyncUtils.guardSync(); return finder.evaluate().first as T; } @@ -457,7 +457,7 @@ abstract class WidgetController { /// /// * 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) { + Iterable elementList(FinderBase finder) { TestAsyncUtils.guardSync(); return finder.evaluate().cast(); } @@ -479,7 +479,7 @@ abstract class WidgetController { /// /// * 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. - T state(Finder finder) { + T state(FinderBase finder) { TestAsyncUtils.guardSync(); return _stateOf(finder.evaluate().single, finder); } @@ -491,7 +491,7 @@ abstract class WidgetController { /// matching widget has no state. /// /// * Use [state] if you only expect to match one state. - T firstState(Finder finder) { + T firstState(FinderBase finder) { TestAsyncUtils.guardSync(); return _stateOf(finder.evaluate().first, finder); } @@ -503,17 +503,17 @@ abstract class WidgetController { /// /// * 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) { + Iterable stateList(FinderBase finder) { TestAsyncUtils.guardSync(); return finder.evaluate().map((Element element) => _stateOf(element, finder)); } - T _stateOf(Element element, Finder finder) { + T _stateOf(Element element, FinderBase finder) { TestAsyncUtils.guardSync(); if (element is StatefulElement) { return element.state as T; } - throw StateError('Widget of type ${element.widget.runtimeType}, with ${finder.description}, is not a StatefulWidget.'); + throw StateError('Widget of type ${element.widget.runtimeType}, with ${finder.describeMatch(Plurality.many)}, is not a StatefulWidget.'); } /// Render objects of all the widgets currently in the widget tree @@ -535,7 +535,7 @@ abstract class WidgetController { /// /// * 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. - T renderObject(Finder finder) { + T renderObject(FinderBase finder) { TestAsyncUtils.guardSync(); return finder.evaluate().single.renderObject! as T; } @@ -546,7 +546,7 @@ abstract class WidgetController { /// Throws a [StateError] if `finder` is empty. /// /// * Use [renderObject] if you only expect to match one render object. - T firstRenderObject(Finder finder) { + T firstRenderObject(FinderBase finder) { TestAsyncUtils.guardSync(); return finder.evaluate().first.renderObject! as T; } @@ -555,7 +555,7 @@ abstract class WidgetController { /// /// * 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) { + Iterable renderObjectList(FinderBase finder) { TestAsyncUtils.guardSync(); return finder.evaluate().map((Element element) { final T result = element.renderObject! as T; @@ -603,7 +603,7 @@ abstract class WidgetController { /// For example, a test that verifies that tapping a disabled button does not /// trigger the button would set `warnIfMissed` to false, because the button /// would ignore the tap. - Future tap(Finder finder, {int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = true}) { + Future tap(FinderBase finder, {int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = true}) { return tapAt(getCenter(finder, warnIfMissed: warnIfMissed, callee: 'tap'), pointer: pointer, buttons: buttons); } @@ -628,7 +628,7 @@ abstract class WidgetController { /// * [tap], which presses and releases a pointer at the given location. /// * [longPress], which presses and releases a pointer with a gap in /// between long enough to trigger the long-press gesture. - Future press(Finder finder, {int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = true}) { + Future press(FinderBase finder, {int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = true}) { return TestAsyncUtils.guard(() { return startGesture(getCenter(finder, warnIfMissed: warnIfMissed, callee: 'press'), pointer: pointer, buttons: buttons); }); @@ -646,7 +646,7 @@ abstract class WidgetController { /// later verify that long-pressing the same location (using the same finder) /// has no effect (since the widget is now obscured), setting `warnIfMissed` /// to false on that second call. - Future longPress(Finder finder, {int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = true}) { + Future longPress(FinderBase finder, {int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = true}) { return longPressAt(getCenter(finder, warnIfMissed: warnIfMissed, callee: 'longPress'), pointer: pointer, buttons: buttons); } @@ -707,7 +707,7 @@ abstract class WidgetController { /// A fling is essentially a drag that ends at a particular speed. If you /// just want to drag and end without a fling, use [drag]. Future fling( - Finder finder, + FinderBase finder, Offset offset, double speed, { int? pointer, @@ -787,7 +787,7 @@ abstract class WidgetController { /// A fling is essentially a drag that ends at a particular speed. If you /// just want to drag and end without a fling, use [drag]. Future trackpadFling( - Finder finder, + FinderBase finder, Offset offset, double speed, { int? pointer, @@ -952,7 +952,7 @@ abstract class WidgetController { /// should be left to their default values. /// {@endtemplate} Future drag( - Finder finder, + FinderBase finder, Offset offset, { int? pointer, int buttons = kPrimaryButton, @@ -1085,7 +1085,7 @@ abstract class WidgetController { /// more accurate time control. /// {@endtemplate} Future timedDrag( - Finder finder, + FinderBase finder, Offset offset, Duration duration, { int? pointer, @@ -1282,14 +1282,14 @@ abstract class WidgetController { /// this method is being called from another that is forwarding its own /// `warnIfMissed` parameter (see e.g. the implementation of [tap]). /// {@endtemplate} - Offset getCenter(Finder finder, { bool warnIfMissed = false, String callee = 'getCenter' }) { + Offset getCenter(FinderBase finder, { bool warnIfMissed = false, String callee = 'getCenter' }) { return _getElementPoint(finder, (Size size) => size.center(Offset.zero), warnIfMissed: warnIfMissed, callee: callee); } /// Returns the point at the top left of the given widget. /// /// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed} - Offset getTopLeft(Finder finder, { bool warnIfMissed = false, String callee = 'getTopLeft' }) { + Offset getTopLeft(FinderBase finder, { bool warnIfMissed = false, String callee = 'getTopLeft' }) { return _getElementPoint(finder, (Size size) => Offset.zero, warnIfMissed: warnIfMissed, callee: callee); } @@ -1297,7 +1297,7 @@ abstract class WidgetController { /// point is not inside the object's hit test area. /// /// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed} - Offset getTopRight(Finder finder, { bool warnIfMissed = false, String callee = 'getTopRight' }) { + Offset getTopRight(FinderBase finder, { bool warnIfMissed = false, String callee = 'getTopRight' }) { return _getElementPoint(finder, (Size size) => size.topRight(Offset.zero), warnIfMissed: warnIfMissed, callee: callee); } @@ -1305,7 +1305,7 @@ abstract class WidgetController { /// point is not inside the object's hit test area. /// /// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed} - Offset getBottomLeft(Finder finder, { bool warnIfMissed = false, String callee = 'getBottomLeft' }) { + Offset getBottomLeft(FinderBase finder, { bool warnIfMissed = false, String callee = 'getBottomLeft' }) { return _getElementPoint(finder, (Size size) => size.bottomLeft(Offset.zero), warnIfMissed: warnIfMissed, callee: callee); } @@ -1313,7 +1313,7 @@ abstract class WidgetController { /// point is not inside the object's hit test area. /// /// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed} - Offset getBottomRight(Finder finder, { bool warnIfMissed = false, String callee = 'getBottomRight' }) { + Offset getBottomRight(FinderBase finder, { bool warnIfMissed = false, String callee = 'getBottomRight' }) { return _getElementPoint(finder, (Size size) => size.bottomRight(Offset.zero), warnIfMissed: warnIfMissed, callee: callee); } @@ -1340,7 +1340,7 @@ abstract class WidgetController { /// in the documentation for the [flutter_test] library. static bool hitTestWarningShouldBeFatal = false; - Offset _getElementPoint(Finder finder, Offset Function(Size size) sizeToPoint, { required bool warnIfMissed, required String callee }) { + Offset _getElementPoint(FinderBase finder, Offset Function(Size size) sizeToPoint, { required bool warnIfMissed, required String callee }) { TestAsyncUtils.guardSync(); final Iterable elements = finder.evaluate(); if (elements.isEmpty) { @@ -1411,7 +1411,7 @@ abstract class WidgetController { /// Returns the size of the given widget. This is only valid once /// the widget's render object has been laid out at least once. - Size getSize(Finder finder) { + Size getSize(FinderBase finder) { TestAsyncUtils.guardSync(); final Element element = finder.evaluate().single; final RenderBox box = element.renderObject! as RenderBox; @@ -1579,7 +1579,7 @@ abstract class WidgetController { /// Returns the rect of the given widget. This is only valid once /// the widget's render object has been laid out at least once. - Rect getRect(Finder finder) => Rect.fromPoints(getTopLeft(finder), getBottomRight(finder)); + Rect getRect(FinderBase finder) => Rect.fromPoints(getTopLeft(finder), getBottomRight(finder)); /// Attempts to find the [SemanticsNode] of first result from `finder`. /// @@ -1596,7 +1596,7 @@ abstract class WidgetController { /// Will throw a [StateError] if the finder returns more than one element or /// if no semantics are found or are not enabled. // TODO(pdblasi-google): Deprecate this and point references to semantics.find. See https://github.com/flutter/flutter/issues/112670. - SemanticsNode getSemantics(Finder finder) => semantics.find(finder); + SemanticsNode getSemantics(FinderBase finder) => semantics.find(finder); /// Enable semantics in a test by creating a [SemanticsHandle]. /// @@ -1620,7 +1620,7 @@ abstract class WidgetController { /// /// * [Scrollable.ensureVisible], which is the production API used to /// implement this method. - Future ensureVisible(Finder finder) => Scrollable.ensureVisible(element(finder)); + Future ensureVisible(FinderBase finder) => Scrollable.ensureVisible(element(finder)); /// Repeatedly scrolls a [Scrollable] by `delta` in the /// [Scrollable.axisDirection] direction until a widget matching `finder` is @@ -1645,9 +1645,9 @@ abstract class WidgetController { /// /// * [dragUntilVisible], which implements the body of this method. Future scrollUntilVisible( - Finder finder, + FinderBase finder, double delta, { - Finder? scrollable, + FinderBase? scrollable, int maxScrolls = 50, Duration duration = const Duration(milliseconds: 50), } @@ -1688,8 +1688,8 @@ abstract class WidgetController { /// * [scrollUntilVisible], which wraps this method with an API that is more /// convenient when dealing with a [Scrollable]. Future dragUntilVisible( - Finder finder, - Finder view, + FinderBase finder, + FinderBase view, Offset moveStep, { int maxIteration = 50, Duration duration = const Duration(milliseconds: 50), diff --git a/packages/flutter_test/lib/src/finders.dart b/packages/flutter_test/lib/src/finders.dart index a79a5575c25..4cff6b0d6e9 100644 --- a/packages/flutter_test/lib/src/finders.dart +++ b/packages/flutter_test/lib/src/finders.dart @@ -2,11 +2,14 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui'; + import 'package:flutter/material.dart' show Tooltip; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; -import 'all_elements.dart'; +import 'binding.dart'; +import 'tree_traversal.dart'; /// Signature for [CommonFinders.byWidgetPredicate]. typedef WidgetPredicate = bool Function(Widget widget); @@ -14,7 +17,13 @@ typedef WidgetPredicate = bool Function(Widget widget); /// Signature for [CommonFinders.byElementPredicate]. typedef ElementPredicate = bool Function(Element element); -/// Some frequently used widget [Finder]s. +/// Signature for [CommonSemanticsFinders.byPredicate]. +typedef SemanticsNodePredicate = bool Function(SemanticsNode node); + +/// Signature for [FinderBase.describeMatch]. +typedef DescribeMatchCallback = String Function(Plurality plurality); + +/// Some frequently used [Finder]s and [SemanticsFinder]s. const CommonFinders find = CommonFinders._(); // Examples can assume: @@ -23,12 +32,16 @@ const CommonFinders find = CommonFinders._(); // late String filePath; // late Key backKey; -/// Provides lightweight syntax for getting frequently used widget [Finder]s. +/// Provides lightweight syntax for getting frequently used [Finder]s and +/// [SemanticsFinder]s through [semantics]. /// /// This class is instantiated once, as [find]. class CommonFinders { const CommonFinders._(); + /// Some frequently used semantics finders. + CommonSemanticsFinders get semantics => const CommonSemanticsFinders._(); + /// Finds [Text], [EditableText], and optionally [RichText] widgets /// containing string equal to the `text` argument. /// @@ -64,7 +77,7 @@ class CommonFinders { bool findRichText = false, bool skipOffstage = true, }) { - return _TextFinder( + return _TextWidgetFinder( text, findRichText: findRichText, skipOffstage: skipOffstage, @@ -108,7 +121,7 @@ class CommonFinders { bool findRichText = false, bool skipOffstage = true, }) { - return _TextContainingFinder( + return _TextContainingWidgetFinder( pattern, findRichText: findRichText, skipOffstage: skipOffstage @@ -121,12 +134,12 @@ class CommonFinders { /// ## Sample code /// /// ```dart - /// // Suppose you have a button with text 'Update' in it: + /// // Suppose there is a button with text 'Update' in it: /// const Button( /// child: Text('Update') /// ); /// - /// // You can find and tap on it like this: + /// // It can be found and tapped like this: /// tester.tap(find.widgetWithText(Button, 'Update')); /// ``` /// @@ -150,9 +163,9 @@ class CommonFinders { /// /// If the `skipOffstage` argument is true (the default), then this skips /// nodes that are [Offstage] or that are from inactive [Route]s. - Finder image(ImageProvider image, { bool skipOffstage = true }) => _WidgetImageFinder(image, skipOffstage: skipOffstage); + Finder image(ImageProvider image, { bool skipOffstage = true }) => _ImageWidgetFinder(image, skipOffstage: skipOffstage); - /// Finds widgets by searching for one with a particular [Key]. + /// Finds widgets by searching for one with the given `key`. /// /// ## Sample code /// @@ -162,7 +175,7 @@ class CommonFinders { /// /// If the `skipOffstage` argument is true (the default), then this skips /// nodes that are [Offstage] or that are from inactive [Route]s. - Finder byKey(Key key, { bool skipOffstage = true }) => _KeyFinder(key, skipOffstage: skipOffstage); + Finder byKey(Key key, { bool skipOffstage = true }) => _KeyWidgetFinder(key, skipOffstage: skipOffstage); /// Finds widgets by searching for widgets implementing a particular type. /// @@ -180,13 +193,13 @@ class CommonFinders { /// /// See also: /// * [byType], which does not do subtype tests. - Finder bySubtype({ bool skipOffstage = true }) => _WidgetSubtypeFinder(skipOffstage: skipOffstage); + Finder bySubtype({ bool skipOffstage = true }) => _SubtypeWidgetFinder(skipOffstage: skipOffstage); /// Finds widgets by searching for widgets with a particular type. /// /// This does not do subclass tests, so for example - /// `byType(StatefulWidget)` will never find anything since that's - /// an abstract class. + /// `byType(StatefulWidget)` will never find anything since [StatefulWidget] + /// is an abstract class. /// /// The `type` argument must be a subclass of [Widget]. /// @@ -201,7 +214,7 @@ class CommonFinders { /// /// See also: /// * [bySubtype], which allows subtype tests. - Finder byType(Type type, { bool skipOffstage = true }) => _WidgetTypeFinder(type, skipOffstage: skipOffstage); + Finder byType(Type type, { bool skipOffstage = true }) => _TypeWidgetFinder(type, skipOffstage: skipOffstage); /// Finds [Icon] widgets containing icon data equal to the `icon` /// argument. @@ -214,7 +227,7 @@ class CommonFinders { /// /// If the `skipOffstage` argument is true (the default), then this skips /// nodes that are [Offstage] or that are from inactive [Route]s. - Finder byIcon(IconData icon, { bool skipOffstage = true }) => _WidgetIconFinder(icon, skipOffstage: skipOffstage); + Finder byIcon(IconData icon, { bool skipOffstage = true }) => _IconWidgetFinder(icon, skipOffstage: skipOffstage); /// Looks for widgets that contain an [Icon] descendant displaying [IconData] /// `icon` in it. @@ -222,12 +235,12 @@ class CommonFinders { /// ## Sample code /// /// ```dart - /// // Suppose you have a button with icon 'arrow_forward' in it: + /// // Suppose there is a button with icon 'arrow_forward' in it: /// const Button( /// child: Icon(Icons.arrow_forward) /// ); /// - /// // You can find and tap on it like this: + /// // It can be found and tapped like this: /// tester.tap(find.widgetWithIcon(Button, Icons.arrow_forward)); /// ``` /// @@ -240,18 +253,18 @@ class CommonFinders { ); } - /// Looks for widgets that contain an [Image] descendant displaying [ImageProvider] - /// `image` in it. + /// Looks for widgets that contain an [Image] descendant displaying + /// [ImageProvider] `image` in it. /// /// ## Sample code /// /// ```dart - /// // Suppose you have a button with image in it: + /// // Suppose there is a button with an image in it: /// Button( /// child: Image.file(File(filePath)) /// ); /// - /// // You can find and tap on it like this: + /// // It can be found and tapped like this: /// tester.tap(find.widgetWithImage(Button, FileImage(File(filePath)))); /// ``` /// @@ -268,7 +281,7 @@ class CommonFinders { /// /// This does not do subclass tests, so for example /// `byElementType(VirtualViewportElement)` will never find anything - /// since that's an abstract class. + /// since [RenderObjectElement] is an abstract class. /// /// The `type` argument must be a subclass of [Element]. /// @@ -280,39 +293,39 @@ class CommonFinders { /// /// If the `skipOffstage` argument is true (the default), then this skips /// nodes that are [Offstage] or that are from inactive [Route]s. - Finder byElementType(Type type, { bool skipOffstage = true }) => _ElementTypeFinder(type, skipOffstage: skipOffstage); + Finder byElementType(Type type, { bool skipOffstage = true }) => _ElementTypeWidgetFinder(type, skipOffstage: skipOffstage); - /// Finds widgets whose current widget is the instance given by the + /// Finds widgets whose current widget is the instance given by the `widget` /// argument. /// /// ## Sample code /// /// ```dart - /// // Suppose you have a button created like this: + /// // Suppose there is a button created like this: /// Widget myButton = const Button( /// child: Text('Update') /// ); /// - /// // You can find and tap on it like this: + /// // It can be found and tapped like this: /// tester.tap(find.byWidget(myButton)); /// ``` /// /// If the `skipOffstage` argument is true (the default), then this skips /// nodes that are [Offstage] or that are from inactive [Route]s. - Finder byWidget(Widget widget, { bool skipOffstage = true }) => _WidgetFinder(widget, skipOffstage: skipOffstage); + Finder byWidget(Widget widget, { bool skipOffstage = true }) => _ExactWidgetFinder(widget, skipOffstage: skipOffstage); - /// Finds widgets using a widget [predicate]. + /// Finds widgets using a widget `predicate`. /// /// ## Sample code /// /// ```dart /// expect(find.byWidgetPredicate( /// (Widget widget) => widget is Tooltip && widget.message == 'Back', - /// description: 'widget with tooltip "Back"', + /// description: 'with tooltip "Back"', /// ), findsOneWidget); /// ``` /// - /// If [description] is provided, then this uses it as the description of the + /// If `description` is provided, then this uses it as the description of the /// [Finder] and appears, for example, in the error message when the finder /// fails to locate the desired widget. Otherwise, the description prints the /// signature of the predicate function. @@ -320,10 +333,10 @@ class CommonFinders { /// If the `skipOffstage` argument is true (the default), then this skips /// nodes that are [Offstage] or that are from inactive [Route]s. Finder byWidgetPredicate(WidgetPredicate predicate, { String? description, bool skipOffstage = true }) { - return _WidgetPredicateFinder(predicate, description: description, skipOffstage: skipOffstage); + return _WidgetPredicateWidgetFinder(predicate, description: description, skipOffstage: skipOffstage); } - /// Finds Tooltip widgets with the given message. + /// Finds [Tooltip] widgets with the given `message`. /// /// ## Sample code /// @@ -340,13 +353,13 @@ class CommonFinders { ); } - /// Finds widgets using an element [predicate]. + /// Finds widgets using an element `predicate`. /// /// ## Sample code /// /// ```dart /// expect(find.byElementPredicate( - /// // finds elements of type SingleChildRenderObjectElement, including + /// // Finds elements of type SingleChildRenderObjectElement, including /// // those that are actually subclasses of that type. /// // (contrast with byElementType, which only returns exact matches) /// (Element element) => element is SingleChildRenderObjectElement, @@ -354,7 +367,7 @@ class CommonFinders { /// ), findsOneWidget); /// ``` /// - /// If [description] is provided, then this uses it as the description of the + /// If `description` is provided, then this uses it as the description of the /// [Finder] and appears, for example, in the error message when the finder /// fails to locate the desired widget. Otherwise, the description prints the /// signature of the predicate function. @@ -362,36 +375,37 @@ class CommonFinders { /// If the `skipOffstage` argument is true (the default), then this skips /// nodes that are [Offstage] or that are from inactive [Route]s. Finder byElementPredicate(ElementPredicate predicate, { String? description, bool skipOffstage = true }) { - return _ElementPredicateFinder(predicate, description: description, skipOffstage: skipOffstage); + return _ElementPredicateWidgetFinder(predicate, description: description, skipOffstage: skipOffstage); } - /// Finds widgets that are descendants of the [of] parameter and that match - /// the [matching] parameter. + /// Finds widgets that are descendants of the `of` parameter and that match + /// the `matching` parameter. /// /// ## Sample code /// /// ```dart /// expect(find.descendant( - /// of: find.widgetWithText(Row, 'label_1'), matching: find.text('value_1') + /// of: find.widgetWithText(Row, 'label_1'), + /// matching: find.text('value_1'), /// ), findsOneWidget); /// ``` /// - /// If the [matchRoot] argument is true then the widget(s) specified by [of] + /// If the `matchRoot` argument is true then the widget(s) specified by `of` /// will be matched along with the descendants. /// - /// If the [skipOffstage] argument is true (the default), then nodes that are + /// If the `skipOffstage` argument is true (the default), then nodes that are /// [Offstage] or that are from inactive [Route]s are skipped. Finder descendant({ - required Finder of, - required Finder matching, + required FinderBase of, + required FinderBase matching, bool matchRoot = false, bool skipOffstage = true, }) { - return _DescendantFinder(of, matching, matchRoot: matchRoot, skipOffstage: skipOffstage); + return _DescendantWidgetFinder(of, matching, matchRoot: matchRoot, skipOffstage: skipOffstage); } - /// Finds widgets that are ancestors of the [of] parameter and that match - /// the [matching] parameter. + /// Finds widgets that are ancestors of the `of` parameter and that match + /// the `matching` parameter. /// /// ## Sample code /// @@ -409,14 +423,14 @@ class CommonFinders { /// ); /// ``` /// - /// If the [matchRoot] argument is true then the widget(s) specified by [of] + /// If the `matchRoot` argument is true then the widget(s) specified by `of` /// will be matched along with the ancestors. Finder ancestor({ - required Finder of, - required Finder matching, + required FinderBase of, + required FinderBase matching, bool matchRoot = false, }) { - return _AncestorFinder(of, matching, matchRoot: matchRoot); + return _AncestorWidgetFinder(of, matching, matchLeaves: matchRoot); } /// Finds [Semantics] widgets matching the given `label`, either by @@ -466,25 +480,476 @@ class CommonFinders { } } -/// Searches a widget tree and returns nodes that match a particular -/// pattern. -abstract class Finder { - /// Initializes a Finder. Used by subclasses to initialize the [skipOffstage] - /// property. - Finder({ this.skipOffstage = true }); + +/// Provides lightweight syntax for getting frequently used semantics finders. +/// +/// This class is instantiated once, as [CommonFinders.semantics], under [find]. +class CommonSemanticsFinders { + const CommonSemanticsFinders._(); + + /// Finds an ancestor of `of` that matches `matching`. + /// + /// If `matchRoot` is true, then the results of `of` are included in the + /// search and results. + FinderBase ancestor({ + required FinderBase of, + required FinderBase matching, + bool matchRoot = false, + }) { + return _AncestorSemanticsFinder(of, matching, matchRoot); + } + + /// Finds a descendant of `of` that matches `matching`. + /// + /// If `matchRoot` is true, then the results of `of` are included in the + /// search and results. + FinderBase descendant({ + required FinderBase of, + required FinderBase matching, + bool matchRoot = false, + }) { + return _DescendantSemanticsFinder(of, matching, matchRoot: matchRoot); + } + + /// Finds any [SemanticsNode]s matching the given `predicate`. + /// + /// If `describeMatch` is provided, it will be used to describe the + /// [FinderBase] and [FinderResult]s. + /// {@macro flutter_test.finders.FinderBase.describeMatch} + /// + /// {@template flutter_test.finders.CommonSemanticsFinders.viewParameter} + /// The `view` provided will be used to determine the semantics tree where + /// the search will be evaluated. If not provided, the search will be + /// evaluated against the semantics tree of [WidgetTester.view]. + /// {@endtemplate} + SemanticsFinder byPredicate( + SemanticsNodePredicate predicate, { + DescribeMatchCallback? describeMatch, + FlutterView? view, + }) { + return _PredicateSemanticsFinder( + predicate, + describeMatch, + _rootFromView(view), + ); + } + + /// Finds any [SemanticsNode]s that has a [SemanticsNode.label] that matches + /// the given `label`. + /// + /// {@macro flutter_test.finders.CommonSemanticsFinders.viewParameter} + SemanticsFinder byLabel(Pattern label, {FlutterView? view}) { + return byPredicate( + (SemanticsNode node) => _matchesPattern(node.label, label), + describeMatch: (Plurality plurality) => '${switch (plurality) { + Plurality.one => 'SemanticsNode', + Plurality.zero || Plurality.many => 'SemanticsNodes', + }} with label "$label"', + view: view, + ); + } + + /// Finds any [SemanticsNode]s that has a [SemanticsNode.value] that matches + /// the given `value`. + /// + /// {@macro flutter_test.finders.CommonSemanticsFinders.viewParameter} + SemanticsFinder byValue(Pattern value, {FlutterView? view}) { + return byPredicate( + (SemanticsNode node) => _matchesPattern(node.value, value), + describeMatch: (Plurality plurality) => '${switch (plurality) { + Plurality.one => 'SemanticsNode', + Plurality.zero || Plurality.many => 'SemanticsNodes', + }} with value "$value"', + view: view, + ); + } + + /// Finds any [SemanticsNode]s that has a [SemanticsNode.hint] that matches + /// the given `hint`. + /// + /// {@macro flutter_test.finders.CommonSemanticsFinders.viewParameter} + SemanticsFinder byHint(Pattern hint, {FlutterView? view}) { + return byPredicate( + (SemanticsNode node) => _matchesPattern(node.hint, hint), + describeMatch: (Plurality plurality) => '${switch (plurality) { + Plurality.one => 'SemanticsNode', + Plurality.zero || Plurality.many => 'SemanticsNodes', + }} with hint "$hint"', + view: view, + ); + } + + /// Finds any [SemanticsNode]s that has the given [SemanticsAction]. + /// + /// {@macro flutter_test.finders.CommonSemanticsFinders.viewParameter} + SemanticsFinder byAction(SemanticsAction action, {FlutterView? view}) { + return byPredicate( + (SemanticsNode node) => node.getSemanticsData().hasAction(action), + describeMatch: (Plurality plurality) => '${switch (plurality) { + Plurality.one => 'SemanticsNode', + Plurality.zero || Plurality.many => 'SemanticsNodes', + }} with action "$action"', + view: view, + ); + } + + /// Finds any [SemanticsNode]s that has at least one of the given + /// [SemanticsAction]s. + /// + /// {@macro flutter_test.finders.CommonSemanticsFinders.viewParameter} + SemanticsFinder byAnyAction(List actions, {FlutterView? view}) { + final int actionsInt = actions.fold(0, (int value, SemanticsAction action) => value | action.index); + return byPredicate( + (SemanticsNode node) => node.getSemanticsData().actions & actionsInt != 0, + describeMatch: (Plurality plurality) => '${switch (plurality) { + Plurality.one => 'SemanticsNode', + Plurality.zero || Plurality.many => 'SemanticsNodes', + }} with any of the following actions: $actions', + view: view, + ); + } + + /// Finds any [SemanticsNode]s that has the given [SemanticsFlag]. + /// + /// {@macro flutter_test.finders.CommonSemanticsFinders.viewParameter} + SemanticsFinder byFlag(SemanticsFlag flag, {FlutterView? view}) { + return byPredicate( + (SemanticsNode node) => node.hasFlag(flag), + describeMatch: (Plurality plurality) => '${switch (plurality) { + Plurality.one => 'SemanticsNode', + Plurality.zero || Plurality.many => 'SemanticsNodes', + }} with flag "$flag"', + view: view, + ); + } + + /// Finds any [SemanticsNode]s that has at least one of the given + /// [SemanticsFlag]s. + /// + /// {@macro flutter_test.finders.CommonSemanticsFinders.viewParameter} + SemanticsFinder byAnyFlag(List flags, {FlutterView? view}) { + final int flagsInt = flags.fold(0, (int value, SemanticsFlag flag) => value | flag.index); + return byPredicate( + (SemanticsNode node) => node.getSemanticsData().flags & flagsInt != 0, + describeMatch: (Plurality plurality) => '${switch (plurality) { + Plurality.one => 'SemanticsNode', + Plurality.zero || Plurality.many => 'SemanticsNodes', + }} with any of the following flags: $flags', + view: view, + ); + } + + bool _matchesPattern(String target, Pattern pattern) { + if (pattern is RegExp) { + return pattern.hasMatch(target); + } else { + return pattern == target; + } + } + + SemanticsNode _rootFromView(FlutterView? view) { + view ??= TestWidgetsFlutterBinding.instance.platformDispatcher.implicitView; + assert(view != null, 'The given view was not available. Ensure WidgetTester.view is available or pass in a specific view using WidgetTester.viewOf.'); + final RenderView renderView = TestWidgetsFlutterBinding.instance.renderViews + .firstWhere((RenderView r) => r.flutterView == view); + + return renderView.owner!.semanticsOwner!.rootSemanticsNode!; + } +} + +/// Describes how a string of text should be pluralized. +enum Plurality { + /// Text should be pluralized to describe zero items. + zero, + /// Text should be pluralized to describe a single item. + one, + /// Text should be pluralized to describe more than one item. + many; + + static Plurality _fromNum(num source) { + assert(source >= 0, 'A Plurality can only be created with a positive number.'); + return switch (source) { + 0 => Plurality.zero, + 1 => Plurality.one, + _ => Plurality.many, + }; + } +} + +/// Encapsulates the logic for searching a list of candidates and filtering the +/// candidates to only those that meet the requirements defined by the finder. +/// +/// Implementations will need to implement [allCandidates] to define the total +/// possible search space and [findInCandidates] to define the requirements of +/// the finder. +/// +/// This library contains [Finder] and [SemanticsFinder] for searching +/// Flutter's element and semantics trees respectively. +/// +/// If the search can be represented as a predicate, then consider using +/// [MatchFinderMixin] along with the [Finder] or [SemanticsFinder] base class. +/// +/// If the search further filters the results from another finder, consider using +/// [ChainedFinderMixin] along with the [Finder] or [SemanticsFinder] base class. +abstract class FinderBase { + bool _cached = false; + + /// The results of the latest [evaluate] or [tryEvaluate] call. + /// + /// Unlike [evaluate] and [tryEvaluate], [found] will not re-execute the + /// search for this finder. Either [evaluate] or [tryEvaluate] must be called + /// before accessing [found]. + FinderResult get found { + assert( + _found != null, + 'No results have been found yet. ' + 'Either `evaluate` or `tryEvaluate` must be called before accessing `found`', + ); + return _found!; + } + FinderResult? _found; + + /// Whether or not this finder has any results in [found]. + bool get hasFound => _found != null; + + /// Describes zero, one, or more candidates that match the requirements of a + /// finder. + /// + /// {@template flutter_test.finders.FinderBase.describeMatch} + /// The description returned should be a brief English phrase describing a + /// matching candidate with the proper plural form. As an example for a string + /// finder that is looking for strings starting with "hello": + /// + /// ```dart + /// String describeMatch(Plurality plurality) { + /// return switch (plurality) { + /// Plurality.zero || Plurality.many => 'strings starting with "hello"', + /// Plurality.one => 'string starting with "hello"', + /// }; + /// } + /// ``` + /// {@endtemplate} + /// + /// This will be used both to describe a finder and the results of searching + /// with that finder. + /// + /// See also: + /// + /// * [FinderBase.toString] where this is used to fully describe the finder + /// * [FinderResult.toString] where this is used to provide context to the + /// results of a search + String describeMatch(Plurality plurality); + + /// Returns all of the items that will be considered by this finder. + @protected + Iterable get allCandidates; + + /// Returns a variant of this finder that only matches the first item + /// found by this finder. + FinderBase get first => _FirstFinder(this); + + /// Returns a variant of this finder that only matches the last item + /// found by this finder. + FinderBase get last => _LastFinder(this); + + /// Returns a variant of this finder that only matches the item at the + /// given index found by this finder. + FinderBase at(int index) => _IndexFinder(this, index); + + /// Returns all the items in the given list that match this + /// finder's requirements. + /// + /// This is overridden to define the requirements of the finder when + /// implementing finders that directly extend [FinderBase]. If a finder can + /// be efficiently described just in terms of a predicate function, consider + /// mixing in [MatchFinderMixin] and implementing [MatchFinderMixin.matches] + /// instead. + @protected + Iterable findInCandidates(Iterable candidates); + + /// Searches a set of candidates for those that meet the requirements set by + /// this finder and returns the result of that search. + /// + /// See also: + /// + /// * [found] which will return the latest results without re-executing the + /// search. + /// * [tryEvaluate] which will indicate whether any results were found rather + /// than directly returning results. + FinderResult evaluate() { + if (!_cached || _found == null) { + _found = FinderResult(describeMatch, findInCandidates(allCandidates)); + } + return found; + } + + /// Searches a set of candidates for those that meet the requirements set by + /// this finder and returns whether the search found any matching candidates. + /// + /// This is useful in cases where an action needs to be repeated while or + /// until a finder has results. The results from the search can be accessed + /// using the [found] property without re-executing the search. + /// + /// ## Sample code + /// + /// ```dart + /// testWidgets('Top text loads first', (WidgetTester tester) async { + /// // Assume a widget is pumped with a top and bottom loading area, with + /// // the texts "Top loaded" and "Bottom loaded" when loading is complete. + /// // await tester.pumpWidget(...) + /// + /// // Wait until at least one loaded widget is available + /// Finder loadedFinder = find.textContaining('loaded'); + /// while (!loadedFinder.tryEvaluate()) { + /// await tester.pump(const Duration(milliseconds: 100)); + /// } + /// + /// expect(loadedFinder.found, hasLength(1)); + /// expect(tester.widget(loadedFinder).data, contains('Top')); + /// }); + /// ``` + bool tryEvaluate() { + evaluate(); + return found.isNotEmpty; + } + + /// Runs the given callback using cached results. + /// + /// While in this callback, this [FinderBase] will cache the results from the + /// next call to [evaluate] or [tryEvaluate] and then no longer evaluate new results + /// until the callback completes. After the first call, all calls to [evaluate], + /// [tryEvaluate] or [found] will return the same results without evaluating. + void runCached(VoidCallback run) { + reset(); + _cached = true; + try { + run(); + } finally { + reset(); + _cached = false; + } + } + + /// Resets all state of this [FinderBase]. + /// + /// Generally used between tests to reset the state of [found] if a finder is + /// used across multiple tests. + void reset() { + _found = null; + } + + /// A string representation of this finder or its results. + /// + /// By default, this describes the results of the search in order to play + /// nicely with [expect] and its output when a failure occurs. If you wish + /// to get a string representation of the finder itself, pass [describeSelf] + /// as `true`. + @override + String toString({bool describeSelf = false}) { + if (describeSelf) { + return 'A finder that searches for ${describeMatch(Plurality.many)}.'; + } else { + if (!hasFound) { + evaluate(); + } + return found.toString(); + } + } +} + +/// The results of searching with a [FinderBase]. +class FinderResult extends Iterable { + /// Creates a new [FinderResult] that describes the `values` using the given + /// `describeMatch` callback. + /// + /// {@macro flutter_test.finders.FinderBase.describeMatch} + FinderResult(DescribeMatchCallback describeMatch, Iterable values) + : _describeMatch = describeMatch, _values = values; + + final DescribeMatchCallback _describeMatch; + final Iterable _values; + + @override + Iterator get iterator => _values.iterator; + + @override + String toString() { + final List valuesList = _values.toList(); + // This will put each value on its own line with a comma and indentation + final String valuesString = valuesList.fold( + '', + (String current, CandidateType candidate) => '$current\n $candidate,', + ); + return 'Found ${valuesList.length} ${_describeMatch(Plurality._fromNum(valuesList.length))}: [' + '${valuesString.isNotEmpty ? '$valuesString\n' : ''}' + ']'; + } +} + +/// Provides backwards compatibility with the original [Finder] API. +mixin _LegacyFinderMixin on FinderBase { + Iterable? _precacheResults; /// Describes what the finder is looking for. The description should be - /// a brief English noun phrase describing the finder's pattern. + /// a brief English noun phrase describing the finder's requirements. + @Deprecated( + 'Use FinderBase.describeMatch instead. ' + 'FinderBase.describeMatch allows for more readable descriptions and removes ambiguity about pluralization. ' + 'This feature was deprecated after v3.13.0-0.2.pre.' + ) String get description; /// Returns all the elements in the given list that match this /// finder's pattern. /// - /// When implementing your own Finders that inherit directly from - /// [Finder], this is the main method to override. If your finder - /// can efficiently be described just in terms of a predicate - /// function, consider extending [MatchFinder] instead. - Iterable apply(Iterable candidates); + /// When implementing Finders that inherit directly from + /// [Finder], [findInCandidates] is the main method to override. This method + /// is maintained for backwards compatibility and will be removed in a future + /// version of Flutter. If the finder can efficiently be described just in + /// terms of a predicate function, consider mixing in [MatchFinderMixin] + /// instead. + @Deprecated( + 'Override FinderBase.findInCandidates instead. ' + 'Using the FinderBase API allows for more consistent caching behavior and cleaner options for interacting with the widget tree. ' + 'This feature was deprecated after v3.13.0-0.2.pre.' + ) + Iterable apply(Iterable candidates) { + return findInCandidates(candidates); + } + + /// Attempts to evaluate the finder. Returns whether any elements in the tree + /// matched the finder. If any did, then the result is cached and can be obtained + /// from [evaluate]. + /// + /// If this returns true, you must call [evaluate] before you call [precache] again. + @Deprecated( + 'Use FinderBase.tryFind or FinderBase.runCached instead. ' + 'Using the FinderBase API allows for more consistent caching behavior and cleaner options for interacting with the widget tree. ' + 'This feature was deprecated after v3.13.0-0.2.pre.' + ) + bool precache() { + assert(_precacheResults == null); + if (tryEvaluate()) { + return true; + } + _precacheResults = null; + return false; + } + + @override + Iterable findInCandidates(Iterable candidates) { + return apply(candidates); + } +} + +/// A base class for creating finders that search the [Element] tree for +/// [Widget]s. +/// +/// The [findInCandidates] method must be overriden and will be enforced at +/// compilation after [apply] is removed. +abstract class Finder extends FinderBase with _LegacyFinderMixin { + /// Creates a new [Finder] with the given `skipOffstage` value. + Finder({this.skipOffstage = true}); /// Whether this finder skips nodes that are offstage. /// @@ -493,13 +958,16 @@ abstract class Finder { /// [Offstage] widgets, as well as children of inactive [Route]s. final bool skipOffstage; - /// Returns all the [Element]s that will be considered by this finder. - /// - /// This is the internal API for the [Finder]. To obtain the elements from - /// a [Finder] in a test, consider [WidgetTester.elementList]. - /// - /// See [collectAllElementsFrom]. - @protected + @override + Finder get first => _FirstWidgetFinder(this); + + @override + Finder get last => _LastWidgetFinder(this); + + @override + Finder at(int index) => _IndexWidgetFinder(this, index); + + @override Iterable get allCandidates { return collectAllElementsFrom( WidgetsBinding.instance.rootElement!, @@ -507,140 +975,168 @@ abstract class Finder { ); } - Iterable? _cachedResult; - - /// Returns the current result. If [precache] was called and returned true, this will - /// cheaply return the result that was computed then. Otherwise, it creates a new - /// iterable to compute the answer. - /// - /// Calling this clears the cache from [precache]. - Iterable evaluate() { - final Iterable result = _cachedResult ?? apply(allCandidates); - _cachedResult = null; - return result; + @override + String describeMatch(Plurality plurality) { + return switch (plurality) { + Plurality.zero ||Plurality.many => 'widgets with $description', + Plurality.one => 'widget with $description', + }; } - /// Attempts to evaluate the finder. Returns whether any elements in the tree - /// matched the finder. If any did, then the result is cached and can be obtained - /// from [evaluate]. - /// - /// If this returns true, you must call [evaluate] before you call [precache] again. - bool precache() { - assert(_cachedResult == null); - final Iterable result = apply(allCandidates); - if (result.isNotEmpty) { - _cachedResult = result; - return true; - } - _cachedResult = null; - return false; - } - - /// Returns a variant of this finder that only matches the first element - /// matched by this finder. - Finder get first => _FirstFinder(this); - - /// Returns a variant of this finder that only matches the last element - /// matched by this finder. - Finder get last => _LastFinder(this); - - /// Returns a variant of this finder that only matches the element at the - /// given index matched by this finder. - Finder at(int index) => _IndexFinder(this, index); - /// 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 + /// The `at` parameter specifies the location relative to the size of the /// target element where the hit test is performed. - Finder hitTestable({ Alignment at = Alignment.center }) => _HitTestableFinder(this, at); + Finder hitTestable({ Alignment at = Alignment.center }) => _HitTestableWidgetFinder(this, at); +} + +/// A base class for creating finders that search the semantics tree. +abstract class SemanticsFinder extends FinderBase { + /// Creates a new [SemanticsFinder] that will search starting at the given + /// `root`. + SemanticsFinder(this.root); + + /// The root of the semantics tree that this finder will search. + final SemanticsNode root; @override - String toString() { - final String additional = skipOffstage ? ' (ignoring offstage widgets)' : ''; - final List widgets = evaluate().toList(); - final int count = widgets.length; - if (count == 0) { - return 'zero widgets with $description$additional'; - } - if (count == 1) { - return 'exactly one widget with $description$additional: ${widgets.single}'; - } - if (count < 4) { - return '$count widgets with $description$additional: $widgets'; - } - return '$count widgets with $description$additional: ${widgets[0]}, ${widgets[1]}, ${widgets[2]}, ...'; + Iterable get allCandidates { + return collectAllSemanticsNodesFrom(root); } } -/// Applies additional filtering against a [parent] [Finder]. -abstract class ChainedFinder extends Finder { - /// Create a Finder chained against the candidates of another [Finder]. - ChainedFinder(this.parent); +/// A mixin that applies additional filtering to the results of a parent [Finder]. + mixin ChainedFinderMixin on FinderBase { - /// Another [Finder] that will run first. - final Finder parent; + /// Another finder whose results will be further filtered. + FinderBase get parent; /// Return another [Iterable] when given an [Iterable] of candidates from a - /// parent [Finder]. + /// parent [FinderBase]. /// - /// This is the method to implement when subclassing [ChainedFinder]. - Iterable filter(Iterable parentCandidates); + /// This is the main method to implement when mixing in [ChainedFinderMixin]. + Iterable filter(Iterable parentCandidates); @override - Iterable apply(Iterable candidates) { - return filter(parent.apply(candidates)); + Iterable findInCandidates(Iterable candidates) { + return filter(parent.findInCandidates(candidates)); } @override - Iterable get allCandidates => parent.allCandidates; + Iterable get allCandidates => parent.allCandidates; } -class _FirstFinder extends ChainedFinder { - _FirstFinder(super.parent); +/// Applies additional filtering against a [parent] widget finder. +abstract class ChainedFinder extends Finder with ChainedFinderMixin { + /// Create a Finder chained against the candidates of another `parent` [Finder]. + ChainedFinder(this.parent); @override - String get description => '${parent.description} (ignoring all but first)'; + final FinderBase parent; +} + +mixin _FirstFinderMixin on ChainedFinderMixin{ + @override + String describeMatch(Plurality plurality) { + return '${parent.describeMatch(plurality)} (ignoring all but first)'; + } @override - Iterable filter(Iterable parentCandidates) sync* { + Iterable filter(Iterable parentCandidates) sync* { yield parentCandidates.first; } } -class _LastFinder extends ChainedFinder { - _LastFinder(super.parent); +class _FirstFinder extends FinderBase + with ChainedFinderMixin, _FirstFinderMixin { + _FirstFinder(this.parent); @override - String get description => '${parent.description} (ignoring all but last)'; + final FinderBase parent; +} + +class _FirstWidgetFinder extends ChainedFinder with _FirstFinderMixin { + _FirstWidgetFinder(super.parent); @override - Iterable filter(Iterable parentCandidates) sync* { + String get description => describeMatch(Plurality.many); +} + +mixin _LastFinderMixin on ChainedFinderMixin { + @override + String describeMatch(Plurality plurality) { + return '${parent.describeMatch(plurality)} (ignoring all but first)'; + } + + @override + Iterable filter(Iterable parentCandidates) sync* { yield parentCandidates.last; } } -class _IndexFinder extends ChainedFinder { - _IndexFinder(super.parent, this.index); - - final int index; +class _LastFinder extends FinderBase + with ChainedFinderMixin, _LastFinderMixin{ + _LastFinder(this.parent); @override - String get description => '${parent.description} (ignoring all but index $index)'; + final FinderBase parent; +} + +class _LastWidgetFinder extends ChainedFinder with _LastFinderMixin { + _LastWidgetFinder(super.parent); @override - Iterable filter(Iterable parentCandidates) sync* { + String get description => describeMatch(Plurality.many); +} + +mixin _IndexFinderMixin on ChainedFinderMixin { + int get index; + + @override + String describeMatch(Plurality plurality) { + return '${parent.describeMatch(plurality)} (ignoring all but index $index)'; + } + + @override + Iterable filter(Iterable parentCandidates) sync* { yield parentCandidates.elementAt(index); } } -class _HitTestableFinder extends ChainedFinder { - _HitTestableFinder(super.parent, this.alignment); +class _IndexFinder extends FinderBase + with ChainedFinderMixin, _IndexFinderMixin { + _IndexFinder(this.parent, this.index); + + @override + final int index; + + @override + final FinderBase parent; +} + +class _IndexWidgetFinder extends ChainedFinder with _IndexFinderMixin { + _IndexWidgetFinder(super.parent, this.index); + + @override + final int index; + + @override + String get description => describeMatch(Plurality.many); +} + +class _HitTestableWidgetFinder extends ChainedFinder { + _HitTestableWidgetFinder(super.parent, this.alignment); final Alignment alignment; @override - String get description => '${parent.description} (considering only hit-testable ones)'; + String describeMatch(Plurality plurality) { + return '${parent.describeMatch(plurality)} (considering only hit-testable ones)'; + } + + @override + String get description => describeMatch(Plurality.many); @override Iterable filter(Iterable parentCandidates) sync* { @@ -660,24 +1156,27 @@ class _HitTestableFinder extends ChainedFinder { } } -/// Searches a widget tree and returns nodes that match a particular -/// pattern. -abstract class MatchFinder extends Finder { - /// Initializes a predicate-based Finder. Used by subclasses to initialize the - /// [skipOffstage] property. - MatchFinder({ super.skipOffstage }); - +/// A mixin for creating finders that search candidates for those that match +/// a given pattern. +mixin MatchFinderMixin on FinderBase { /// Returns true if the given element matches the pattern. /// - /// When implementing your own MatchFinder, this is the main method to override. - bool matches(Element candidate); + /// When implementing a MatchFinder, this is the main method to override. + bool matches(CandidateType candidate); @override - Iterable apply(Iterable candidates) { + Iterable findInCandidates(Iterable candidates) { return candidates.where(matches); } } +/// Searches candidates for any that match a particular pattern. +abstract class MatchFinder extends Finder with MatchFinderMixin { + /// Initializes a predicate-based Finder. Used by subclasses to initialize the + /// `skipOffstage` property. + MatchFinder({ super.skipOffstage }); +} + abstract class _MatchTextFinder extends MatchFinder { _MatchTextFinder({ this.findRichText = false, @@ -740,8 +1239,8 @@ abstract class _MatchTextFinder extends MatchFinder { } } -class _TextFinder extends _MatchTextFinder { - _TextFinder( +class _TextWidgetFinder extends _MatchTextFinder { + _TextWidgetFinder( this.text, { super.findRichText, super.skipOffstage, @@ -758,8 +1257,8 @@ class _TextFinder extends _MatchTextFinder { } } -class _TextContainingFinder extends _MatchTextFinder { - _TextContainingFinder( +class _TextContainingWidgetFinder extends _MatchTextFinder { + _TextContainingWidgetFinder( this.pattern, { super.findRichText, super.skipOffstage, @@ -776,8 +1275,8 @@ class _TextContainingFinder extends _MatchTextFinder { } } -class _KeyFinder extends MatchFinder { - _KeyFinder(this.key, { super.skipOffstage }); +class _KeyWidgetFinder extends MatchFinder { + _KeyWidgetFinder(this.key, { super.skipOffstage }); final Key key; @@ -790,8 +1289,8 @@ class _KeyFinder extends MatchFinder { } } -class _WidgetSubtypeFinder extends MatchFinder { - _WidgetSubtypeFinder({ super.skipOffstage }); +class _SubtypeWidgetFinder extends MatchFinder { + _SubtypeWidgetFinder({ super.skipOffstage }); @override String get description => 'is "$T"'; @@ -802,8 +1301,8 @@ class _WidgetSubtypeFinder extends MatchFinder { } } -class _WidgetTypeFinder extends MatchFinder { - _WidgetTypeFinder(this.widgetType, { super.skipOffstage }); +class _TypeWidgetFinder extends MatchFinder { + _TypeWidgetFinder(this.widgetType, { super.skipOffstage }); final Type widgetType; @@ -816,8 +1315,8 @@ class _WidgetTypeFinder extends MatchFinder { } } -class _WidgetImageFinder extends MatchFinder { - _WidgetImageFinder(this.image, { super.skipOffstage }); +class _ImageWidgetFinder extends MatchFinder { + _ImageWidgetFinder(this.image, { super.skipOffstage }); final ImageProvider image; @@ -836,8 +1335,8 @@ class _WidgetImageFinder extends MatchFinder { } } -class _WidgetIconFinder extends MatchFinder { - _WidgetIconFinder(this.icon, { super.skipOffstage }); +class _IconWidgetFinder extends MatchFinder { + _IconWidgetFinder(this.icon, { super.skipOffstage }); final IconData icon; @@ -851,8 +1350,8 @@ class _WidgetIconFinder extends MatchFinder { } } -class _ElementTypeFinder extends MatchFinder { - _ElementTypeFinder(this.elementType, { super.skipOffstage }); +class _ElementTypeWidgetFinder extends MatchFinder { + _ElementTypeWidgetFinder(this.elementType, { super.skipOffstage }); final Type elementType; @@ -865,8 +1364,8 @@ class _ElementTypeFinder extends MatchFinder { } } -class _WidgetFinder extends MatchFinder { - _WidgetFinder(this.widget, { super.skipOffstage }); +class _ExactWidgetFinder extends MatchFinder { + _ExactWidgetFinder(this.widget, { super.skipOffstage }); final Widget widget; @@ -879,15 +1378,15 @@ class _WidgetFinder extends MatchFinder { } } -class _WidgetPredicateFinder extends MatchFinder { - _WidgetPredicateFinder(this.predicate, { String? description, super.skipOffstage }) +class _WidgetPredicateWidgetFinder extends MatchFinder { + _WidgetPredicateWidgetFinder(this.predicate, { String? description, super.skipOffstage }) : _description = description; final WidgetPredicate predicate; final String? _description; @override - String get description => _description ?? 'widget matching predicate ($predicate)'; + String get description => _description ?? 'widget matching predicate'; @override bool matches(Element candidate) { @@ -895,15 +1394,15 @@ class _WidgetPredicateFinder extends MatchFinder { } } -class _ElementPredicateFinder extends MatchFinder { - _ElementPredicateFinder(this.predicate, { String? description, super.skipOffstage }) +class _ElementPredicateWidgetFinder extends MatchFinder { + _ElementPredicateWidgetFinder(this.predicate, { String? description, super.skipOffstage }) : _description = description; final ElementPredicate predicate; final String? _description; @override - String get description => _description ?? 'element matching predicate ($predicate)'; + String get description => _description ?? 'element matching predicate'; @override bool matches(Element candidate) { @@ -911,80 +1410,182 @@ class _ElementPredicateFinder extends MatchFinder { } } -class _DescendantFinder extends Finder { - _DescendantFinder( +class _PredicateSemanticsFinder extends SemanticsFinder + with MatchFinderMixin { + _PredicateSemanticsFinder(this.predicate, DescribeMatchCallback? describeMatch, super.root) + : _describeMatch = describeMatch; + + final SemanticsNodePredicate predicate; + final DescribeMatchCallback? _describeMatch; + + @override + String describeMatch(Plurality plurality) { + return _describeMatch?.call(plurality) ?? + 'matching semantics predicate'; + } + + @override + bool matches(SemanticsNode candidate) { + return predicate(candidate); + } +} + +mixin _DescendantFinderMixin on FinderBase { + + FinderBase get ancestor; + FinderBase get descendant; + bool get matchRoot; + + @override + String describeMatch(Plurality plurality) { + return '${descendant.describeMatch(plurality)} descending from ' + '${ancestor.describeMatch(plurality)}' + '${matchRoot ? ' inclusive' : ''}'; + } + + @override + Iterable findInCandidates(Iterable candidates) { + final Iterable descendants = descendant.evaluate(); + return candidates.where((CandidateType candidate) => descendants.contains(candidate)); + } + + @override + Iterable get allCandidates { + final Iterable ancestors = ancestor.evaluate(); + final List candidates = ancestors.expand( + (CandidateType ancestor) => _collectDescendants(ancestor) + ).toSet().toList(); + if (matchRoot) { + candidates.insertAll(0, ancestors); + } + return candidates; + } + + Iterable _collectDescendants(CandidateType root); +} + +class _DescendantWidgetFinder extends Finder + with _DescendantFinderMixin { + _DescendantWidgetFinder( this.ancestor, this.descendant, { this.matchRoot = false, super.skipOffstage, }); - final Finder ancestor; - final Finder descendant; + @override + final FinderBase ancestor; + @override + final FinderBase descendant; + @override final bool matchRoot; @override - String get description { - if (matchRoot) { - return '${descendant.description} in the subtree(s) beginning with ${ancestor.description}'; - } - return '${descendant.description} that has ancestor(s) with ${ancestor.description}'; - } + String get description => describeMatch(Plurality.many); @override - Iterable apply(Iterable candidates) { - final Iterable descendants = descendant.evaluate(); - return candidates.where((Element element) => descendants.contains(element)); - } - - @override - Iterable get allCandidates { - final Iterable ancestorElements = ancestor.evaluate(); - final List candidates = ancestorElements.expand( - (Element element) => collectAllElementsFrom(element, skipOffstage: skipOffstage) - ).toSet().toList(); - if (matchRoot) { - candidates.insertAll(0, ancestorElements); - } - return candidates; + Iterable _collectDescendants(Element root) { + return collectAllElementsFrom(root, skipOffstage: skipOffstage); } } -class _AncestorFinder extends Finder { - _AncestorFinder(this.descendant, this.ancestor, { this.matchRoot = false }) : super(skipOffstage: false); +class _DescendantSemanticsFinder extends FinderBase + with _DescendantFinderMixin { + _DescendantSemanticsFinder(this.ancestor, this.descendant, {this.matchRoot = false}); - final Finder ancestor; - final Finder descendant; + @override + final FinderBase ancestor; + + @override + final FinderBase descendant; + + @override final bool matchRoot; @override - String get description { - if (matchRoot) { - return 'ancestor ${ancestor.description} beginning with ${descendant.description}'; - } - return '${ancestor.description} which is an ancestor of ${descendant.description}'; + Iterable _collectDescendants(SemanticsNode root) { + return collectAllSemanticsNodesFrom(root); + } +} + +mixin _AncestorFinderMixin on FinderBase { + FinderBase get ancestor; + FinderBase get descendant; + bool get matchLeaves; + + @override + String describeMatch(Plurality plurality) { + return '${ancestor.describeMatch(plurality)} that are ancestors of ' + '${descendant.describeMatch(plurality)}' + '${matchLeaves ? ' inclusive' : ''}'; } @override - Iterable apply(Iterable candidates) { - final Iterable ancestors = ancestor.evaluate(); - return candidates.where((Element element) => ancestors.contains(element)); + Iterable findInCandidates(Iterable candidates) { + final Iterable ancestors = ancestor.evaluate(); + return candidates.where((CandidateType element) => ancestors.contains(element)); } @override - Iterable get allCandidates { - final List candidates = []; - for (final Element root in descendant.evaluate()) { - final List ancestors = []; - if (matchRoot) { - ancestors.add(root); + Iterable get allCandidates { + final List candidates = []; + for (final CandidateType leaf in descendant.evaluate()) { + if (matchLeaves) { + candidates.add(leaf); } - root.visitAncestorElements((Element element) { - ancestors.add(element); - return true; - }); - candidates.addAll(ancestors); + candidates.addAll(_collectAncestors(leaf)); } return candidates; } + + Iterable _collectAncestors(CandidateType child); +} + +class _AncestorWidgetFinder extends Finder + with _AncestorFinderMixin { + _AncestorWidgetFinder(this.descendant, this.ancestor, { this.matchLeaves = false }) : super(skipOffstage: false); + + @override + final FinderBase ancestor; + @override + final FinderBase descendant; + @override + final bool matchLeaves; + + @override + String get description => describeMatch(Plurality.many); + + @override + Iterable _collectAncestors(Element child) { + final List ancestors = []; + child.visitAncestorElements((Element element) { + ancestors.add(element); + return true; + }); + return ancestors; + } +} + +class _AncestorSemanticsFinder extends FinderBase + with _AncestorFinderMixin { + _AncestorSemanticsFinder(this.descendant, this.ancestor, this.matchLeaves); + + @override + final FinderBase ancestor; + + @override + final FinderBase descendant; + + @override + final bool matchLeaves; + + @override + Iterable _collectAncestors(SemanticsNode child) { + final List ancestors = []; + while (child.parent != null) { + ancestors.add(child.parent!); + child = child.parent!; + } + return ancestors; + } } diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index 1e97df7cdfe..54d02bf5404 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart @@ -16,11 +16,12 @@ import 'package:matcher/src/expect/async_matcher.dart'; // ignore: implementatio import '_matchers_io.dart' if (dart.library.html) '_matchers_web.dart' show MatchesGoldenFile, captureImage; import 'accessibility.dart'; import 'binding.dart'; +import 'controller.dart'; import 'finders.dart'; import 'goldens.dart'; import 'widget_tester.dart' show WidgetTester; -/// Asserts that the [Finder] matches no widgets in the widget tree. +/// Asserts that the [FinderBase] matches nothing in the available candidates. /// /// ## Sample code /// @@ -30,14 +31,16 @@ import 'widget_tester.dart' show WidgetTester; /// /// See also: /// -/// * [findsWidgets], when you want the finder to find one or more widgets. -/// * [findsOneWidget], when you want the finder to find exactly one widget. -/// * [findsNWidgets], when you want the finder to find a specific number of widgets. -/// * [findsAtLeastNWidgets], when you want the finder to find at least a specific number of widgets. -const Matcher findsNothing = _FindsWidgetMatcher(null, 0); +/// * [findsAny], when you want the finder to find one or more candidates. +/// * [findsOne], when you want the finder to find exactly one candidate. +/// * [findsExactly], when you want the finder to find a specific number of candidates. +/// * [findsAtLeast], when you want the finder to find at least a specific number of candidates. +const Matcher findsNothing = _FindsCountMatcher(null, 0); /// Asserts that the [Finder] locates at least one widget in the widget tree. /// +/// This is equivalent to the preferred [findsAny] method. +/// /// ## Sample code /// /// ```dart @@ -47,13 +50,31 @@ const Matcher findsNothing = _FindsWidgetMatcher(null, 0); /// See also: /// /// * [findsNothing], when you want the finder to not find anything. -/// * [findsOneWidget], when you want the finder to find exactly one widget. -/// * [findsNWidgets], when you want the finder to find a specific number of widgets. -/// * [findsAtLeastNWidgets], when you want the finder to find at least a specific number of widgets. -const Matcher findsWidgets = _FindsWidgetMatcher(1, null); +/// * [findsOne], when you want the finder to find exactly one candidate. +/// * [findsExactly], when you want the finder to find a specific number of candidates. +/// * [findsAtLeast], when you want the finder to find at least a specific number of candidates. +const Matcher findsWidgets = _FindsCountMatcher(1, null); + +/// Asserts that the [FinderBase] locates at least one matching candidate. +/// +/// ## Sample code +/// +/// ```dart +/// expect(find.text('Save'), findsAny); +/// ``` +/// +/// See also: +/// +/// * [findsNothing], when you want the finder to not find anything. +/// * [findsOne], when you want the finder to find exactly one candidate. +/// * [findsExactly], when you want the finder to find a specific number of candidates. +/// * [findsAtLeast], when you want the finder to find at least a specific number of candidates. +const Matcher findsAny = _FindsCountMatcher(1, null); /// Asserts that the [Finder] locates at exactly one widget in the widget tree. /// +/// This is equivalent to the preferred [findsOne] method. +/// /// ## Sample code /// /// ```dart @@ -63,13 +84,31 @@ const Matcher findsWidgets = _FindsWidgetMatcher(1, null); /// See also: /// /// * [findsNothing], when you want the finder to not find anything. -/// * [findsWidgets], when you want the finder to find one or more widgets. -/// * [findsNWidgets], when you want the finder to find a specific number of widgets. -/// * [findsAtLeastNWidgets], when you want the finder to find at least a specific number of widgets. -const Matcher findsOneWidget = _FindsWidgetMatcher(1, 1); +/// * [findsAny], when you want the finder to find one or more candidates. +/// * [findsExactly], when you want the finder to find a specific number of candidates. +/// * [findsAtLeast], when you want the finder to find at least a specific number of candidates. +const Matcher findsOneWidget = _FindsCountMatcher(1, 1); + +/// Asserts that the [FinderBase] finds exactly one matching candidate. +/// +/// ## Sample code +/// +/// ```dart +/// expect(find.text('Save'), findsOne); +/// ``` +/// +/// See also: +/// +/// * [findsNothing], when you want the finder to not find anything. +/// * [findsAny], when you want the finder to find one or more candidates. +/// * [findsExactly], when you want the finder to find a specific number candidates. +/// * [findsAtLeast], when you want the finder to find at least a specific number of candidates. +const Matcher findsOne = _FindsCountMatcher(1, 1); /// Asserts that the [Finder] locates the specified number of widgets in the widget tree. /// +/// This is equivalent to the preferred [findsExactly] method. +/// /// ## Sample code /// /// ```dart @@ -79,13 +118,31 @@ const Matcher findsOneWidget = _FindsWidgetMatcher(1, 1); /// See also: /// /// * [findsNothing], when you want the finder to not find anything. -/// * [findsWidgets], when you want the finder to find one or more widgets. -/// * [findsOneWidget], when you want the finder to find exactly one widget. -/// * [findsAtLeastNWidgets], when you want the finder to find at least a specific number of widgets. -Matcher findsNWidgets(int n) => _FindsWidgetMatcher(n, n); +/// * [findsAny], when you want the finder to find one or more candidates. +/// * [findsOne], when you want the finder to find exactly one candidate. +/// * [findsAtLeast], when you want the finder to find at least a specific number of candidates. +Matcher findsNWidgets(int n) => _FindsCountMatcher(n, n); + +/// Asserts that the [FinderBase] locates the specified number of candidates. +/// +/// ## Sample code +/// +/// ```dart +/// expect(find.text('Save'), findsExactly(2)); +/// ``` +/// +/// See also: +/// +/// * [findsNothing], when you want the finder to not find anything. +/// * [findsAny], when you want the finder to find one or more candidates. +/// * [findsOne], when you want the finder to find exactly one candidates. +/// * [findsAtLeast], when you want the finder to find at least a specific number of candidates. +Matcher findsExactly(int n) => _FindsCountMatcher(n, n); /// Asserts that the [Finder] locates at least a number of widgets in the widget tree. /// +/// This is equivalent to the preferred [findsAtLeast] method. +/// /// ## Sample code /// /// ```dart @@ -95,10 +152,26 @@ Matcher findsNWidgets(int n) => _FindsWidgetMatcher(n, n); /// See also: /// /// * [findsNothing], when you want the finder to not find anything. -/// * [findsWidgets], when you want the finder to find one or more widgets. -/// * [findsOneWidget], when you want the finder to find exactly one widget. -/// * [findsNWidgets], when you want the finder to find a specific number of widgets. -Matcher findsAtLeastNWidgets(int n) => _FindsWidgetMatcher(n, null); +/// * [findsAny], when you want the finder to find one or more candidates. +/// * [findsOne], when you want the finder to find exactly one candidate. +/// * [findsExactly], when you want the finder to find a specific number of candidates. +Matcher findsAtLeastNWidgets(int n) => _FindsCountMatcher(n, null); + +/// Asserts that the [FinderBase] locates at least the given number of candidates. +/// +/// ## Sample code +/// +/// ```dart +/// expect(find.text('Save'), findsAtLeast(2)); +/// ``` +/// +/// See also: +/// +/// * [findsNothing], when you want the finder to not find anything. +/// * [findsAny], when you want the finder to find one or more candidates. +/// * [findsOne], when you want the finder to find exactly one candidates. +/// * [findsExactly], when you want the finder to find a specific number of candidates. +Matcher findsAtLeast(int n) => _FindsCountMatcher(n, null); /// Asserts that the [Finder] locates a single widget that has at /// least one [Offstage] widget ancestor. @@ -527,7 +600,7 @@ AsyncMatcher matchesReferenceImage(ui.Image image) { /// /// See also: /// -/// * [WidgetTester.getSemantics], the tester method which retrieves semantics. +/// * [SemanticsController.find] under [WidgetTester.semantics], the tester method which retrieves semantics. /// * [containsSemantics], a similar matcher without default values for flags or actions. Matcher matchesSemantics({ String? label, @@ -707,7 +780,7 @@ Matcher matchesSemantics({ /// /// See also: /// -/// * [WidgetTester.getSemantics], the tester method which retrieves semantics. +/// * [SemanticsController.find] under [WidgetTester.semantics], the tester method which retrieves semantics. /// * [matchesSemantics], a similar matcher with default values for flags and actions. Matcher containsSemantics({ String? label, @@ -900,19 +973,19 @@ AsyncMatcher doesNotMeetGuideline(AccessibilityGuideline guideline) { return _DoesNotMatchAccessibilityGuideline(guideline); } -class _FindsWidgetMatcher extends Matcher { - const _FindsWidgetMatcher(this.min, this.max); +class _FindsCountMatcher extends Matcher { + const _FindsCountMatcher(this.min, this.max); final int? min; final int? max; @override - bool matches(covariant Finder finder, Map matchState) { + bool matches(covariant FinderBase finder, Map matchState) { assert(min != null || max != null); assert(min == null || max == null || min! <= max!); - matchState[Finder] = finder; + matchState[FinderBase] = finder; int count = 0; - final Iterator iterator = finder.evaluate().iterator; + final Iterator iterator = finder.evaluate().iterator; if (min != null) { while (count < min! && iterator.moveNext()) { count += 1; @@ -937,26 +1010,26 @@ class _FindsWidgetMatcher extends Matcher { assert(min != null || max != null); if (min == max) { if (min == 1) { - return description.add('exactly one matching node in the widget tree'); + return description.add('exactly one matching candidate'); } - return description.add('exactly $min matching nodes in the widget tree'); + return description.add('exactly $min matching candidates'); } if (min == null) { if (max == 0) { - return description.add('no matching nodes in the widget tree'); + return description.add('no matching candidates'); } if (max == 1) { - return description.add('at most one matching node in the widget tree'); + return description.add('at most one matching candidate'); } - return description.add('at most $max matching nodes in the widget tree'); + return description.add('at most $max matching candidates'); } if (max == null) { if (min == 1) { - return description.add('at least one matching node in the widget tree'); + return description.add('at least one matching candidate'); } - return description.add('at least $min matching nodes in the widget tree'); + return description.add('at least $min matching candidates'); } - return description.add('between $min and $max matching nodes in the widget tree (inclusive)'); + return description.add('between $min and $max matching candidates (inclusive)'); } @override @@ -966,8 +1039,8 @@ class _FindsWidgetMatcher extends Matcher { Map matchState, bool verbose, ) { - final Finder finder = matchState[Finder] as Finder; - final int count = finder.evaluate().length; + final FinderBase finder = matchState[FinderBase] as FinderBase; + final int count = finder.found.length; if (count == 0) { assert(min != null && min! > 0); if (min == 1 && max == 1) { diff --git a/packages/flutter_test/lib/src/tree_traversal.dart b/packages/flutter_test/lib/src/tree_traversal.dart new file mode 100644 index 00000000000..5ae34e797c7 --- /dev/null +++ b/packages/flutter_test/lib/src/tree_traversal.dart @@ -0,0 +1,156 @@ +// Copyright 2014 The Flutter 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/foundation.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/widgets.dart'; + +/// Provides an iterable that efficiently returns all the [Element]s +/// rooted at the given [Element]. See [CachingIterable] for details. +/// +/// This function must be called again if the tree changes. You cannot +/// call this function once, then reuse the iterable after having +/// changed the state of the tree, because the iterable returned by +/// this function caches the results and only walks the tree once. +/// +/// The same applies to any iterable obtained indirectly through this +/// one, for example the results of calling `where` on this iterable +/// are also cached. +Iterable collectAllElementsFrom( + Element rootElement, { + required bool skipOffstage, +}) { + return CachingIterable(_DepthFirstElementTreeIterator(rootElement, !skipOffstage)); +} + +/// Provides an iterable that efficiently returns all the [SemanticsNode]s +/// rooted at the given [SemanticsNode]. See [CachingIterable] for details. +/// +/// By default, this will traverse the semantics tree in semantic traversal +/// order, but the traversal order can be changed by passing in a different +/// value to `order`. +/// +/// This function must be called again if the semantics change. You cannot call +/// this function once, then reuse the iterable after having changed the state +/// of the tree, because the iterable returned by this function caches the +/// results and only walks the tree once. +/// +/// The same applies to any iterable obtained indirectly through this +/// one, for example the results of calling `where` on this iterable +/// are also cached. +Iterable collectAllSemanticsNodesFrom( + SemanticsNode root, { + DebugSemanticsDumpOrder order = DebugSemanticsDumpOrder.traversalOrder, + }) { + return CachingIterable(_DepthFirstSemanticsTreeIterator(root, order)); +} + +/// Provides a recursive, efficient, depth first search of a tree. +/// +/// This iterator executes a depth first search as an iterable, and iterates in +/// a left to right order: +/// +/// 1 +/// / \ +/// 2 3 +/// / \ / \ +/// 4 5 6 7 +/// +/// Will iterate in order 2, 4, 5, 3, 6, 7. The given root element is not +/// included in the traversal. +abstract class _DepthFirstTreeIterator implements Iterator { + _DepthFirstTreeIterator(ItemType root) { + _fillStack(_collectChildren(root)); + } + + @override + ItemType get current => _current!; + late ItemType _current; + + final List _stack = []; + + @override + bool moveNext() { + if (_stack.isEmpty) { + return false; + } + + _current = _stack.removeLast(); + _fillStack(_collectChildren(_current)); + return true; + } + + /// Fills the stack in such a way that the next element of a depth first + /// traversal is easily and efficiently accessible when calling `moveNext`. + void _fillStack(List children) { + // We reverse the list of children so we don't have to do use expensive + // `insert` or `remove` operations, and so the order of the traversal + // is depth first when built lazily through the iterator. + // + // This is faster than `_stack.addAll(children.reversed)`, presumably since + // we don't actually care about maintaining an iteration pointer. + while (children.isNotEmpty) { + _stack.add(children.removeLast()); + } + } + + /// Collect the children from [root] in the order they are expected to traverse. + List _collectChildren(ItemType root); +} + +/// [Element.visitChildren] does not guarantee order, but does guarantee stable +/// order. This iterator also guarantees stable order, and iterates in a left +/// to right order: +/// +/// 1 +/// / \ +/// 2 3 +/// / \ / \ +/// 4 5 6 7 +/// +/// Will iterate in order 2, 4, 5, 3, 6, 7. +/// +/// Performance is important here because this class is on the critical path +/// for flutter_driver and package:integration_test performance tests. +/// Performance is measured in the all_elements_bench microbenchmark. +/// Any changes to this implementation should check the before and after numbers +/// on that benchmark to avoid regressions in general performance test overhead. +/// +/// If we could use RTL order, we could save on performance, but numerous tests +/// have been written (and developers clearly expect) that LTR order will be +/// respected. +class _DepthFirstElementTreeIterator extends _DepthFirstTreeIterator { + _DepthFirstElementTreeIterator(super.root, this.includeOffstage); + + final bool includeOffstage; + + @override + List _collectChildren(Element root) { + final List children = []; + if (includeOffstage) { + root.visitChildren(children.add); + } else { + root.debugVisitOnstageChildren(children.add); + } + + return children; + } +} + +/// Iterates the semantics tree starting at the given `root`. +/// +/// This will iterate in the same order expected from accessibility services, +/// so the results can be used to simulate the same traversal the engine will +/// make. The results are not filtered based on flags or visibility, so they +/// will need to be further filtered to fully simulate an accessiblity service. +class _DepthFirstSemanticsTreeIterator extends _DepthFirstTreeIterator { + _DepthFirstSemanticsTreeIterator(super.root, this.order); + + final DebugSemanticsDumpOrder order; + + @override + List _collectChildren(SemanticsNode root) { + return root.debugListChildrenInOrder(order); + } +} diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index b4d65a1cb51..2a8c36dcc78 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -13,7 +13,6 @@ import 'package:matcher/expect.dart' as matcher_expect; import 'package:meta/meta.dart'; import 'package:test_api/scaffolding.dart' as test_package; -import 'all_elements.dart'; import 'binding.dart'; import 'controller.dart'; import 'finders.dart'; @@ -23,6 +22,7 @@ import 'test_async_utils.dart'; import 'test_compat.dart'; import 'test_pointer.dart'; import 'test_text_input.dart'; +import 'tree_traversal.dart'; // Keep users from needing multiple imports to test semantics. export 'package:flutter/rendering.dart' show SemanticsHandle; @@ -1089,12 +1089,16 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker /// /// Tests that just need to add text to widgets like [TextField] /// or [TextFormField] only need to call [enterText]. - Future showKeyboard(Finder finder) async { + Future showKeyboard(FinderBase finder) async { + bool skipOffstage = true; + if (finder is Finder) { + skipOffstage = finder.skipOffstage; + } return TestAsyncUtils.guard(() async { final EditableTextState editable = state( find.descendant( of: finder, - matching: find.byType(EditableText, skipOffstage: finder.skipOffstage), + matching: find.byType(EditableText, skipOffstage: skipOffstage), matchRoot: true, ), ); @@ -1124,7 +1128,7 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker /// that widget has an open connection (e.g. by using [tap] to focus it), /// then call `testTextInput.enterText` directly (see /// [TestTextInput.enterText]). - Future enterText(Finder finder, String text) async { + Future enterText(FinderBase finder, String text) async { return TestAsyncUtils.guard(() async { await showKeyboard(finder); testTextInput.enterText(text); diff --git a/packages/flutter_test/test/finders_test.dart b/packages/flutter_test/test/finders_test.dart index 09a482bc29e..68ac80a0184 100644 --- a/packages/flutter_test/test/finders_test.dart +++ b/packages/flutter_test/test/finders_test.dart @@ -8,6 +8,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +const List fooBarTexts = [ + Text('foo', textDirection: TextDirection.ltr), + Text('bar', textDirection: TextDirection.ltr), +]; + void main() { group('image', () { testWidgets('finds Image widgets', (WidgetTester tester) async { @@ -390,6 +395,764 @@ void main() { find.byWidgetPredicate((_) => true).evaluate().length; expect(find.bySubtype(), findsNWidgets(totalWidgetCount)); }); + + group('find.byElementPredicate', () { + testWidgets('fails with a custom description in the message', (WidgetTester tester) async { + await tester.pumpWidget(const Text('foo', textDirection: TextDirection.ltr)); + + const String customDescription = 'custom description'; + late TestFailure failure; + try { + expect(find.byElementPredicate((_) => false, description: customDescription), findsOneWidget); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure, isNotNull); + expect(failure.message, contains('Actual: _ElementPredicateWidgetFinder: false, description: customDescription), findsOneWidget); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure, isNotNull); + expect(failure.message, contains('Actual: _WidgetPredicateWidgetFinder:[ + Column(children: fooBarTexts), + ], + )); + + expect(find.descendant( + of: find.widgetWithText(Row, 'foo'), + matching: find.text('bar'), + ), findsOneWidget); + }); + + testWidgets('finds two descendants with different ancestors', (WidgetTester tester) async { + await tester.pumpWidget(const Row( + textDirection: TextDirection.ltr, + children: [ + Column(children: fooBarTexts), + Column(children: fooBarTexts), + ], + )); + + expect(find.descendant( + of: find.widgetWithText(Column, 'foo'), + matching: find.text('bar'), + ), findsNWidgets(2)); + }); + + testWidgets('fails with a descriptive message', (WidgetTester tester) async { + await tester.pumpWidget(const Row( + textDirection: TextDirection.ltr, + children: [ + Column(children: [Text('foo', textDirection: TextDirection.ltr)]), + Text('bar', textDirection: TextDirection.ltr), + ], + )); + + late TestFailure failure; + try { + expect(find.descendant( + of: find.widgetWithText(Column, 'foo'), + matching: find.text('bar'), + ), findsOneWidget); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure, isNotNull); + expect( + failure.message, + contains( + 'Actual: _DescendantWidgetFinder:[ + Column(children: fooBarTexts), + ], + )); + + expect(find.ancestor( + of: find.text('bar'), + matching: find.widgetWithText(Row, 'foo'), + ), findsOneWidget); + }); + + testWidgets('finds two matching ancestors, one descendant', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Row( + children: [ + Row(children: fooBarTexts), + ], + ), + ), + ); + + expect(find.ancestor( + of: find.text('bar'), + matching: find.byType(Row), + ), findsNWidgets(2)); + }); + + testWidgets('fails with a descriptive message', (WidgetTester tester) async { + await tester.pumpWidget(const Row( + textDirection: TextDirection.ltr, + children: [ + Column(children: [Text('foo', textDirection: TextDirection.ltr)]), + Text('bar', textDirection: TextDirection.ltr), + ], + )); + + late TestFailure failure; + try { + expect(find.ancestor( + of: find.text('bar'), + matching: find.widgetWithText(Column, 'foo'), + ), findsOneWidget); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure, isNotNull); + expect( + failure.message, + contains( + 'Actual: _AncestorWidgetFinder:[ + Column(children: fooBarTexts), + ], + )); + + expect(find.ancestor( + of: find.byType(Column), + matching: find.widgetWithText(Column, 'foo'), + ), findsNothing); + }); + + testWidgets('Match the root', (WidgetTester tester) async { + await tester.pumpWidget(const Row( + textDirection: TextDirection.ltr, + children: [ + Column(children: fooBarTexts), + ], + )); + + expect(find.descendant( + of: find.byType(Column), + matching: find.widgetWithText(Column, 'foo'), + matchRoot: true, + ), findsOneWidget); + }); + + testWidgets('is fast in deep tree', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: _deepWidgetTree( + depth: 1000, + child: Row( + children: [ + _deepWidgetTree( + depth: 1000, + child: const Column(children: fooBarTexts), + ), + ], + ), + ), + ), + ); + + expect(find.ancestor( + of: find.text('bar'), + matching: find.byType(Row), + ), findsOneWidget); + }); + }); + + group('CommonSemanticsFinders', () { + final Widget semanticsTree = _boilerplate( + Semantics( + container: true, + header: true, + readOnly: true, + onCopy: () {}, + onLongPress: () {}, + value: 'value1', + hint: 'hint1', + label: 'label1', + child: Semantics( + container: true, + textField: true, + onSetText: (_) { }, + onPaste: () { }, + onLongPress: () { }, + value: 'value2', + hint: 'hint2', + label: 'label2', + child: Semantics( + container: true, + readOnly: true, + onCopy: () {}, + value: 'value3', + hint: 'hint3', + label: 'label3', + child: Semantics( + container: true, + readOnly: true, + onLongPress: () { }, + value: 'value4', + hint: 'hint4', + label: 'label4', + child: Semantics( + container: true, + onLongPress: () { }, + onCopy: () {}, + value: 'value5', + hint: 'hint5', + label: 'label5' + ), + ), + ) + ), + ), + ); + + group('ancestor', () { + testWidgets('finds matching ancestor nodes', (WidgetTester tester) async { + await tester.pumpWidget(semanticsTree); + + final FinderBase finder = find.semantics.ancestor( + of: find.semantics.byLabel('label4'), + matching: find.semantics.byAction(SemanticsAction.copy), + ); + + expect(finder, findsExactly(2)); + }); + + testWidgets('fails with descriptive message', (WidgetTester tester) async { + late TestFailure failure; + await tester.pumpWidget(semanticsTree); + + final FinderBase finder = find.semantics.ancestor( + of: find.semantics.byLabel('label4'), + matching: find.semantics.byAction(SemanticsAction.copy), + ); + + try { + expect(finder, findsExactly(3)); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure.message, contains('Actual: _AncestorSemanticsFinder: finder = find.semantics.descendant( + of: find.semantics.byLabel('label4'), + matching: find.semantics.byAction(SemanticsAction.copy), + ); + + expect(finder, findsOne); + }); + + testWidgets('fails with descriptive message', (WidgetTester tester) async { + late TestFailure failure; + await tester.pumpWidget(semanticsTree); + + final FinderBase finder = find.semantics.descendant( + of: find.semantics.byLabel('label4'), + matching: find.semantics.byAction(SemanticsAction.copy), + ); + + try { + expect(finder, findsNothing); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure.message, contains('Actual: _DescendantSemanticsFinder: 1; + }, + ); + + expect(finder, findsExactly(4)); + }); + + testWidgets('fails with default message', (WidgetTester tester) async { + late TestFailure failure; + final RegExp replaceRegExp = RegExp(r'^[^\d]+'); + await tester.pumpWidget(semanticsTree); + + final SemanticsFinder finder = find.semantics.byPredicate( + (SemanticsNode node) { + final int labelNum = int.tryParse(node.label.replaceAll(replaceRegExp, '')) ?? -1; + return labelNum > 1; + }, + ); + try { + expect(finder, findsExactly(5)); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure.message, contains('Actual: _PredicateSemanticsFinder: 1; + }, + describeMatch: (_) => expected, + ); + try { + expect(finder, findsExactly(5)); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure.message, contains(expected)); + }); + }); + + group('byLabel', () { + testWidgets('finds nodes with matching label using String', (WidgetTester tester) async { + await tester.pumpWidget(semanticsTree); + + final SemanticsFinder finder = find.semantics.byLabel('label3'); + + expect(finder, findsOne); + expect(finder.found.first.label, 'label3'); + }); + + testWidgets('finds nodes with matching label using RegEx', (WidgetTester tester) async { + await tester.pumpWidget(semanticsTree); + + final SemanticsFinder finder = find.semantics.byLabel(RegExp('^label.*')); + + expect(finder, findsExactly(5)); + expect(finder.found.every((SemanticsNode node) => node.label.startsWith('label')), isTrue); + }); + + testWidgets('fails with descriptive message', (WidgetTester tester) async { + late TestFailure failure; + await tester.pumpWidget(semanticsTree); + + final SemanticsFinder finder = find.semantics.byLabel('label3'); + + try { + expect(finder, findsNothing); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure.message, contains('Actual: _PredicateSemanticsFinder: node.value.startsWith('value')), isTrue); + }); + + testWidgets('fails with descriptive message', (WidgetTester tester) async { + late TestFailure failure; + await tester.pumpWidget(semanticsTree); + + final SemanticsFinder finder = find.semantics.byValue('value3'); + + try { + expect(finder, findsNothing); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure.message, contains('Actual: _PredicateSemanticsFinder: node.hint.startsWith('hint')), isTrue); + }); + + testWidgets('fails with descriptive message', (WidgetTester tester) async { + late TestFailure failure; + await tester.pumpWidget(semanticsTree); + + final SemanticsFinder finder = find.semantics.byHint('hint3'); + + try { + expect(finder, findsNothing); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure.message, contains('Actual: _PredicateSemanticsFinder:[ + SemanticsAction.paste, + SemanticsAction.longPress, + ]); + + expect(finder, findsExactly(4)); + }); + + testWidgets('fails with descriptive message', (WidgetTester tester) async { + late TestFailure failure; + await tester.pumpWidget(semanticsTree); + + final SemanticsFinder finder = find.semantics.byAnyAction([ + SemanticsAction.paste, + SemanticsAction.longPress, + ]); + + try { + expect(finder, findsExactly(5)); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure.message, contains('Actual: _PredicateSemanticsFinder:[ + SemanticsFlag.isHeader, + SemanticsFlag.isTextField, + ]); + + expect(finder, findsExactly(2)); + }); + + testWidgets('fails with descriptive message', (WidgetTester tester) async { + late TestFailure failure; + await tester.pumpWidget(semanticsTree); + + final SemanticsFinder finder = find.semantics.byAnyFlag([ + SemanticsFlag.isHeader, + SemanticsFlag.isTextField, + ]); + + try { + expect(finder, findsExactly(3)); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure.message, contains('Actual: _PredicateSemanticsFinder: Plurality.zero, + 1 => Plurality.one, + _ => Plurality.many, + }; + late final Plurality actual; + final _FakeFinder finder = _FakeFinder( + describeMatchCallback: (Plurality plurality) { + actual = plurality; + return 'Fake description'; + }, + findInCandidatesCallback: (_) => Iterable.generate(i, (int index) => index.toString()), + ); + finder.evaluate().toString(); + + expect(actual, expected); + }); + + test('gets expected plurality for $i when reporting results from toString', () { + final Plurality expected = switch (i) { + 0 => Plurality.zero, + 1 => Plurality.one, + _ => Plurality.many, + }; + late final Plurality actual; + final _FakeFinder finder = _FakeFinder( + describeMatchCallback: (Plurality plurality) { + actual = plurality; + return 'Fake description'; + }, + findInCandidatesCallback: (_) => Iterable.generate(i, (int index) => index.toString()), + ); + finder.toString(); + + expect(actual, expected); + }); + + test('always gets many when describing finder', () { + const Plurality expected = Plurality.many; + late final Plurality actual; + final _FakeFinder finder = _FakeFinder( + describeMatchCallback: (Plurality plurality) { + actual = plurality; + return 'Fake description'; + }, + findInCandidatesCallback: (_) => Iterable.generate(i, (int index) => index.toString()), + ); + finder.toString(describeSelf: true); + + expect(actual, expected); + }); + } + }); + + test('findInCandidates gets allCandidates', () { + final List expected = ['Test1', 'Test2', 'Test3', 'Test4']; + late final List actual; + final _FakeFinder finder = _FakeFinder( + allCandidatesCallback: () => expected, + findInCandidatesCallback: (Iterable candidates) { + actual = candidates.toList(); + return candidates; + }, + ); + finder.evaluate(); + + expect(actual, expected); + }); + + test('allCandidates calculated for each find', () { + const int expectedCallCount = 3; + int actualCallCount = 0; + final _FakeFinder finder = _FakeFinder( + allCandidatesCallback: () { + actualCallCount++; + return ['test']; + }, + ); + for (int i = 0; i < expectedCallCount; i++) { + finder.evaluate(); + } + + expect(actualCallCount, expectedCallCount); + }); + + test('allCandidates only called once while caching', () { + int actualCallCount = 0; + final _FakeFinder finder = _FakeFinder( + allCandidatesCallback: () { + actualCallCount++; + return ['test']; + }, + ); + finder.runCached(() { + for (int i = 0; i < 5; i++) { + finder.evaluate(); + finder.tryEvaluate(); + final FinderResult _ = finder.found; + } + }); + + expect(actualCallCount, 1); + }); + + group('tryFind', () { + test('returns false if no results', () { + final _FakeFinder finder = _FakeFinder( + findInCandidatesCallback: (_) => [], + ); + + expect(finder.tryEvaluate(), false); + }); + + test('returns true if results are available', () { + final _FakeFinder finder = _FakeFinder( + findInCandidatesCallback: (_) => ['Results'], + ); + + expect(finder.tryEvaluate(), true); + }); + }); + + group('found', () { + test('throws before any calls to evaluate or tryEvaluate', () { + final _FakeFinder finder = _FakeFinder(); + + expect(finder.hasFound, false); + expect(() => finder.found, throwsAssertionError); + }); + + test('has same results as evaluate after call to evaluate', () { + final _FakeFinder finder = _FakeFinder(); + final FinderResult expected = finder.evaluate(); + + expect(finder.hasFound, true); + expect(finder.found, expected); + }); + + test('has expected results after call to tryFind', () { + final Iterable expected = Iterable.generate(10, (int i) => i.toString()); + final _FakeFinder finder = _FakeFinder(findInCandidatesCallback: (_) => expected); + finder.tryEvaluate(); + + + expect(finder.hasFound, true); + expect(finder.found, orderedEquals(expected)); + }); + }); + }); } Widget _boilerplate(Widget child) { @@ -442,3 +1205,45 @@ class SimpleGenericWidget extends StatelessWidget { return _child; } } + +/// Wraps [child] in [depth] layers of [SizedBox] +Widget _deepWidgetTree({required int depth, required Widget child}) { + Widget tree = child; + for (int i = 0; i < depth; i += 1) { + tree = SizedBox(child: tree); + } + return tree; +} + +class _FakeFinder extends FinderBase { + _FakeFinder({ + this.allCandidatesCallback, + this.describeMatchCallback, + this.findInCandidatesCallback, + }); + + final Iterable Function()? allCandidatesCallback; + final DescribeMatchCallback? describeMatchCallback; + final Iterable Function(Iterable candidates)? findInCandidatesCallback; + + + @override + Iterable get allCandidates { + return allCandidatesCallback?.call() ?? [ + 'String 1', 'String 2', 'String 3', + ]; + } + + @override + String describeMatch(Plurality plurality) { + return describeMatchCallback?.call(plurality) ?? switch (plurality) { + Plurality.one => 'String', + Plurality.many || Plurality.zero => 'Strings', + }; + } + + @override + Iterable findInCandidates(Iterable candidates) { + return findInCandidatesCallback?.call(candidates) ?? candidates; + } +} diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart index 87d5fffa40a..7a5aa13fa5f 100644 --- a/packages/flutter_test/test/matchers_test.dart +++ b/packages/flutter_test/test/matchers_test.dart @@ -1330,6 +1330,72 @@ void main() { expect(find.byType(Text), isNot(findsAtLeastNWidgets(3))); }); }); + + group('findsOneWidget', () { + testWidgets('finds exactly one widget', (WidgetTester tester) async { + await tester.pumpWidget(const Text('foo', textDirection: TextDirection.ltr)); + expect(find.text('foo'), findsOneWidget); + }); + + testWidgets('fails with a descriptive message', (WidgetTester tester) async { + late TestFailure failure; + try { + expect(find.text('foo', skipOffstage: false), findsOneWidget); + } on TestFailure catch (e) { + failure = e; + } + + expect(failure, isNotNull); + final String? message = failure.message; + expect(message, contains('Expected: exactly one matching candidate\n')); + expect(message, contains('Actual: _TextWidgetFinder: fooBarTexts = [ - Text('foo', textDirection: TextDirection.ltr), - Text('bar', textDirection: TextDirection.ltr), -]; - void main() { group('expectLater', () { testWidgets('completes when matcher completes', (WidgetTester tester) async { @@ -75,70 +70,6 @@ void main() { }); }, skip: true); // [intended] API testing - group('findsOneWidget', () { - testWidgets('finds exactly one widget', (WidgetTester tester) async { - await tester.pumpWidget(const Text('foo', textDirection: TextDirection.ltr)); - expect(find.text('foo'), findsOneWidget); - }); - - testWidgets('fails with a descriptive message', (WidgetTester tester) async { - late TestFailure failure; - try { - expect(find.text('foo', skipOffstage: false), findsOneWidget); - } on TestFailure catch (e) { - failure = e; - } - - expect(failure, isNotNull); - final String? message = failure.message; - expect(message, contains('Expected: exactly one matching node in the widget tree\n')); - expect(message, contains('Actual: _TextFinder:\n')); - expect(message, contains('Which: means none were found but one was expected\n')); - }); - }); - - group('findsNothing', () { - testWidgets('finds no widgets', (WidgetTester tester) async { - expect(find.text('foo'), findsNothing); - }); - - testWidgets('fails with a descriptive message', (WidgetTester tester) async { - await tester.pumpWidget(const Text('foo', textDirection: TextDirection.ltr)); - - late TestFailure failure; - try { - expect(find.text('foo', skipOffstage: false), findsNothing); - } on TestFailure catch (e) { - failure = e; - } - - expect(failure, isNotNull); - final String? message = failure.message; - - expect(message, contains('Expected: no matching nodes in the widget tree\n')); - expect(message, contains('Actual: _TextFinder:\n')); - expect(message, contains('Which: means one was found but none were expected\n')); - }); - - testWidgets('fails with a descriptive message when skipping', (WidgetTester tester) async { - await tester.pumpWidget(const Text('foo', textDirection: TextDirection.ltr)); - - late TestFailure failure; - try { - expect(find.text('foo'), findsNothing); - } on TestFailure catch (e) { - failure = e; - } - - expect(failure, isNotNull); - final String? message = failure.message; - - expect(message, contains('Expected: no matching nodes in the widget tree\n')); - expect(message, contains('Actual: _TextFinder:\n')); - expect(message, contains('Which: means one was found but none were expected\n')); - }); - }); - group('pumping', () { testWidgets('pumping', (WidgetTester tester) async { await tester.pumpWidget(const Text('foo', textDirection: TextDirection.ltr)); @@ -196,215 +127,6 @@ void main() { expect(logPaints, [60000, 70000, 80000]); }); }); - - group('find.byElementPredicate', () { - testWidgets('fails with a custom description in the message', (WidgetTester tester) async { - await tester.pumpWidget(const Text('foo', textDirection: TextDirection.ltr)); - - const String customDescription = 'custom description'; - late TestFailure failure; - try { - expect(find.byElementPredicate((_) => false, description: customDescription), findsOneWidget); - } on TestFailure catch (e) { - failure = e; - } - - expect(failure, isNotNull); - expect(failure.message, contains('Actual: _ElementPredicateFinder: false, description: customDescription), findsOneWidget); - } on TestFailure catch (e) { - failure = e; - } - - expect(failure, isNotNull); - expect(failure.message, contains('Actual: _WidgetPredicateFinder:[ - Column(children: fooBarTexts), - ], - )); - - expect(find.descendant( - of: find.widgetWithText(Row, 'foo'), - matching: find.text('bar'), - ), findsOneWidget); - }); - - testWidgets('finds two descendants with different ancestors', (WidgetTester tester) async { - await tester.pumpWidget(const Row( - textDirection: TextDirection.ltr, - children: [ - Column(children: fooBarTexts), - Column(children: fooBarTexts), - ], - )); - - expect(find.descendant( - of: find.widgetWithText(Column, 'foo'), - matching: find.text('bar'), - ), findsNWidgets(2)); - }); - - testWidgets('fails with a descriptive message', (WidgetTester tester) async { - await tester.pumpWidget(const Row( - textDirection: TextDirection.ltr, - children: [ - Column(children: [Text('foo', textDirection: TextDirection.ltr)]), - Text('bar', textDirection: TextDirection.ltr), - ], - )); - - late TestFailure failure; - try { - expect(find.descendant( - of: find.widgetWithText(Column, 'foo'), - matching: find.text('bar'), - ), findsOneWidget); - } on TestFailure catch (e) { - failure = e; - } - - expect(failure, isNotNull); - expect( - failure.message, - contains( - 'Actual: _DescendantFinder:[ - Column(children: fooBarTexts), - ], - )); - - expect(find.ancestor( - of: find.text('bar'), - matching: find.widgetWithText(Row, 'foo'), - ), findsOneWidget); - }); - - testWidgets('finds two matching ancestors, one descendant', (WidgetTester tester) async { - await tester.pumpWidget( - const Directionality( - textDirection: TextDirection.ltr, - child: Row( - children: [ - Row(children: fooBarTexts), - ], - ), - ), - ); - - expect(find.ancestor( - of: find.text('bar'), - matching: find.byType(Row), - ), findsNWidgets(2)); - }); - - testWidgets('fails with a descriptive message', (WidgetTester tester) async { - await tester.pumpWidget(const Row( - textDirection: TextDirection.ltr, - children: [ - Column(children: [Text('foo', textDirection: TextDirection.ltr)]), - Text('bar', textDirection: TextDirection.ltr), - ], - )); - - late TestFailure failure; - try { - expect(find.ancestor( - of: find.text('bar'), - matching: find.widgetWithText(Column, 'foo'), - ), findsOneWidget); - } on TestFailure catch (e) { - failure = e; - } - - expect(failure, isNotNull); - expect( - failure.message, - contains( - 'Actual: _AncestorFinder:[ - Column(children: fooBarTexts), - ], - )); - - expect(find.ancestor( - of: find.byType(Column), - matching: find.widgetWithText(Column, 'foo'), - ), findsNothing); - }); - - testWidgets('Match the root', (WidgetTester tester) async { - await tester.pumpWidget(const Row( - textDirection: TextDirection.ltr, - children: [ - Column(children: fooBarTexts), - ], - )); - - expect(find.descendant( - of: find.byType(Column), - matching: find.widgetWithText(Column, 'foo'), - matchRoot: true, - ), findsOneWidget); - }); - - testWidgets('is fast in deep tree', (WidgetTester tester) async { - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: _deepWidgetTree( - depth: 1000, - child: Row( - children: [ - _deepWidgetTree( - depth: 1000, - child: const Column(children: fooBarTexts), - ), - ], - ), - ), - ), - ); - - expect(find.ancestor( - of: find.text('bar'), - matching: find.byType(Row), - ), findsOneWidget); - }); - }); - group('pageBack', () { testWidgets('fails when there are no back buttons', (WidgetTester tester) async { await tester.pumpWidget(Container()); @@ -985,12 +707,3 @@ class _AlwaysRepaint extends CustomPainter { onPaint(); } } - -/// Wraps [child] in [depth] layers of [SizedBox] -Widget _deepWidgetTree({required int depth, required Widget child}) { - Widget tree = child; - for (int i = 0; i < depth; i += 1) { - tree = SizedBox(child: tree); - } - return tree; -}