Unverified Commit 5df1c996 authored by pdblasi-google's avatar pdblasi-google Committed by GitHub

Adds SemanticsNode Finders for searching the semantics tree (#127137)

* Pulled `FinderBase` out of `Finder`
  * `FinderBase` can be used for any object, not just elements
  * Terminology was updated to be more "find" related
* Re-implemented `Finder` using `FinderBase<Element>`
  * Backwards compatibility maintained with `_LegacyFinderMixin`
* Introduced base classes for SemanticsNode finders
* Introduced basic SemanticsNode finders through `find.semantics`
* Updated some relevant matchers to make use of the more generic `FinderBase`

Closes #123634
Closes #115874
parent 73e0dbf5
...@@ -126,11 +126,11 @@ class _LiveWidgetController extends LiveWidgetController { ...@@ -126,11 +126,11 @@ class _LiveWidgetController extends LiveWidgetController {
} }
/// Runs `finder` repeatedly until it finds one or more [Element]s. /// Runs `finder` repeatedly until it finds one or more [Element]s.
Future<Finder> _waitForElement(Finder finder) async { Future<FinderBase<Element>> _waitForElement(FinderBase<Element> finder) async {
if (frameSync) { if (frameSync) {
await _waitUntilFrame(() => binding.transientCallbackCount == 0); await _waitUntilFrame(() => binding.transientCallbackCount == 0);
} }
await _waitUntilFrame(() => finder.precache()); await _waitUntilFrame(() => finder.tryEvaluate());
if (frameSync) { if (frameSync) {
await _waitUntilFrame(() => binding.transientCallbackCount == 0); await _waitUntilFrame(() => binding.transientCallbackCount == 0);
} }
...@@ -138,12 +138,12 @@ class _LiveWidgetController extends LiveWidgetController { ...@@ -138,12 +138,12 @@ class _LiveWidgetController extends LiveWidgetController {
} }
@override @override
Future<void> tap(Finder finder, { int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = true }) async { Future<void> tap(FinderBase<Element> finder, { int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = true }) async {
await super.tap(await _waitForElement(finder), pointer: pointer, buttons: buttons, warnIfMissed: warnIfMissed); await super.tap(await _waitForElement(finder), pointer: pointer, buttons: buttons, warnIfMissed: warnIfMissed);
} }
Future<void> scrollIntoView(Finder finder, {required double alignment}) async { Future<void> scrollIntoView(FinderBase<Element> finder, {required double alignment}) async {
final Finder target = await _waitForElement(finder); final FinderBase<Element> target = await _waitForElement(finder);
await Scrollable.ensureVisible(target.evaluate().single, duration: const Duration(milliseconds: 100), alignment: alignment); await Scrollable.ensureVisible(target.evaluate().single, duration: const Duration(milliseconds: 100), alignment: alignment);
} }
} }
...@@ -56,7 +56,7 @@ Future<void> main() async { ...@@ -56,7 +56,7 @@ Future<void> main() async {
do { do {
await controller.drag(list, const Offset(0.0, -30.0)); await controller.drag(list, const Offset(0.0, -30.0));
await Future<void>.delayed(const Duration(milliseconds: 20)); await Future<void>.delayed(const Duration(milliseconds: 20));
} while (!lastItem.precache()); } while (!lastItem.tryEvaluate());
debugPrint('==== MEMORY BENCHMARK ==== DONE ===='); debugPrint('==== MEMORY BENCHMARK ==== DONE ====');
} }
...@@ -60,7 +60,7 @@ Future<void> main() async { ...@@ -60,7 +60,7 @@ Future<void> main() async {
do { do {
await controller.drag(demoList, const Offset(0.0, -300.0)); await controller.drag(demoList, const Offset(0.0, -300.0));
await Future<void>.delayed(const Duration(milliseconds: 20)); await Future<void>.delayed(const Duration(milliseconds: 20));
} while (!demoItem.precache()); } while (!demoItem.tryEvaluate());
// Ensure that the center of the "Text fields" item is visible // Ensure that the center of the "Text fields" item is visible
// because that's where we're going to tap // because that's where we're going to tap
......
...@@ -15,13 +15,14 @@ const List<Widget> children = <Widget>[ ...@@ -15,13 +15,14 @@ const List<Widget> children = <Widget>[
void expectRects(WidgetTester tester, List<Rect> expected) { void expectRects(WidgetTester tester, List<Rect> expected) {
final Finder finder = find.byType(SizedBox); final Finder finder = find.byType(SizedBox);
finder.precache();
final List<Rect> actual = <Rect>[]; final List<Rect> actual = <Rect>[];
for (int i = 0; i < expected.length; ++i) { finder.runCached(() {
final Finder current = finder.at(i); for (int i = 0; i < expected.length; ++i) {
expect(current, findsOneWidget); final Finder current = finder.at(i);
actual.add(tester.getRect(finder.at(i))); expect(current, findsOneWidget);
} actual.add(tester.getRect(finder.at(i)));
}
});
expect(() => finder.at(expected.length), throwsRangeError); expect(() => finder.at(expected.length), throwsRangeError);
expect(actual, equals(expected)); expect(actual, equals(expected));
} }
......
...@@ -58,7 +58,6 @@ export 'dart:async' show Future; ...@@ -58,7 +58,6 @@ export 'dart:async' show Future;
export 'src/_goldens_io.dart' if (dart.library.html) 'src/_goldens_web.dart'; 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/_matchers_io.dart' if (dart.library.html) 'src/_matchers_web.dart';
export 'src/accessibility.dart'; export 'src/accessibility.dart';
export 'src/all_elements.dart';
export 'src/animation_sheet.dart'; export 'src/animation_sheet.dart';
export 'src/binding.dart'; export 'src/binding.dart';
export 'src/controller.dart'; export 'src/controller.dart';
...@@ -83,5 +82,6 @@ export 'src/test_exception_reporter.dart'; ...@@ -83,5 +82,6 @@ export 'src/test_exception_reporter.dart';
export 'src/test_pointer.dart'; export 'src/test_pointer.dart';
export 'src/test_text_input.dart'; export 'src/test_text_input.dart';
export 'src/test_vsync.dart'; export 'src/test_vsync.dart';
export 'src/tree_traversal.dart';
export 'src/widget_tester.dart'; export 'src/widget_tester.dart';
export 'src/window.dart'; export 'src/window.dart';
This diff is collapsed.
This diff is collapsed.
...@@ -3,12 +3,13 @@ ...@@ -3,12 +3,13 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
/// Provides an iterable that efficiently returns all the elements /// Provides an iterable that efficiently returns all the [Element]s
/// rooted at the given element. See [CachingIterable] for details. /// rooted at the given [Element]. See [CachingIterable] for details.
/// ///
/// This method must be called again if the tree changes. You cannot /// This function must be called again if the tree changes. You cannot
/// call this function once, then reuse the iterable after having /// call this function once, then reuse the iterable after having
/// changed the state of the tree, because the iterable returned by /// changed the state of the tree, because the iterable returned by
/// this function caches the results and only walks the tree once. /// this function caches the results and only walks the tree once.
...@@ -20,11 +21,84 @@ Iterable<Element> collectAllElementsFrom( ...@@ -20,11 +21,84 @@ Iterable<Element> collectAllElementsFrom(
Element rootElement, { Element rootElement, {
required bool skipOffstage, required bool skipOffstage,
}) { }) {
return CachingIterable<Element>(_DepthFirstChildIterator(rootElement, skipOffstage)); return CachingIterable<Element>(_DepthFirstElementTreeIterator(rootElement, !skipOffstage));
} }
/// Provides a recursive, efficient, depth first search of an element tree. /// 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<SemanticsNode> collectAllSemanticsNodesFrom(
SemanticsNode root, {
DebugSemanticsDumpOrder order = DebugSemanticsDumpOrder.traversalOrder,
}) {
return CachingIterable<SemanticsNode>(_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<ItemType> implements Iterator<ItemType> {
_DepthFirstTreeIterator(ItemType root) {
_fillStack(_collectChildren(root));
}
@override
ItemType get current => _current!;
late ItemType _current;
final List<ItemType> _stack = <ItemType>[];
@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<ItemType> 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<ItemType> _collectChildren(ItemType root);
}
/// [Element.visitChildren] does not guarantee order, but does guarantee stable /// [Element.visitChildren] does not guarantee order, but does guarantee stable
/// order. This iterator also guarantees stable order, and iterates in a left /// order. This iterator also guarantees stable order, and iterates in a left
/// to right order: /// to right order:
...@@ -37,7 +111,7 @@ Iterable<Element> collectAllElementsFrom( ...@@ -37,7 +111,7 @@ Iterable<Element> collectAllElementsFrom(
/// ///
/// Will iterate in order 2, 4, 5, 3, 6, 7. /// Will iterate in order 2, 4, 5, 3, 6, 7.
/// ///
/// Performance is important here because this method is on the critical path /// Performance is important here because this class is on the critical path
/// for flutter_driver and package:integration_test performance tests. /// for flutter_driver and package:integration_test performance tests.
/// Performance is measured in the all_elements_bench microbenchmark. /// Performance is measured in the all_elements_bench microbenchmark.
/// Any changes to this implementation should check the before and after numbers /// Any changes to this implementation should check the before and after numbers
...@@ -46,46 +120,37 @@ Iterable<Element> collectAllElementsFrom( ...@@ -46,46 +120,37 @@ Iterable<Element> collectAllElementsFrom(
/// If we could use RTL order, we could save on performance, but numerous tests /// 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 /// have been written (and developers clearly expect) that LTR order will be
/// respected. /// respected.
class _DepthFirstChildIterator implements Iterator<Element> { class _DepthFirstElementTreeIterator extends _DepthFirstTreeIterator<Element> {
_DepthFirstChildIterator(Element rootElement, this.skipOffstage) { _DepthFirstElementTreeIterator(super.root, this.includeOffstage);
_fillChildren(rootElement);
}
final bool skipOffstage;
late Element _current; final bool includeOffstage;
final List<Element> _stack = <Element>[];
@override @override
Element get current => _current; List<Element> _collectChildren(Element root) {
final List<Element> children = <Element>[];
@override if (includeOffstage) {
bool moveNext() { root.visitChildren(children.add);
if (_stack.isEmpty) { } else {
return false; root.debugVisitOnstageChildren(children.add);
} }
_current = _stack.removeLast(); return children;
_fillChildren(_current);
return true;
} }
}
void _fillChildren(Element element) { /// Iterates the semantics tree starting at the given `root`.
// 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 /// This will iterate in the same order expected from accessibility services,
// faster. Unfortunately, a lot of tests depend on LTR order. /// so the results can be used to simulate the same traversal the engine will
final List<Element> reversed = <Element>[]; /// make. The results are not filtered based on flags or visibility, so they
if (skipOffstage) { /// will need to be further filtered to fully simulate an accessiblity service.
element.debugVisitOnstageChildren(reversed.add); class _DepthFirstSemanticsTreeIterator extends _DepthFirstTreeIterator<SemanticsNode> {
} else { _DepthFirstSemanticsTreeIterator(super.root, this.order);
element.visitChildren(reversed.add);
} final DebugSemanticsDumpOrder order;
// This is faster than _stack.addAll(reversed.reversed), presumably since
// we don't actually care about maintaining an iteration pointer. @override
while (reversed.isNotEmpty) { List<SemanticsNode> _collectChildren(SemanticsNode root) {
_stack.add(reversed.removeLast()); return root.debugListChildrenInOrder(order);
}
} }
} }
...@@ -13,7 +13,6 @@ import 'package:matcher/expect.dart' as matcher_expect; ...@@ -13,7 +13,6 @@ import 'package:matcher/expect.dart' as matcher_expect;
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:test_api/scaffolding.dart' as test_package; import 'package:test_api/scaffolding.dart' as test_package;
import 'all_elements.dart';
import 'binding.dart'; import 'binding.dart';
import 'controller.dart'; import 'controller.dart';
import 'finders.dart'; import 'finders.dart';
...@@ -23,6 +22,7 @@ import 'test_async_utils.dart'; ...@@ -23,6 +22,7 @@ import 'test_async_utils.dart';
import 'test_compat.dart'; import 'test_compat.dart';
import 'test_pointer.dart'; import 'test_pointer.dart';
import 'test_text_input.dart'; import 'test_text_input.dart';
import 'tree_traversal.dart';
// Keep users from needing multiple imports to test semantics. // Keep users from needing multiple imports to test semantics.
export 'package:flutter/rendering.dart' show SemanticsHandle; export 'package:flutter/rendering.dart' show SemanticsHandle;
...@@ -1089,12 +1089,16 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker ...@@ -1089,12 +1089,16 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker
/// ///
/// Tests that just need to add text to widgets like [TextField] /// Tests that just need to add text to widgets like [TextField]
/// or [TextFormField] only need to call [enterText]. /// or [TextFormField] only need to call [enterText].
Future<void> showKeyboard(Finder finder) async { Future<void> showKeyboard(FinderBase<Element> finder) async {
bool skipOffstage = true;
if (finder is Finder) {
skipOffstage = finder.skipOffstage;
}
return TestAsyncUtils.guard<void>(() async { return TestAsyncUtils.guard<void>(() async {
final EditableTextState editable = state<EditableTextState>( final EditableTextState editable = state<EditableTextState>(
find.descendant( find.descendant(
of: finder, of: finder,
matching: find.byType(EditableText, skipOffstage: finder.skipOffstage), matching: find.byType(EditableText, skipOffstage: skipOffstage),
matchRoot: true, matchRoot: true,
), ),
); );
...@@ -1124,7 +1128,7 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker ...@@ -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), /// that widget has an open connection (e.g. by using [tap] to focus it),
/// then call `testTextInput.enterText` directly (see /// then call `testTextInput.enterText` directly (see
/// [TestTextInput.enterText]). /// [TestTextInput.enterText]).
Future<void> enterText(Finder finder, String text) async { Future<void> enterText(FinderBase<Element> finder, String text) async {
return TestAsyncUtils.guard<void>(() async { return TestAsyncUtils.guard<void>(() async {
await showKeyboard(finder); await showKeyboard(finder);
testTextInput.enterText(text); testTextInput.enterText(text);
......
...@@ -1330,6 +1330,72 @@ void main() { ...@@ -1330,6 +1330,72 @@ void main() {
expect(find.byType(Text), isNot(findsAtLeastNWidgets(3))); 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:<Found 0 widgets with text "foo"'));
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 candidates\n'));
expect(message, contains('Actual: _TextWidgetFinder:<Found 1 widget with text "foo"'));
expect(message, contains('Text("foo", textDirection: ltr, dependencies: [MediaQuery])'));
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 candidates\n'));
expect(message, contains('Actual: _TextWidgetFinder:<Found 1 widget with text "foo"'));
expect(message, contains('Text("foo", textDirection: ltr, dependencies: [MediaQuery])'));
expect(message, contains('Which: means one was found but none were expected\n'));
});
});
} }
enum _ComparatorBehavior { enum _ComparatorBehavior {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment