| // 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. | |
| /// @docImport 'dart:io'; | |
| /// | |
| /// @docImport 'package:flutter/scheduler.dart'; | |
| /// @docImport 'package:flutter_driver/flutter_driver.dart'; | |
| /// | |
| /// @docImport 'binding.dart'; | |
| /// @docImport 'finders.dart'; | |
| /// @docImport 'matchers.dart'; | |
| /// @docImport 'widget_tester.dart'; | |
| library; | |
| import 'dart:ui' as ui; | |
| import 'package:clock/clock.dart'; | |
| import 'package:flutter/foundation.dart'; | |
| import 'package:flutter/gestures.dart'; | |
| import 'package:flutter/rendering.dart'; | |
| import 'package:flutter/services.dart'; | |
| import 'package:flutter/widgets.dart'; | |
| import 'event_simulation.dart'; | |
| import 'finders.dart' as finders; | |
| 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 | |
| /// smaller moves. | |
| /// | |
| /// This value must be greater than [kTouchSlop]. | |
| const double kDragSlopDefault = 20.0; | |
| // Finds the end index (exclusive) of the span at `startIndex`, or `endIndex` if | |
| // there are no other spans between `startIndex` and `endIndex`. | |
| // The InlineSpan protocol doesn't expose the length of the span so we'll | |
| // have to iterate through the whole range. | |
| (InlineSpan, int)? _findEndOfSpan(InlineSpan rootSpan, int startIndex, int endIndex) { | |
| assert(endIndex > startIndex); | |
| final InlineSpan? subspan = rootSpan.getSpanForPosition(TextPosition(offset: startIndex)); | |
| if (subspan == null) { | |
| return null; | |
| } | |
| int i = startIndex + 1; | |
| while (i < endIndex && rootSpan.getSpanForPosition(TextPosition(offset: i)) == subspan) { | |
| i += 1; | |
| } | |
| return (subspan, i); | |
| } | |
| // Examples can assume: | |
| // typedef MyWidget = Placeholder; | |
| /// Class that programmatically interacts with the [Semantics] tree. | |
| /// | |
| /// Allows for testing of the [Semantics] tree, which is used by assistive | |
| /// technology, search engines, and other analysis software to determine the | |
| /// meaning of an application. | |
| /// | |
| /// Should be accessed through [WidgetController.semantics]. If no custom | |
| /// implementation is provided, a default [SemanticsController] will be created. | |
| class SemanticsController { | |
| /// Creates a [SemanticsController] that uses the given binding. Will be | |
| /// automatically created as part of instantiating a [WidgetController], but | |
| /// a custom implementation can be passed via the [WidgetController] constructor. | |
| SemanticsController._(this._controller); | |
| static final int _scrollingActions = | |
| SemanticsAction.scrollUp.index | | |
| SemanticsAction.scrollDown.index | | |
| SemanticsAction.scrollLeft.index | | |
| SemanticsAction.scrollRight.index | | |
| SemanticsAction.scrollToOffset.index; | |
| final WidgetController _controller; | |
| /// Attempts to find the [SemanticsNode] of first result from `finder`. | |
| /// | |
| /// If the object identified by the finder doesn't own its semantic node, | |
| /// this will return the semantics data of the first ancestor with semantics. | |
| /// The ancestor's semantic data will include the child's as well as | |
| /// other nodes that have been merged together. | |
| /// | |
| /// If the [SemanticsNode] of the object identified by the finder is | |
| /// force-merged into an ancestor (e.g. via the [MergeSemantics] widget) | |
| /// the node into which it is merged is returned. That node will include | |
| /// all the semantics information of the nodes merged into it. | |
| /// | |
| /// Will throw a [StateError] if the finder returns more than one element or | |
| /// if no semantics are found or are not enabled. | |
| SemanticsNode find(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| final Iterable<Element> candidates = finder.evaluate(); | |
| if (candidates.isEmpty) { | |
| throw StateError('Finder returned no matching elements.'); | |
| } | |
| if (candidates.length > 1) { | |
| throw StateError('Finder returned more than one element.'); | |
| } | |
| final Element element = candidates.single; | |
| RenderObject? renderObject = element.findRenderObject(); | |
| SemanticsNode? result = renderObject?.debugSemantics; | |
| while (renderObject != null && (result == null || result.isMergedIntoParent)) { | |
| renderObject = renderObject.parent; | |
| result = renderObject?.debugSemantics; | |
| } | |
| if (result == null) { | |
| throw StateError('No Semantics data found.'); | |
| } | |
| return result; | |
| } | |
| /// Simulates a traversal of the currently visible semantics tree as if by | |
| /// assistive technologies. | |
| /// | |
| /// Starts at the node for `startNode`. If `startNode` is not provided, then | |
| /// the traversal begins with the first accessible node in the tree. If | |
| /// `startNode` finds zero elements or more than one element, a [StateError] | |
| /// will be thrown. | |
| /// | |
| /// Ends at the node for `endNode`, inclusive. If `endNode` is not provided, | |
| /// then the traversal ends with the last accessible node in the currently | |
| /// available tree. If `endNode` finds zero elements or more than one element, | |
| /// a [StateError] will be thrown. | |
| /// | |
| /// If provided, the nodes for `endNode` and `startNode` must be part of the | |
| /// same semantics tree, i.e. they must be part of the same view. | |
| /// | |
| /// If neither `startNode` or `endNode` is provided, `view` can be provided to | |
| /// specify the semantics tree to traverse. If `view` is left unspecified, | |
| /// [WidgetTester.view] is traversed by default. | |
| /// | |
| /// Since the order is simulated, edge cases that differ between platforms | |
| /// (such as how the last visible item in a scrollable list is handled) may be | |
| /// inconsistent with platform behavior, but are expected to be sufficient for | |
| /// testing order, availability to assistive technologies, and interactions. | |
| /// | |
| /// ## Sample Code | |
| /// | |
| /// ```dart | |
| /// testWidgets('MyWidget', (WidgetTester tester) async { | |
| /// await tester.pumpWidget(const MyWidget()); | |
| /// | |
| /// expect( | |
| /// tester.semantics.simulatedAccessibilityTraversal(), | |
| /// containsAllInOrder(<Matcher>[ | |
| /// isSemantics(label: 'My Widget'), | |
| /// isSemantics(label: 'is awesome!', isChecked: true), | |
| /// ]), | |
| /// ); | |
| /// }); | |
| /// ``` | |
| /// | |
| /// See also: | |
| /// | |
| /// * [isSemantics] and [matchesSemantics], which can be used to match | |
| /// against a single node in the traversal. | |
| /// * [containsAllInOrder], which can be given an [Iterable<Matcher>] to fuzzy | |
| /// match the order allowing extra nodes before after and between matching | |
| /// parts of the traversal. | |
| /// * [orderedEquals], which can be given an [Iterable<Matcher>] to exactly | |
| /// match the order of the traversal. | |
| Iterable<SemanticsNode> simulatedAccessibilityTraversal({ | |
| @Deprecated( | |
| 'Use startNode instead. ' | |
| 'This method was originally created before semantics finders were available. ' | |
| 'Semantics finders avoid edge cases where some nodes are not discoverable by widget finders and should be preferred for semantics testing. ' | |
| 'This feature was deprecated after v3.15.0-15.2.pre.', | |
| ) | |
| finders.FinderBase<Element>? start, | |
| @Deprecated( | |
| 'Use endNode instead. ' | |
| 'This method was originally created before semantics finders were available. ' | |
| 'Semantics finders avoid edge cases where some nodes are not discoverable by widget finders and should be preferred for semantics testing. ' | |
| 'This feature was deprecated after v3.15.0-15.2.pre.', | |
| ) | |
| finders.FinderBase<Element>? end, | |
| finders.FinderBase<SemanticsNode>? startNode, | |
| finders.FinderBase<SemanticsNode>? endNode, | |
| FlutterView? view, | |
| }) { | |
| TestAsyncUtils.guardSync(); | |
| assert( | |
| start == null || startNode == null, | |
| 'Cannot provide both start and startNode. Prefer startNode as start is deprecated.', | |
| ); | |
| assert( | |
| end == null || endNode == null, | |
| 'Cannot provide both end and endNode. Prefer endNode as end is deprecated.', | |
| ); | |
| FlutterView? startView; | |
| if (start != null) { | |
| startView = _controller.viewOf(start); | |
| if (view != null && startView != view) { | |
| throw StateError( | |
| 'The start node is not part of the provided view.\n' | |
| 'Finder: ${start.toString(describeSelf: true)}\n' | |
| 'View of start node: $startView\n' | |
| 'Specified view: $view', | |
| ); | |
| } | |
| } else if (startNode != null) { | |
| final SemanticsOwner owner = startNode.evaluate().single.owner!; | |
| final RenderView renderView = _controller.binding.renderViews.firstWhere( | |
| (RenderView render) => render.owner!.semanticsOwner == owner, | |
| ); | |
| startView = renderView.flutterView; | |
| if (view != null && startView != view) { | |
| throw StateError( | |
| 'The start node is not part of the provided view.\n' | |
| 'Finder: ${startNode.toString(describeSelf: true)}\n' | |
| 'View of start node: $startView\n' | |
| 'Specified view: $view', | |
| ); | |
| } | |
| } | |
| FlutterView? endView; | |
| if (end != null) { | |
| endView = _controller.viewOf(end); | |
| if (view != null && endView != view) { | |
| throw StateError( | |
| 'The end node is not part of the provided view.\n' | |
| 'Finder: ${end.toString(describeSelf: true)}\n' | |
| 'View of end node: $endView\n' | |
| 'Specified view: $view', | |
| ); | |
| } | |
| } else if (endNode != null) { | |
| final SemanticsOwner owner = endNode.evaluate().single.owner!; | |
| final RenderView renderView = _controller.binding.renderViews.firstWhere( | |
| (RenderView render) => render.owner!.semanticsOwner == owner, | |
| ); | |
| endView = renderView.flutterView; | |
| if (view != null && endView != view) { | |
| throw StateError( | |
| 'The end node is not part of the provided view.\n' | |
| 'Finder: ${endNode.toString(describeSelf: true)}\n' | |
| 'View of end node: $endView\n' | |
| 'Specified view: $view', | |
| ); | |
| } | |
| } | |
| if (endView != null && startView != null && endView != startView) { | |
| throw StateError( | |
| 'The start and end node are in different views.\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', | |
| ); | |
| } | |
| final FlutterView actualView = view ?? startView ?? endView ?? _controller.view; | |
| final RenderView renderView = _controller.binding.renderViews.firstWhere( | |
| (RenderView r) => r.flutterView == actualView, | |
| ); | |
| final traversal = <SemanticsNode>[]; | |
| _accessibilityTraversal(renderView.owner!.semanticsOwner!.rootSemanticsNode!, traversal); | |
| // Setting the range | |
| SemanticsNode? node; | |
| String? errorString; | |
| int startIndex; | |
| if (start != null) { | |
| node = find(start); | |
| startIndex = traversal.indexOf(node); | |
| errorString = start.toString(describeSelf: true); | |
| } else if (startNode != null) { | |
| node = startNode.evaluate().single; | |
| startIndex = traversal.indexOf(node); | |
| errorString = startNode.toString(describeSelf: true); | |
| } else { | |
| startIndex = 0; | |
| } | |
| if (startIndex == -1) { | |
| throw StateError( | |
| 'The expected starting node was not found.\n' | |
| 'Finder: $errorString\n\n' | |
| 'Expected Start Node: $node\n\n' | |
| 'Traversal: [\n ${traversal.join('\n ')}\n]', | |
| ); | |
| } | |
| int? endIndex; | |
| if (end != null) { | |
| node = find(end); | |
| endIndex = traversal.indexOf(node); | |
| errorString = end.toString(describeSelf: true); | |
| } else if (endNode != null) { | |
| node = endNode.evaluate().single; | |
| endIndex = traversal.indexOf(node); | |
| errorString = endNode.toString(describeSelf: true); | |
| } | |
| if (endIndex == -1) { | |
| throw StateError( | |
| 'The expected ending node was not found.\n' | |
| 'Finder: $errorString\n\n' | |
| 'Expected End Node: $node\n\n' | |
| 'Traversal: [\n ${traversal.join('\n ')}\n]', | |
| ); | |
| } | |
| endIndex ??= traversal.length - 1; | |
| return traversal.getRange(startIndex, endIndex + 1); | |
| } | |
| /// Recursive depth first traversal of the specified `node`, adding nodes | |
| /// that are important for semantics to the `traversal` list. | |
| void _accessibilityTraversal(SemanticsNode node, List<SemanticsNode> traversal) { | |
| if (_isImportantForAccessibility(node)) { | |
| traversal.add(node); | |
| } | |
| final List<SemanticsNode> children = node.debugListChildrenInOrder( | |
| DebugSemanticsDumpOrder.traversalOrder, | |
| ); | |
| for (final child in children) { | |
| _accessibilityTraversal(child, traversal); | |
| } | |
| } | |
| /// Whether or not the node is important for semantics. Should match most cases | |
| /// on the platforms, but certain edge cases will be inconsistent. | |
| /// | |
| /// Based on: | |
| /// | |
| /// * [flutter/engine/AccessibilityBridge.java#SemanticsNode.isFocusable()](https://github.com/flutter/flutter/blob/main/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java#L2641) | |
| /// * [flutter/engine/SemanticsObject.mm#SemanticsObject.isAccessibilityElement](https://github.com/flutter/flutter/blob/main/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm#L449) | |
| // | |
| // TODO(quncheng): If this method is modified, please also update the copy of | |
| // this method located in `packages/flutter/lib/src/widgets/_accessibility_evaluations.dart`. | |
| // This private method will be removed once the feature flag | |
| // `isAccessibilityEvaluationsEnabled` is turned on. | |
| bool _isImportantForAccessibility(SemanticsNode node) { | |
| if (node.isMergedIntoParent) { | |
| // If this node is merged, all its information are present on an ancestor | |
| // node. | |
| return false; | |
| } | |
| final SemanticsData data = node.getSemanticsData(); | |
| // If the node scopes a route, it doesn't matter what other flags/actions it | |
| // has, it is _not_ important for accessibility, so we short circuit. | |
| if (data.flagsCollection.scopesRoute) { | |
| return false; | |
| } | |
| final hasNonScrollingAction = data.actions & ~_scrollingActions != 0; | |
| if (hasNonScrollingAction) { | |
| return true; | |
| } | |
| /// Based on Android's FOCUSABLE_FLAGS. See [flutter/engine/AccessibilityBridge.java](https://github.com/flutter/flutter/blob/main/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java). | |
| final bool hasImportantFlag = | |
| data.flagsCollection.isChecked != ui.CheckedState.none || | |
| data.flagsCollection.isToggled != ui.Tristate.none || | |
| data.flagsCollection.isEnabled != ui.Tristate.none || | |
| data.flagsCollection.isButton || | |
| data.flagsCollection.isTextField || | |
| data.flagsCollection.isFocused != ui.Tristate.none || | |
| data.flagsCollection.isSlider || | |
| data.flagsCollection.isInMutuallyExclusiveGroup; | |
| if (hasImportantFlag) { | |
| return true; | |
| } | |
| final bool hasContent = | |
| data.label.isNotEmpty || | |
| data.value.isNotEmpty || | |
| data.hint.isNotEmpty || | |
| data.tooltip.isNotEmpty; | |
| if (hasContent) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| /// Performs the given [SemanticsAction] on the [SemanticsNode] found by `finder`. | |
| /// | |
| /// If `args` are provided, they will be passed unmodified with the `action`. | |
| /// The `checkForAction` argument allows for attempting to perform `action` on | |
| /// `node` even if it doesn't report supporting that action. This is useful | |
| /// for implicitly supported actions such as [SemanticsAction.showOnScreen]. | |
| void performAction( | |
| finders.FinderBase<SemanticsNode> finder, | |
| SemanticsAction action, { | |
| Object? args, | |
| bool checkForAction = true, | |
| }) { | |
| final SemanticsNode node = finder.evaluate().single; | |
| if (checkForAction && !node.getSemanticsData().hasAction(action)) { | |
| throw StateError( | |
| 'The given node does not support $action. If the action is implicitly ' | |
| 'supported or an unsupported action is being tested for this node, ' | |
| 'set `checkForAction` to false.\n' | |
| 'Node: $node', | |
| ); | |
| } | |
| node.owner!.performAction(node.id, action, args); | |
| } | |
| /// Performs a [SemanticsAction.tap] action on the [SemanticsNode] found | |
| /// by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.tap]. | |
| void tap(finders.FinderBase<SemanticsNode> finder) { | |
| performAction(finder, SemanticsAction.tap); | |
| } | |
| /// Performs a [SemanticsAction.longPress] action on the [SemanticsNode] found | |
| /// by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.longPress]. | |
| void longPress(finders.FinderBase<SemanticsNode> finder) { | |
| performAction(finder, SemanticsAction.longPress); | |
| } | |
| /// Performs a [SemanticsAction.scrollLeft] action on the [SemanticsNode] | |
| /// found by `scrollable` or the first scrollable node in the default | |
| /// semantics tree if no `scrollable` is provided. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `scrollable` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `scrollable` does not support | |
| /// [SemanticsAction.scrollLeft]. | |
| void scrollLeft({finders.FinderBase<SemanticsNode>? scrollable}) { | |
| performAction(scrollable ?? finders.find.semantics.scrollable(), SemanticsAction.scrollLeft); | |
| } | |
| /// Performs a [SemanticsAction.scrollRight] action on the [SemanticsNode] | |
| /// found by `scrollable` or the first scrollable node in the default | |
| /// semantics tree if no `scrollable` is provided. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `scrollable` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `scrollable` does not support | |
| /// [SemanticsAction.scrollRight]. | |
| void scrollRight({finders.FinderBase<SemanticsNode>? scrollable}) { | |
| performAction(scrollable ?? finders.find.semantics.scrollable(), SemanticsAction.scrollRight); | |
| } | |
| /// Performs a [SemanticsAction.scrollUp] action on the [SemanticsNode] found | |
| /// by `scrollable` or the first scrollable node in the default semantics | |
| /// tree if no `scrollable` is provided. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `scrollable` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `scrollable` does not support | |
| /// [SemanticsAction.scrollUp]. | |
| void scrollUp({finders.FinderBase<SemanticsNode>? scrollable}) { | |
| performAction(scrollable ?? finders.find.semantics.scrollable(), SemanticsAction.scrollUp); | |
| } | |
| /// Performs a [SemanticsAction.scrollDown] action on the [SemanticsNode] | |
| /// found by `scrollable` or the first scrollable node in the default | |
| /// semantics tree if no `scrollable` is provided. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `scrollable` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `scrollable` does not support | |
| /// [SemanticsAction.scrollDown]. | |
| void scrollDown({finders.FinderBase<SemanticsNode>? scrollable}) { | |
| performAction(scrollable ?? finders.find.semantics.scrollable(), SemanticsAction.scrollDown); | |
| } | |
| /// Performs a [SemanticsAction.increase] action on the [SemanticsNode] | |
| /// found by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.increase]. | |
| void increase(finders.FinderBase<SemanticsNode> finder) { | |
| performAction(finder, SemanticsAction.increase); | |
| } | |
| /// Performs a [SemanticsAction.decrease] action on the [SemanticsNode] | |
| /// found by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.decrease]. | |
| void decrease(finders.FinderBase<SemanticsNode> finder) { | |
| performAction(finder, SemanticsAction.decrease); | |
| } | |
| /// Performs a [SemanticsAction.showOnScreen] action on the [SemanticsNode] | |
| /// found by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.showOnScreen]. | |
| void showOnScreen(finders.FinderBase<SemanticsNode> finder) { | |
| performAction(finder, SemanticsAction.showOnScreen, checkForAction: false); | |
| } | |
| /// Performs a [SemanticsAction.moveCursorForwardByCharacter] action on the | |
| /// [SemanticsNode] found by `finder`. | |
| /// | |
| /// If `shouldModifySelection` is true, then the cursor will begin or extend | |
| /// a selection. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.moveCursorForwardByCharacter]. | |
| void moveCursorForwardByCharacter( | |
| finders.FinderBase<SemanticsNode> finder, { | |
| bool shouldModifySelection = false, | |
| }) { | |
| performAction( | |
| finder, | |
| SemanticsAction.moveCursorForwardByCharacter, | |
| args: shouldModifySelection, | |
| ); | |
| } | |
| /// Performs a [SemanticsAction.moveCursorForwardByWord] action on the | |
| /// [SemanticsNode] found by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.moveCursorForwardByWord]. | |
| void moveCursorForwardByWord( | |
| finders.FinderBase<SemanticsNode> finder, { | |
| bool shouldModifySelection = false, | |
| }) { | |
| performAction(finder, SemanticsAction.moveCursorForwardByWord, args: shouldModifySelection); | |
| } | |
| /// Performs a [SemanticsAction.moveCursorBackwardByCharacter] action on the | |
| /// [SemanticsNode] found by `finder`. | |
| /// | |
| /// If `shouldModifySelection` is true, then the cursor will begin or extend | |
| /// a selection. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.moveCursorBackwardByCharacter]. | |
| void moveCursorBackwardByCharacter( | |
| finders.FinderBase<SemanticsNode> finder, { | |
| bool shouldModifySelection = false, | |
| }) { | |
| performAction( | |
| finder, | |
| SemanticsAction.moveCursorBackwardByCharacter, | |
| args: shouldModifySelection, | |
| ); | |
| } | |
| /// Performs a [SemanticsAction.moveCursorBackwardByWord] action on the | |
| /// [SemanticsNode] found by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.moveCursorBackwardByWord]. | |
| void moveCursorBackwardByWord( | |
| finders.FinderBase<SemanticsNode> finder, { | |
| bool shouldModifySelection = false, | |
| }) { | |
| performAction(finder, SemanticsAction.moveCursorBackwardByWord, args: shouldModifySelection); | |
| } | |
| /// Performs a [SemanticsAction.setText] action on the [SemanticsNode] | |
| /// found by `finder` using the given `text`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.setText]. | |
| void setText(finders.FinderBase<SemanticsNode> finder, String text) { | |
| performAction(finder, SemanticsAction.setText, args: text); | |
| } | |
| /// Performs a [SemanticsAction.setSelection] action on the [SemanticsNode] | |
| /// found by `finder`. | |
| /// | |
| /// The `base` parameter is the start index of selection, and the `extent` | |
| /// parameter is the length of the selection. Each value should be limited | |
| /// between 0 and the length of the found [SemanticsNode]'s `value`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.setSelection]. | |
| void setSelection( | |
| finders.FinderBase<SemanticsNode> finder, { | |
| required int base, | |
| required int extent, | |
| }) { | |
| performAction( | |
| finder, | |
| SemanticsAction.setSelection, | |
| args: <String, int>{'base': base, 'extent': extent}, | |
| ); | |
| } | |
| /// Performs a [SemanticsAction.copy] action on the [SemanticsNode] | |
| /// found by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.copy]. | |
| void copy(finders.FinderBase<SemanticsNode> finder) { | |
| performAction(finder, SemanticsAction.copy); | |
| } | |
| /// Performs a [SemanticsAction.cut] action on the [SemanticsNode] | |
| /// found by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.cut]. | |
| void cut(finders.FinderBase<SemanticsNode> finder) { | |
| performAction(finder, SemanticsAction.cut); | |
| } | |
| /// Performs a [SemanticsAction.paste] action on the [SemanticsNode] | |
| /// found by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.paste]. | |
| void paste(finders.FinderBase<SemanticsNode> finder) { | |
| performAction(finder, SemanticsAction.paste); | |
| } | |
| /// Performs a [SemanticsAction.didGainAccessibilityFocus] action on the | |
| /// [SemanticsNode] found by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.didGainAccessibilityFocus]. | |
| void didGainAccessibilityFocus(finders.FinderBase<SemanticsNode> finder) { | |
| performAction(finder, SemanticsAction.didGainAccessibilityFocus); | |
| } | |
| /// Performs a [SemanticsAction.didLoseAccessibilityFocus] action on the | |
| /// [SemanticsNode] found by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.didLoseAccessibilityFocus]. | |
| void didLoseAccessibilityFocus(finders.FinderBase<SemanticsNode> finder) { | |
| performAction(finder, SemanticsAction.didLoseAccessibilityFocus); | |
| } | |
| /// Performs a [SemanticsAction.customAction] action on the | |
| /// [SemanticsNode] found by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.customAction]. | |
| void customAction(finders.FinderBase<SemanticsNode> finder, CustomSemanticsAction action) { | |
| performAction( | |
| finder, | |
| SemanticsAction.customAction, | |
| args: CustomSemanticsAction.getIdentifier(action), | |
| ); | |
| } | |
| /// Performs a [SemanticsAction.dismiss] action on the [SemanticsNode] | |
| /// found by `finder`. | |
| /// | |
| /// Throws a [StateError] if: | |
| /// * The given `finder` returns zero or more than one result. | |
| /// * The [SemanticsNode] found with `finder` does not support | |
| /// [SemanticsAction.dismiss]. | |
| void dismiss(finders.FinderBase<SemanticsNode> finder) { | |
| performAction(finder, SemanticsAction.dismiss); | |
| } | |
| } | |
| /// Class that programmatically interacts with widgets. | |
| /// | |
| /// For a variant of this class suited specifically for unit tests, see | |
| /// [WidgetTester]. For one suitable for live tests on a device, consider | |
| /// [LiveWidgetController]. | |
| /// | |
| /// Concrete subclasses must implement the [pump] method. | |
| abstract class WidgetController { | |
| /// Creates a widget controller that uses the given binding. | |
| WidgetController(this.binding); | |
| /// A reference to the current instance of the binding. | |
| final WidgetsBinding binding; | |
| /// The [TestPlatformDispatcher] that is being used in this test. | |
| /// | |
| /// This will be injected into the framework such that calls to | |
| /// [WidgetsBinding.platformDispatcher] will use this. This allows | |
| /// users to change platform specific properties for testing. | |
| /// | |
| /// See also: | |
| /// | |
| /// * [TestFlutterView] which allows changing view specific properties | |
| /// for testing | |
| /// * [view] and [viewOf] which are used to find | |
| /// [TestFlutterView]s from the widget tree | |
| TestPlatformDispatcher get platformDispatcher => | |
| binding.platformDispatcher as TestPlatformDispatcher; | |
| /// The [TestFlutterView] provided by default when testing with | |
| /// [WidgetTester.pumpWidget]. | |
| /// | |
| /// If the test uses multiple views, this will return the view that is painted | |
| /// into by [WidgetTester.pumpWidget]. If a different view needs to be | |
| /// accessed use [viewOf] to ensure that the view related to the widget being | |
| /// evaluated is the one that gets updated. | |
| /// | |
| /// See also: | |
| /// | |
| /// * [viewOf], which can find a [TestFlutterView] related to a given finder. | |
| /// This is how to modify view properties for testing when dealing with | |
| /// multiple views. | |
| TestFlutterView get view => platformDispatcher.implicitView!; | |
| /// Provides access to a [SemanticsController] for testing anything related to | |
| /// the [Semantics] tree. | |
| /// | |
| /// Assistive technologies, search engines, and other analysis tools all make | |
| /// use of the [Semantics] tree to determine the meaning of an application. | |
| /// If semantics has been disabled for the test, this will throw a [StateError]. | |
| SemanticsController get semantics { | |
| if (!binding.semanticsEnabled) { | |
| throw StateError( | |
| 'Semantics are not enabled. Enable them by passing ' | |
| '`semanticsEnabled: true` to `testWidgets`, or by manually creating a ' | |
| '`SemanticsHandle` with `WidgetController.ensureSemantics()`.', | |
| ); | |
| } | |
| return _semantics; | |
| } | |
| late final SemanticsController _semantics = SemanticsController._(this); | |
| // FINDER API | |
| // TODO(ianh): verify that the return values are of type T and throw | |
| // a good message otherwise, in all the generic methods below | |
| /// Finds the [TestFlutterView] that is the closest ancestor of the widget | |
| /// found by [finder]. | |
| /// | |
| /// [TestFlutterView] can be used to modify view specific properties for testing. | |
| /// | |
| /// See also: | |
| /// | |
| /// * [view] which returns the [TestFlutterView] used when only a single | |
| /// view is being used. | |
| TestFlutterView viewOf(finders.FinderBase<Element> finder) { | |
| return _viewOf(finder) as TestFlutterView; | |
| } | |
| FlutterView _viewOf(finders.FinderBase<Element> finder) { | |
| final FlutterView? view = _maybeViewOf(finder); | |
| if (view == null) { | |
| throw StateError( | |
| 'No FlutterView ancestor found for finder: ' | |
| '${finder.toString(describeSelf: true)}', | |
| ); | |
| } | |
| return view; | |
| } | |
| FlutterView? _maybeViewOf(finders.FinderBase<Element> finder) { | |
| final Iterable<View> views = widgetList<View>( | |
| finders.find.ancestor(of: finder, matching: finders.find.byType(View)), | |
| ); | |
| if (views.isEmpty) { | |
| return null; | |
| } | |
| return views.first.view; | |
| } | |
| /// Checks if `finder` exists in the tree. | |
| bool any(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| return finder.evaluate().isNotEmpty; | |
| } | |
| /// All widgets currently in the widget tree (lazy pre-order traversal). | |
| /// | |
| /// Can contain duplicates, since widgets can be used in multiple | |
| /// places in the widget tree. | |
| Iterable<Widget> get allWidgets { | |
| TestAsyncUtils.guardSync(); | |
| return allElements.map<Widget>((Element element) => element.widget); | |
| } | |
| /// The matching widget in the widget tree. | |
| /// | |
| /// Throws a [StateError] if `finder` is empty or matches more than | |
| /// one widget. | |
| /// | |
| /// * Use [firstWidget] if you expect to match several widgets but only want the first. | |
| /// * Use [widgetList] if you expect to match several widgets and want all of them. | |
| T widget<T extends Widget>(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| return finder.evaluate().single.widget as T; | |
| } | |
| /// The first matching widget according to a depth-first pre-order | |
| /// traversal of the widget tree. | |
| /// | |
| /// Throws a [StateError] if `finder` is empty. | |
| /// | |
| /// * Use [widget] if you only expect to match one widget. | |
| T firstWidget<T extends Widget>(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| return finder.evaluate().first.widget as T; | |
| } | |
| /// The matching widgets in the widget tree. | |
| /// | |
| /// * Use [widget] if you only expect to match one widget. | |
| /// * Use [firstWidget] if you expect to match several but only want the first. | |
| Iterable<T> widgetList<T extends Widget>(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| return finder.evaluate().map<T>((Element element) { | |
| final result = element.widget as T; | |
| return result; | |
| }); | |
| } | |
| /// Find all layers that are children of the provided [finder]. | |
| /// | |
| /// The [finder] must match exactly one element. | |
| Iterable<Layer> layerListOf(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| final Element element = finder.evaluate().single; | |
| final RenderObject object = element.renderObject!; | |
| var current = object; | |
| while (current.debugLayer == null) { | |
| current = current.parent!; | |
| } | |
| final ContainerLayer layer = current.debugLayer!; | |
| return _walkLayers(layer); | |
| } | |
| /// All elements currently in the widget tree (lazy pre-order traversal). | |
| /// | |
| /// The returned iterable is lazy. It does not walk the entire widget tree | |
| /// immediately, but rather a chunk at a time as the iteration progresses | |
| /// using [Iterator.moveNext]. | |
| Iterable<Element> get allElements { | |
| TestAsyncUtils.guardSync(); | |
| return collectAllElementsFrom(binding.rootElement!, skipOffstage: false); | |
| } | |
| /// The matching element in the widget tree. | |
| /// | |
| /// Throws a [StateError] if `finder` is empty or matches more than | |
| /// one element. | |
| /// | |
| /// * Use [firstElement] if you expect to match several elements but only want the first. | |
| /// * Use [elementList] if you expect to match several elements and want all of them. | |
| T element<T extends Element>(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| return finder.evaluate().single as T; | |
| } | |
| /// The first matching element according to a depth-first pre-order | |
| /// traversal of the widget tree. | |
| /// | |
| /// Throws a [StateError] if `finder` is empty. | |
| /// | |
| /// * Use [element] if you only expect to match one element. | |
| T firstElement<T extends Element>(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| return finder.evaluate().first as T; | |
| } | |
| /// The matching elements in the widget tree. | |
| /// | |
| /// * Use [element] if you only expect to match one element. | |
| /// * Use [firstElement] if you expect to match several but only want the first. | |
| Iterable<T> elementList<T extends Element>(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| return finder.evaluate().cast<T>(); | |
| } | |
| /// All states currently in the widget tree (lazy pre-order traversal). | |
| /// | |
| /// The returned iterable is lazy. It does not walk the entire widget tree | |
| /// immediately, but rather a chunk at a time as the iteration progresses | |
| /// using [Iterator.moveNext]. | |
| Iterable<State> get allStates { | |
| TestAsyncUtils.guardSync(); | |
| return allElements.whereType<StatefulElement>().map<State>( | |
| (StatefulElement element) => element.state, | |
| ); | |
| } | |
| /// The matching state in the widget tree. | |
| /// | |
| /// Throws a [StateError] if `finder` is empty, matches more than | |
| /// one state, or matches a widget that has no state. | |
| /// | |
| /// * Use [firstState] if you expect to match several states but only want the first. | |
| /// * Use [stateList] if you expect to match several states and want all of them. | |
| T state<T extends State>(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| return _stateOf<T>(finder.evaluate().single, finder); | |
| } | |
| /// The first matching state according to a depth-first pre-order | |
| /// traversal of the widget tree. | |
| /// | |
| /// Throws a [StateError] if `finder` is empty or if the first | |
| /// matching widget has no state. | |
| /// | |
| /// * Use [state] if you only expect to match one state. | |
| T firstState<T extends State>(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| return _stateOf<T>(finder.evaluate().first, finder); | |
| } | |
| /// The matching states in the widget tree. | |
| /// | |
| /// Throws a [StateError] if any of the elements in `finder` match a widget | |
| /// that has no state. | |
| /// | |
| /// * Use [state] if you only expect to match one state. | |
| /// * Use [firstState] if you expect to match several but only want the first. | |
| Iterable<T> stateList<T extends State>(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| return finder.evaluate().map<T>((Element element) => _stateOf<T>(element, finder)); | |
| } | |
| T _stateOf<T extends State>(Element element, finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| if (element is StatefulElement) { | |
| return element.state as T; | |
| } | |
| throw StateError( | |
| 'Widget of type ${element.widget.runtimeType}, with ${finder.describeMatch(finders.Plurality.many)}, is not a StatefulWidget.', | |
| ); | |
| } | |
| /// Render objects of all the widgets currently in the widget tree | |
| /// (lazy pre-order traversal). | |
| /// | |
| /// This will almost certainly include many duplicates since the | |
| /// render object of a [StatelessWidget] or [StatefulWidget] is the | |
| /// render object of its child; only [RenderObjectWidget]s have | |
| /// their own render object. | |
| Iterable<RenderObject> get allRenderObjects { | |
| TestAsyncUtils.guardSync(); | |
| return allElements.map<RenderObject>((Element element) => element.renderObject!); | |
| } | |
| /// The render object of the matching widget in the widget tree. | |
| /// | |
| /// Throws a [StateError] if `finder` is empty or matches more than | |
| /// one widget (even if they all have the same render object). | |
| /// | |
| /// * Use [firstRenderObject] if you expect to match several render objects but only want the first. | |
| /// * Use [renderObjectList] if you expect to match several render objects and want all of them. | |
| T renderObject<T extends RenderObject>(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| return finder.evaluate().single.renderObject! as T; | |
| } | |
| /// The render object of the first matching widget according to a | |
| /// depth-first pre-order traversal of the widget tree. | |
| /// | |
| /// Throws a [StateError] if `finder` is empty. | |
| /// | |
| /// * Use [renderObject] if you only expect to match one render object. | |
| T firstRenderObject<T extends RenderObject>(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| return finder.evaluate().first.renderObject! as T; | |
| } | |
| /// The render objects of the matching widgets in the widget tree. | |
| /// | |
| /// * Use [renderObject] if you only expect to match one render object. | |
| /// * Use [firstRenderObject] if you expect to match several but only want the first. | |
| Iterable<T> renderObjectList<T extends RenderObject>(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| return finder.evaluate().map<T>((Element element) { | |
| final result = element.renderObject! as T; | |
| return result; | |
| }); | |
| } | |
| /// Returns a list of all the [Layer] objects in the rendering. | |
| List<Layer> get layers { | |
| return <Layer>[ | |
| for (final RenderView renderView in binding.renderViews) | |
| ..._walkLayers(renderView.debugLayer!), | |
| ]; | |
| } | |
| Iterable<Layer> _walkLayers(Layer layer) sync* { | |
| TestAsyncUtils.guardSync(); | |
| yield layer; | |
| if (layer is ContainerLayer) { | |
| final ContainerLayer root = layer; | |
| Layer? child = root.firstChild; | |
| while (child != null) { | |
| yield* _walkLayers(child); | |
| child = child.nextSibling; | |
| } | |
| } | |
| } | |
| // INTERACTION | |
| /// Sends a 'handleScrollToTop' message to the test application via the | |
| /// [SystemChannels.statusBar] channel, to simulate an iOS status bar tap | |
| /// event. | |
| void simulateStatusBarTap() { | |
| final ByteData message = const JSONMethodCodec().encodeMethodCall( | |
| const MethodCall('handleScrollToTop'), | |
| ); | |
| binding.defaultBinaryMessenger.handlePlatformMessage( | |
| SystemChannels.statusBar.name, | |
| message, | |
| (ByteData? data) {}, | |
| ); | |
| } | |
| /// Dispatch a pointer down / pointer up sequence at the center of | |
| /// the given widget, assuming it is exposed. | |
| /// | |
| /// {@template flutter.flutter_test.WidgetController.tap.warnIfMissed} | |
| /// The `warnIfMissed` argument, if true (the default), causes a warning to be | |
| /// displayed on the console if the specified [Finder] indicates a widget and | |
| /// location that, were a pointer event to be sent to that location, would not | |
| /// actually send any events to the widget (e.g. because the widget is | |
| /// obscured, or the location is off-screen, or the widget is transparent to | |
| /// pointer events). | |
| /// | |
| /// Set the argument to false to silence that warning if you intend to not | |
| /// actually hit the specified element. | |
| /// {@endtemplate} | |
| /// | |
| /// 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<void> tap( | |
| finders.FinderBase<Element> finder, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| bool warnIfMissed = true, | |
| PointerDeviceKind kind = PointerDeviceKind.touch, | |
| }) { | |
| final FlutterView? view = _maybeViewOf(finder); | |
| return tapAt( | |
| getCenter(finder, warnIfMissed: warnIfMissed, callee: 'tap'), | |
| pointer: pointer, | |
| buttons: buttons, | |
| kind: kind, | |
| view: view, | |
| ); | |
| } | |
| /// Dispatch a pointer down / pointer up sequence at a hit-testable | |
| /// [InlineSpan] (typically a [TextSpan]) within the given text range. | |
| /// | |
| /// This method performs a more spatially precise tap action on a piece of | |
| /// static text, than the widget-based [tap] method. | |
| /// | |
| /// The given [Finder] must find one and only one matching substring, and the | |
| /// substring must be hit-testable (meaning, it must not be off-screen, or be | |
| /// obscured by other widgets, or in a disabled widget). Otherwise this method | |
| /// throws a [FlutterError]. | |
| /// | |
| /// If the target substring contains more than one hit-testable [InlineSpan]s, | |
| /// [tapOnText] taps on one of them, but does not guarantee which. | |
| /// | |
| /// The `pointer` and `button` arguments specify [PointerEvent.pointer] and | |
| /// [PointerEvent.buttons] of the tap event. | |
| Future<void> tapOnText( | |
| finders.FinderBase<finders.TextRangeContext> textRangeFinder, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| }) { | |
| final Iterable<finders.TextRangeContext> ranges = textRangeFinder.evaluate(); | |
| if (ranges.isEmpty) { | |
| throw FlutterError(textRangeFinder.toString()); | |
| } | |
| if (ranges.length > 1) { | |
| throw FlutterError( | |
| '$textRangeFinder. The "tapOnText" method needs a single non-empty TextRange.', | |
| ); | |
| } | |
| final Offset? tapLocation = _findHitTestableOffsetIn(ranges.single); | |
| if (tapLocation == null) { | |
| final finders.TextRangeContext found = textRangeFinder.evaluate().single; | |
| throw FlutterError.fromParts(<DiagnosticsNode>[ | |
| ErrorSummary('Finder specifies a TextRange that can not receive pointer events.'), | |
| ErrorDescription('The finder used was: ${textRangeFinder.toString(describeSelf: true)}'), | |
| ErrorDescription( | |
| 'Found a matching substring in a static text widget, within ${found.textRange}.', | |
| ), | |
| ErrorDescription( | |
| 'But the "tapOnText" method could not find a hit-testable Offset with in that text range.', | |
| ), | |
| found.renderObject.toDiagnosticsNode( | |
| name: 'The RenderBox of that static text widget was', | |
| style: DiagnosticsTreeStyle.shallow, | |
| ), | |
| ]); | |
| } | |
| return tapAt(tapLocation, pointer: pointer, buttons: buttons, view: ranges.single.view.view); | |
| } | |
| /// Dispatch a pointer down / pointer up sequence at the given location. | |
| Future<void> tapAt( | |
| Offset location, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| PointerDeviceKind kind = PointerDeviceKind.touch, | |
| FlutterView? view, | |
| }) { | |
| return TestAsyncUtils.guard<void>(() async { | |
| final TestGesture gesture = await startGesture( | |
| location, | |
| pointer: pointer, | |
| buttons: buttons, | |
| kind: kind, | |
| view: view, | |
| ); | |
| await gesture.up(); | |
| }); | |
| } | |
| /// Dispatch a pointer down at the center of the given widget, assuming it is | |
| /// exposed. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed} | |
| /// | |
| /// The return value is a [TestGesture] object that can be used to continue the | |
| /// gesture (e.g. moving the pointer or releasing it). | |
| /// | |
| /// See also: | |
| /// | |
| /// * [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<TestGesture> press( | |
| finders.FinderBase<Element> finder, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| bool warnIfMissed = true, | |
| PointerDeviceKind kind = PointerDeviceKind.touch, | |
| }) { | |
| final FlutterView? view = _maybeViewOf(finder); | |
| return TestAsyncUtils.guard<TestGesture>(() { | |
| return startGesture( | |
| getCenter(finder, warnIfMissed: warnIfMissed, callee: 'press'), | |
| pointer: pointer, | |
| buttons: buttons, | |
| kind: kind, | |
| view: view, | |
| ); | |
| }); | |
| } | |
| /// Dispatch a pointer down / pointer up sequence (with a delay of | |
| /// [kLongPressTimeout] + [kPressTimeout] between the two events) at the | |
| /// center of the given widget, assuming it is exposed. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed} | |
| /// | |
| /// For example, consider a widget that, when long-pressed, shows an overlay | |
| /// that obscures the original widget. A test for that widget might first | |
| /// long-press that widget with `warnIfMissed` at its default value true, then | |
| /// 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<void> longPress( | |
| finders.FinderBase<Element> finder, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| bool warnIfMissed = true, | |
| PointerDeviceKind kind = PointerDeviceKind.touch, | |
| }) { | |
| final FlutterView? view = _maybeViewOf(finder); | |
| return longPressAt( | |
| getCenter(finder, warnIfMissed: warnIfMissed, callee: 'longPress'), | |
| pointer: pointer, | |
| buttons: buttons, | |
| kind: kind, | |
| view: view, | |
| ); | |
| } | |
| /// Dispatch a pointer down / pointer up sequence at the given location with | |
| /// a delay of [kLongPressTimeout] + [kPressTimeout] between the two events. | |
| Future<void> longPressAt( | |
| Offset location, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| PointerDeviceKind kind = PointerDeviceKind.touch, | |
| FlutterView? view, | |
| }) { | |
| return TestAsyncUtils.guard<void>(() async { | |
| final TestGesture gesture = await startGesture( | |
| location, | |
| pointer: pointer, | |
| buttons: buttons, | |
| kind: kind, | |
| view: view, | |
| ); | |
| await pump(kLongPressTimeout + kPressTimeout); | |
| await gesture.up(); | |
| }); | |
| } | |
| /// Attempts a fling gesture starting from the center of the given | |
| /// widget, moving the given distance, reaching the given speed. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed} | |
| /// | |
| /// {@template flutter.flutter_test.WidgetController.fling.offset} | |
| /// The `offset` represents a distance the pointer moves in the global | |
| /// coordinate system of the screen. | |
| /// | |
| /// Positive [Offset.dy] values mean the pointer moves downward. Negative | |
| /// [Offset.dy] values mean the pointer moves upwards. Accordingly, positive | |
| /// [Offset.dx] values mean the pointer moves towards the right. Negative | |
| /// [Offset.dx] values mean the pointer moves towards left. | |
| /// {@endtemplate} | |
| /// | |
| /// {@template flutter.flutter_test.WidgetController.fling} | |
| /// This can pump frames. | |
| /// | |
| /// Exactly 50 pointer events are synthesized. | |
| /// | |
| /// The `speed` is in pixels per second in the direction given by `offset`. | |
| /// | |
| /// The `offset` and `speed` control the interval between each pointer event. | |
| /// For example, if the `offset` is 200 pixels down, and the `speed` is 800 | |
| /// pixels per second, the pointer events will be sent for each increment | |
| /// of 4 pixels (200/50), over 250ms (200/800), meaning events will be sent | |
| /// every 1.25ms (250/200). | |
| /// | |
| /// To make tests more realistic, frames may be pumped during this time (using | |
| /// calls to [pump]). If the total duration is longer than `frameInterval`, | |
| /// then one frame is pumped each time that amount of time elapses while | |
| /// sending events, or each time an event is synthesized, whichever is rarer. | |
| /// | |
| /// See [LiveTestWidgetsFlutterBindingFramePolicy.benchmarkLive] if the method | |
| /// is used in a live environment and accurate time control is important. | |
| /// | |
| /// The `initialOffset` argument, if non-zero, causes the pointer to first | |
| /// apply that offset, then pump a delay of `initialOffsetDelay`. This can be | |
| /// used to simulate a drag followed by a fling, including dragging in the | |
| /// opposite direction of the fling (e.g. dragging 200 pixels to the right, | |
| /// then fling to the left over 200 pixels, ending at the exact point that the | |
| /// drag started). | |
| /// {@endtemplate} | |
| /// | |
| /// 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<void> fling( | |
| finders.FinderBase<Element> finder, | |
| Offset offset, | |
| double speed, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| Duration frameInterval = const Duration(milliseconds: 16), | |
| Offset initialOffset = Offset.zero, | |
| Duration initialOffsetDelay = const Duration(seconds: 1), | |
| bool warnIfMissed = true, | |
| PointerDeviceKind deviceKind = PointerDeviceKind.touch, | |
| }) { | |
| final FlutterView? view = _maybeViewOf(finder); | |
| return flingFrom( | |
| getCenter(finder, warnIfMissed: warnIfMissed, callee: 'fling'), | |
| offset, | |
| speed, | |
| pointer: pointer, | |
| buttons: buttons, | |
| frameInterval: frameInterval, | |
| initialOffset: initialOffset, | |
| initialOffsetDelay: initialOffsetDelay, | |
| deviceKind: deviceKind, | |
| view: view, | |
| ); | |
| } | |
| /// Attempts a fling gesture starting from the given location, moving the | |
| /// given distance, reaching the given speed. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.fling} | |
| /// | |
| /// A fling is essentially a drag that ends at a particular speed. If you | |
| /// just want to drag and end without a fling, use [dragFrom]. | |
| Future<void> flingFrom( | |
| Offset startLocation, | |
| Offset offset, | |
| double speed, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| Duration frameInterval = const Duration(milliseconds: 16), | |
| Offset initialOffset = Offset.zero, | |
| Duration initialOffsetDelay = const Duration(seconds: 1), | |
| PointerDeviceKind deviceKind = PointerDeviceKind.touch, | |
| FlutterView? view, | |
| }) { | |
| assert(offset.distance > 0.0); | |
| assert(speed > 0.0); // speed is pixels/second | |
| return TestAsyncUtils.guard<void>(() async { | |
| final testPointer = TestPointer(pointer ?? _getNextPointer(), deviceKind, null, buttons); | |
| const kMoveCount = | |
| 50; // Needs to be >= kHistorySize, see _LeastSquaresVelocityTrackerStrategy | |
| final double timeStampDelta = 1000000.0 * offset.distance / (kMoveCount * speed); | |
| var timeStamp = 0.0; | |
| var lastTimeStamp = timeStamp; | |
| await sendEventToBinding( | |
| testPointer.down( | |
| startLocation, | |
| timeStamp: Duration(microseconds: timeStamp.round()), | |
| view: view, | |
| ), | |
| ); | |
| if (initialOffset.distance > 0.0) { | |
| await sendEventToBinding( | |
| testPointer.move( | |
| startLocation + initialOffset, | |
| timeStamp: Duration(microseconds: timeStamp.round()), | |
| ), | |
| ); | |
| timeStamp += initialOffsetDelay.inMicroseconds; | |
| await pump(initialOffsetDelay); | |
| } | |
| for (var i = 0; i <= kMoveCount; i += 1) { | |
| final Offset location = | |
| startLocation + initialOffset + Offset.lerp(Offset.zero, offset, i / kMoveCount)!; | |
| await sendEventToBinding( | |
| testPointer.move( | |
| location, | |
| timeStamp: Duration(microseconds: timeStamp.round()), | |
| view: view, | |
| ), | |
| ); | |
| timeStamp += timeStampDelta; | |
| if (timeStamp - lastTimeStamp > frameInterval.inMicroseconds) { | |
| await pump(Duration(microseconds: (timeStamp - lastTimeStamp).truncate())); | |
| lastTimeStamp = timeStamp; | |
| } | |
| } | |
| await sendEventToBinding( | |
| testPointer.up( | |
| timeStamp: Duration(microseconds: timeStamp.round()), | |
| view: view, | |
| ), | |
| ); | |
| }); | |
| } | |
| /// Attempts a trackpad fling gesture starting from the center of the given | |
| /// widget, moving the given distance, reaching the given speed. A trackpad | |
| /// fling sends PointerPanZoom events instead of a sequence of touch events. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed} | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.fling} | |
| /// | |
| /// 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<void> trackpadFling( | |
| finders.FinderBase<Element> finder, | |
| Offset offset, | |
| double speed, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| Duration frameInterval = const Duration(milliseconds: 16), | |
| Offset initialOffset = Offset.zero, | |
| Duration initialOffsetDelay = const Duration(seconds: 1), | |
| bool warnIfMissed = true, | |
| }) { | |
| final FlutterView? view = _maybeViewOf(finder); | |
| return trackpadFlingFrom( | |
| getCenter(finder, warnIfMissed: warnIfMissed, callee: 'fling'), | |
| offset, | |
| speed, | |
| pointer: pointer, | |
| buttons: buttons, | |
| frameInterval: frameInterval, | |
| initialOffset: initialOffset, | |
| initialOffsetDelay: initialOffsetDelay, | |
| view: view, | |
| ); | |
| } | |
| /// Attempts a fling gesture starting from the given location, moving the | |
| /// given distance, reaching the given speed. A trackpad fling sends | |
| /// PointerPanZoom events instead of a sequence of touch events. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.fling} | |
| /// | |
| /// A fling is essentially a drag that ends at a particular speed. If you | |
| /// just want to drag and end without a fling, use [dragFrom]. | |
| Future<void> trackpadFlingFrom( | |
| Offset startLocation, | |
| Offset offset, | |
| double speed, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| Duration frameInterval = const Duration(milliseconds: 16), | |
| Offset initialOffset = Offset.zero, | |
| Duration initialOffsetDelay = const Duration(seconds: 1), | |
| FlutterView? view, | |
| }) { | |
| assert(offset.distance > 0.0); | |
| assert(speed > 0.0); // speed is pixels/second | |
| return TestAsyncUtils.guard<void>(() async { | |
| final testPointer = TestPointer( | |
| pointer ?? _getNextPointer(), | |
| PointerDeviceKind.trackpad, | |
| null, | |
| buttons, | |
| ); | |
| const kMoveCount = | |
| 50; // Needs to be >= kHistorySize, see _LeastSquaresVelocityTrackerStrategy | |
| final double timeStampDelta = 1000000.0 * offset.distance / (kMoveCount * speed); | |
| var timeStamp = 0.0; | |
| var lastTimeStamp = timeStamp; | |
| await sendEventToBinding( | |
| testPointer.panZoomStart( | |
| startLocation, | |
| timeStamp: Duration(microseconds: timeStamp.round()), | |
| view: view, | |
| ), | |
| ); | |
| if (initialOffset.distance > 0.0) { | |
| await sendEventToBinding( | |
| testPointer.panZoomUpdate( | |
| startLocation, | |
| pan: initialOffset, | |
| timeStamp: Duration(microseconds: timeStamp.round()), | |
| view: view, | |
| ), | |
| ); | |
| timeStamp += initialOffsetDelay.inMicroseconds; | |
| await pump(initialOffsetDelay); | |
| } | |
| for (var i = 0; i <= kMoveCount; i += 1) { | |
| final Offset pan = initialOffset + Offset.lerp(Offset.zero, offset, i / kMoveCount)!; | |
| await sendEventToBinding( | |
| testPointer.panZoomUpdate( | |
| startLocation, | |
| pan: pan, | |
| timeStamp: Duration(microseconds: timeStamp.round()), | |
| view: view, | |
| ), | |
| ); | |
| timeStamp += timeStampDelta; | |
| if (timeStamp - lastTimeStamp > frameInterval.inMicroseconds) { | |
| await pump(Duration(microseconds: (timeStamp - lastTimeStamp).truncate())); | |
| lastTimeStamp = timeStamp; | |
| } | |
| } | |
| await sendEventToBinding( | |
| testPointer.panZoomEnd( | |
| timeStamp: Duration(microseconds: timeStamp.round()), | |
| view: view, | |
| ), | |
| ); | |
| }); | |
| } | |
| /// A simulator of how the framework handles a series of [PointerEvent]s | |
| /// received from the Flutter engine. | |
| /// | |
| /// The [PointerEventRecord.timeDelay] is used as the time delay of the events | |
| /// injection relative to the starting point of the method call. | |
| /// | |
| /// Returns a list of the difference between the real delay time when the | |
| /// [PointerEventRecord.events] are processed and | |
| /// [PointerEventRecord.timeDelay]. | |
| /// - For [AutomatedTestWidgetsFlutterBinding] where the clock is fake, the | |
| /// return value should be exact zeros. | |
| /// - For [LiveTestWidgetsFlutterBinding], the values are typically small | |
| /// positives, meaning the event happens a little later than the set time, | |
| /// but a very small portion may have a tiny negative value for about tens of | |
| /// microseconds. This is due to the nature of [Future.delayed]. | |
| /// | |
| /// The closer the return values are to zero the more faithful it is to the | |
| /// `records`. | |
| /// | |
| /// See [PointerEventRecord]. | |
| Future<List<Duration>> handlePointerEventRecord(List<PointerEventRecord> records); | |
| /// Called to indicate that there should be a new frame after an optional | |
| /// delay. | |
| /// | |
| /// The frame is pumped after a delay of [duration] if [duration] is not null, | |
| /// or immediately otherwise. | |
| /// | |
| /// This is invoked by [flingFrom], for instance, so that the sequence of | |
| /// pointer events occurs over time. | |
| /// | |
| /// The [WidgetTester] subclass implements this by deferring to the [binding]. | |
| /// | |
| /// See also: | |
| /// | |
| /// * [SchedulerBinding.endOfFrame], which returns a future that could be | |
| /// appropriate to return in the implementation of this method. | |
| Future<void> pump([Duration duration]); | |
| /// Repeatedly calls [pump] with the given `duration` until there are no | |
| /// longer any frames scheduled. This will call [pump] at least once, even if | |
| /// no frames are scheduled when the function is called, to flush any pending | |
| /// microtasks which may themselves schedule a frame. | |
| /// | |
| /// This essentially waits for all animations to have completed. | |
| /// | |
| /// If it takes longer that the given `timeout` to settle, then the test will | |
| /// fail (this method will throw an exception). In particular, this means that | |
| /// if there is an infinite animation in progress (for example, if there is an | |
| /// indeterminate progress indicator spinning), this method will throw. | |
| /// | |
| /// The default timeout is ten minutes, which is longer than most reasonable | |
| /// finite animations would last. | |
| /// | |
| /// If the function returns, it returns the number of pumps that it performed. | |
| /// | |
| /// In general, it is better practice to figure out exactly why each frame is | |
| /// needed, and then to [pump] exactly as many frames as necessary. This will | |
| /// help catch regressions where, for instance, an animation is being started | |
| /// one frame later than it should. | |
| /// | |
| /// Alternatively, one can check that the return value from this function | |
| /// matches the expected number of pumps. | |
| Future<int> pumpAndSettle([Duration duration = const Duration(milliseconds: 100)]); | |
| /// Attempts to drag the given widget by the given offset, by | |
| /// starting a drag in the middle of the widget. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed} | |
| /// | |
| /// If you want the drag to end with a speed so that the gesture recognition | |
| /// system identifies the gesture as a fling, consider using [fling] instead. | |
| /// | |
| /// The operation happens at once. If you want the drag to last for a period | |
| /// of time, consider using [timedDrag]. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.fling.offset} | |
| /// | |
| /// {@template flutter.flutter_test.WidgetController.drag} | |
| /// By default, if the x or y component of offset is greater than | |
| /// [kDragSlopDefault], the gesture is broken up into two separate moves | |
| /// calls. Changing `touchSlopX` or `touchSlopY` will change the minimum | |
| /// amount of movement in the respective axis before the drag will be broken | |
| /// into multiple calls. To always send the drag with just a single call to | |
| /// [TestGesture.moveBy], `touchSlopX` and `touchSlopY` should be set to 0. | |
| /// | |
| /// Breaking the drag into multiple moves is necessary for accurate execution | |
| /// of drag update calls with a [DragStartBehavior] variable set to | |
| /// [DragStartBehavior.start]. Without such a change, the dragUpdate callback | |
| /// from a drag recognizer will never be invoked. | |
| /// | |
| /// To force this function to a send a single move event, the `touchSlopX` and | |
| /// `touchSlopY` variables should be set to 0. However, generally, these values | |
| /// should be left to their default values. | |
| /// {@endtemplate} | |
| Future<void> drag( | |
| finders.FinderBase<Element> finder, | |
| Offset offset, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| double touchSlopX = kDragSlopDefault, | |
| double touchSlopY = kDragSlopDefault, | |
| bool warnIfMissed = true, | |
| PointerDeviceKind kind = PointerDeviceKind.touch, | |
| }) { | |
| final FlutterView? view = _maybeViewOf(finder); | |
| return dragFrom( | |
| getCenter(finder, warnIfMissed: warnIfMissed, callee: 'drag'), | |
| offset, | |
| pointer: pointer, | |
| buttons: buttons, | |
| touchSlopX: touchSlopX, | |
| touchSlopY: touchSlopY, | |
| kind: kind, | |
| view: view, | |
| ); | |
| } | |
| /// Attempts a drag gesture consisting of a pointer down, a move by | |
| /// the given offset, and a pointer up. | |
| /// | |
| /// If you want the drag to end with a speed so that the gesture recognition | |
| /// system identifies the gesture as a fling, consider using [flingFrom] | |
| /// instead. | |
| /// | |
| /// The operation happens at once. If you want the drag to last for a period | |
| /// of time, consider using [timedDragFrom]. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.drag} | |
| Future<void> dragFrom( | |
| Offset startLocation, | |
| Offset offset, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| double touchSlopX = kDragSlopDefault, | |
| double touchSlopY = kDragSlopDefault, | |
| PointerDeviceKind kind = PointerDeviceKind.touch, | |
| FlutterView? view, | |
| }) { | |
| assert(kDragSlopDefault > kTouchSlop); | |
| return TestAsyncUtils.guard<void>(() async { | |
| final TestGesture gesture = await startGesture( | |
| startLocation, | |
| pointer: pointer, | |
| buttons: buttons, | |
| kind: kind, | |
| view: view, | |
| ); | |
| final double xSign = offset.dx.sign; | |
| final double ySign = offset.dy.sign; | |
| final double offsetX = offset.dx; | |
| final double offsetY = offset.dy; | |
| final bool separateX = offset.dx.abs() > touchSlopX && touchSlopX > 0; | |
| final bool separateY = offset.dy.abs() > touchSlopY && touchSlopY > 0; | |
| if (separateY || separateX) { | |
| final double offsetSlope = offsetY / offsetX; | |
| final double inverseOffsetSlope = offsetX / offsetY; | |
| final double slopSlope = touchSlopY / touchSlopX; | |
| final double absoluteOffsetSlope = offsetSlope.abs(); | |
| final double signedSlopX = touchSlopX * xSign; | |
| final double signedSlopY = touchSlopY * ySign; | |
| if (absoluteOffsetSlope != slopSlope) { | |
| // The drag goes through one or both of the extents of the edges of the box. | |
| if (absoluteOffsetSlope < slopSlope) { | |
| assert(offsetX.abs() > touchSlopX); | |
| // The drag goes through the vertical edge of the box. | |
| // It is guaranteed that the |offsetX| > touchSlopX. | |
| final double diffY = offsetSlope.abs() * touchSlopX * ySign; | |
| // The vector from the origin to the vertical edge. | |
| await gesture.moveBy(Offset(signedSlopX, diffY), view: view); | |
| if (offsetY.abs() <= touchSlopY) { | |
| // The drag ends on or before getting to the horizontal extension of the horizontal edge. | |
| await gesture.moveBy(Offset(offsetX - signedSlopX, offsetY - diffY), view: view); | |
| } else { | |
| final double diffY2 = signedSlopY - diffY; | |
| final double diffX2 = inverseOffsetSlope * diffY2; | |
| // The vector from the edge of the box to the horizontal extension of the horizontal edge. | |
| await gesture.moveBy(Offset(diffX2, diffY2), view: view); | |
| await gesture.moveBy( | |
| Offset(offsetX - diffX2 - signedSlopX, offsetY - signedSlopY), | |
| view: view, | |
| ); | |
| } | |
| } else { | |
| assert(offsetY.abs() > touchSlopY); | |
| // The drag goes through the horizontal edge of the box. | |
| // It is guaranteed that the |offsetY| > touchSlopY. | |
| final double diffX = inverseOffsetSlope.abs() * touchSlopY * xSign; | |
| // The vector from the origin to the vertical edge. | |
| await gesture.moveBy(Offset(diffX, signedSlopY), view: view); | |
| if (offsetX.abs() <= touchSlopX) { | |
| // The drag ends on or before getting to the vertical extension of the vertical edge. | |
| await gesture.moveBy(Offset(offsetX - diffX, offsetY - signedSlopY), view: view); | |
| } else { | |
| final double diffX2 = signedSlopX - diffX; | |
| final double diffY2 = offsetSlope * diffX2; | |
| // The vector from the edge of the box to the vertical extension of the vertical edge. | |
| await gesture.moveBy(Offset(diffX2, diffY2), view: view); | |
| await gesture.moveBy( | |
| Offset(offsetX - signedSlopX, offsetY - diffY2 - signedSlopY), | |
| view: view, | |
| ); | |
| } | |
| } | |
| } else { | |
| // The drag goes through the corner of the box. | |
| await gesture.moveBy(Offset(signedSlopX, signedSlopY), view: view); | |
| await gesture.moveBy(Offset(offsetX - signedSlopX, offsetY - signedSlopY), view: view); | |
| } | |
| } else { | |
| // The drag ends inside the box. | |
| await gesture.moveBy(offset, view: view); | |
| } | |
| await gesture.up(view: view); | |
| }); | |
| } | |
| /// Attempts to drag the given widget by the given offset in the `duration` | |
| /// time, starting in the middle of the widget. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed} | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.fling.offset} | |
| /// | |
| /// This is the timed version of [drag]. This may or may not result in a | |
| /// [fling] or ballistic animation, depending on the speed from | |
| /// `offset/duration`. | |
| /// | |
| /// {@template flutter.flutter_test.WidgetController.timedDrag} | |
| /// The move events are sent at a given `frequency` in Hz (or events per | |
| /// second). It defaults to 60Hz. | |
| /// | |
| /// The movement is linear in time. | |
| /// | |
| /// See also [LiveTestWidgetsFlutterBindingFramePolicy.benchmarkLive] for | |
| /// more accurate time control. | |
| /// {@endtemplate} | |
| Future<void> timedDrag( | |
| finders.FinderBase<Element> finder, | |
| Offset offset, | |
| Duration duration, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| double frequency = 60.0, | |
| bool warnIfMissed = true, | |
| FlutterView? view, | |
| }) { | |
| return timedDragFrom( | |
| getCenter(finder, warnIfMissed: warnIfMissed, callee: 'timedDrag'), | |
| offset, | |
| duration, | |
| pointer: pointer, | |
| buttons: buttons, | |
| frequency: frequency, | |
| view: view, | |
| ); | |
| } | |
| /// Attempts a series of [PointerEvent]s to simulate a drag operation in the | |
| /// `duration` time. | |
| /// | |
| /// This is the timed version of [dragFrom]. This may or may not result in a | |
| /// [flingFrom] or ballistic animation, depending on the speed from | |
| /// `offset/duration`. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.timedDrag} | |
| Future<void> timedDragFrom( | |
| Offset startLocation, | |
| Offset offset, | |
| Duration duration, { | |
| int? pointer, | |
| int buttons = kPrimaryButton, | |
| double frequency = 60.0, | |
| FlutterView? view, | |
| }) { | |
| assert(frequency > 0); | |
| final int intervals = duration.inMicroseconds * frequency ~/ 1E6; | |
| assert(intervals > 1); | |
| pointer ??= _getNextPointer(); | |
| final timeStamps = <Duration>[ | |
| for (int t = 0; t <= intervals; t += 1) duration * t ~/ intervals, | |
| ]; | |
| final offsets = <Offset>[ | |
| startLocation, | |
| for (int t = 0; t <= intervals; t += 1) startLocation + offset * (t / intervals), | |
| ]; | |
| final records = <PointerEventRecord>[ | |
| PointerEventRecord(Duration.zero, <PointerEvent>[ | |
| PointerAddedEvent( | |
| position: startLocation, | |
| viewId: view?.viewId ?? WidgetsBinding.instance.platformDispatcher.implicitView!.viewId, | |
| ), | |
| PointerDownEvent(position: startLocation, pointer: pointer, buttons: buttons), | |
| ]), | |
| ...<PointerEventRecord>[ | |
| for (int t = 0; t <= intervals; t += 1) | |
| PointerEventRecord(timeStamps[t], <PointerEvent>[ | |
| PointerMoveEvent( | |
| viewId: | |
| view?.viewId ?? WidgetsBinding.instance.platformDispatcher.implicitView!.viewId, | |
| timeStamp: timeStamps[t], | |
| position: offsets[t + 1], | |
| delta: offsets[t + 1] - offsets[t], | |
| pointer: pointer, | |
| buttons: buttons, | |
| ), | |
| ]), | |
| ], | |
| PointerEventRecord(duration, <PointerEvent>[ | |
| PointerUpEvent( | |
| viewId: view?.viewId ?? WidgetsBinding.instance.platformDispatcher.implicitView!.viewId, | |
| timeStamp: duration, | |
| position: offsets.last, | |
| pointer: pointer, | |
| // The PointerData received from the engine with | |
| // change = PointerChange.up, which translates to PointerUpEvent, | |
| // doesn't provide the button field. | |
| // buttons: buttons, | |
| ), | |
| ]), | |
| ]; | |
| return TestAsyncUtils.guard<void>(() async { | |
| await handlePointerEventRecord(records); | |
| }); | |
| } | |
| /// The next available pointer identifier. | |
| /// | |
| /// This is the default pointer identifier that will be used the next time the | |
| /// [startGesture] method is called without an explicit pointer identifier. | |
| int get nextPointer => _nextPointer; | |
| static int _nextPointer = 1; | |
| static int _getNextPointer() { | |
| final int result = _nextPointer; | |
| _nextPointer += 1; | |
| return result; | |
| } | |
| TestGesture _createGesture({ | |
| int? pointer, | |
| required PointerDeviceKind kind, | |
| required int buttons, | |
| }) { | |
| return TestGesture( | |
| dispatcher: sendEventToBinding, | |
| kind: kind, | |
| pointer: pointer ?? _getNextPointer(), | |
| buttons: buttons, | |
| ); | |
| } | |
| /// Creates gesture and returns the [TestGesture] object which you can use | |
| /// to continue the gesture using calls on the [TestGesture] object. | |
| /// | |
| /// You can use [startGesture] instead if your gesture begins with a down | |
| /// event. | |
| Future<TestGesture> createGesture({ | |
| int? pointer, | |
| PointerDeviceKind kind = PointerDeviceKind.touch, | |
| int buttons = kPrimaryButton, | |
| }) async { | |
| return _createGesture(pointer: pointer, kind: kind, buttons: buttons); | |
| } | |
| /// Creates a gesture with an initial appropriate starting gesture at a | |
| /// particular point, and returns the [TestGesture] object which you can use | |
| /// to continue the gesture. Usually, the starting gesture will be a down event, | |
| /// but if [kind] is set to [PointerDeviceKind.trackpad], the gesture will start | |
| /// with a panZoomStart gesture. | |
| /// | |
| /// You can use [createGesture] if your gesture doesn't begin with an initial | |
| /// down or panZoomStart gesture. | |
| /// | |
| /// See also: | |
| /// * [WidgetController.drag], a method to simulate a drag. | |
| /// * [WidgetController.timedDrag], a method to simulate the drag of a given | |
| /// widget in a given duration. It sends move events at a given frequency and | |
| /// it is useful when there are listeners involved. | |
| /// * [WidgetController.fling], a method to simulate a fling. | |
| Future<TestGesture> startGesture( | |
| Offset downLocation, { | |
| int? pointer, | |
| PointerDeviceKind kind = PointerDeviceKind.touch, | |
| int buttons = kPrimaryButton, | |
| FlutterView? view, | |
| }) async { | |
| final TestGesture result = _createGesture(pointer: pointer, kind: kind, buttons: buttons); | |
| if (kind == PointerDeviceKind.trackpad) { | |
| await result.panZoomStart(downLocation, view: view); | |
| } else { | |
| await result.down(downLocation, view: view); | |
| } | |
| return result; | |
| } | |
| /// Forwards the given location to the binding's hitTest logic. | |
| HitTestResult hitTestOnBinding(Offset location, {int? viewId}) { | |
| viewId ??= view.viewId; | |
| final result = HitTestResult(); | |
| binding.hitTestInView(result, location, viewId); | |
| return result; | |
| } | |
| /// Forwards the given pointer event to the binding. | |
| Future<void> sendEventToBinding(PointerEvent event) { | |
| return TestAsyncUtils.guard<void>(() async { | |
| binding.handlePointerEvent(event); | |
| }); | |
| } | |
| /// Calls [debugPrint] with the given message. | |
| /// | |
| /// This is overridden by the WidgetTester subclass to use the test binding's | |
| /// [TestWidgetsFlutterBinding.debugPrintOverride], so that it appears on the | |
| /// console even if the test is logging output from the application. | |
| @protected | |
| void printToConsole(String message) { | |
| debugPrint(message); | |
| } | |
| // GEOMETRY | |
| /// Returns the point at the center of the given widget. | |
| /// | |
| /// {@template flutter.flutter_test.WidgetController.getCenter.warnIfMissed} | |
| /// If `warnIfMissed` is true (the default is false), then the returned | |
| /// coordinate is checked to see if a hit test at the returned location would | |
| /// actually include the specified element in the [HitTestResult], and if not, | |
| /// a warning is printed to the console. | |
| /// | |
| /// The `callee` argument is used to identify the method that should be | |
| /// referenced in messages regarding `warnIfMissed`. It can be ignored unless | |
| /// this method is being called from another that is forwarding its own | |
| /// `warnIfMissed` parameter (see e.g. the implementation of [tap]). | |
| /// {@endtemplate} | |
| Offset getCenter( | |
| finders.FinderBase<Element> 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( | |
| finders.FinderBase<Element> finder, { | |
| bool warnIfMissed = false, | |
| String callee = 'getTopLeft', | |
| }) { | |
| return _getElementPoint( | |
| finder, | |
| (Size size) => Offset.zero, | |
| warnIfMissed: warnIfMissed, | |
| callee: callee, | |
| ); | |
| } | |
| /// Returns the point at the top right of the given widget. This | |
| /// point is not inside the object's hit test area. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed} | |
| Offset getTopRight( | |
| finders.FinderBase<Element> finder, { | |
| bool warnIfMissed = false, | |
| String callee = 'getTopRight', | |
| }) { | |
| return _getElementPoint( | |
| finder, | |
| (Size size) => size.topRight(Offset.zero), | |
| warnIfMissed: warnIfMissed, | |
| callee: callee, | |
| ); | |
| } | |
| /// Returns the point at the bottom left of the given widget. This | |
| /// point is not inside the object's hit test area. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed} | |
| Offset getBottomLeft( | |
| finders.FinderBase<Element> finder, { | |
| bool warnIfMissed = false, | |
| String callee = 'getBottomLeft', | |
| }) { | |
| return _getElementPoint( | |
| finder, | |
| (Size size) => size.bottomLeft(Offset.zero), | |
| warnIfMissed: warnIfMissed, | |
| callee: callee, | |
| ); | |
| } | |
| /// Returns the point at the bottom right of the given widget. This | |
| /// point is not inside the object's hit test area. | |
| /// | |
| /// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed} | |
| Offset getBottomRight( | |
| finders.FinderBase<Element> finder, { | |
| bool warnIfMissed = false, | |
| String callee = 'getBottomRight', | |
| }) { | |
| return _getElementPoint( | |
| finder, | |
| (Size size) => size.bottomRight(Offset.zero), | |
| warnIfMissed: warnIfMissed, | |
| callee: callee, | |
| ); | |
| } | |
| /// Whether warnings relating to hit tests not hitting their mark should be | |
| /// fatal (cause the test to fail). | |
| /// | |
| /// Some methods, e.g. [tap], have an argument `warnIfMissed` which causes a | |
| /// warning to be displayed if the specified [Finder] indicates a widget and | |
| /// location that, were a pointer event to be sent to that location, would not | |
| /// actually send any events to the widget (e.g. because the widget is | |
| /// obscured, or the location is off-screen, or the widget is transparent to | |
| /// pointer events). | |
| /// | |
| /// This warning was added in 2021. In ordinary operation this warning is | |
| /// non-fatal since making it fatal would be a significantly breaking change | |
| /// for anyone who already has tests relying on the ability to target events | |
| /// using finders where the events wouldn't reach the widgets specified by the | |
| /// finders in question. | |
| /// | |
| /// However, doing this is usually unintentional. To make the warning fatal, | |
| /// thus failing any tests where it occurs, this property can be set to true. | |
| /// | |
| /// Typically this is done using a `flutter_test_config.dart` file, as described | |
| /// in the documentation for the [flutter_test] library. | |
| static bool hitTestWarningShouldBeFatal = false; | |
| /// Finds one hit-testable Offset in the given `textRangeContext`'s render | |
| /// object. | |
| Offset? _findHitTestableOffsetIn(finders.TextRangeContext textRangeContext) { | |
| TestAsyncUtils.guardSync(); | |
| final TextRange range = textRangeContext.textRange; | |
| assert(range.isNormalized); | |
| assert(range.isValid); | |
| final Offset renderParagraphPaintOffset = textRangeContext.renderObject.localToGlobal( | |
| Offset.zero, | |
| ); | |
| assert(renderParagraphPaintOffset.isFinite); | |
| int spanStart = range.start; | |
| while (spanStart < range.end) { | |
| switch (_findEndOfSpan(textRangeContext.renderObject.text, spanStart, range.end)) { | |
| case (final HitTestTarget target, final int endIndex): | |
| // Uses BoxHeightStyle.tight in getBoxesForSelection to make sure the | |
| // returned boxes don't extend outside of the hit-testable region. | |
| final Iterable<Offset> testOffsets = textRangeContext.renderObject | |
| .getBoxesForSelection(TextSelection(baseOffset: spanStart, extentOffset: endIndex)) | |
| // Try hit-testing the center of each TextBox. | |
| .map((TextBox textBox) => textBox.toRect().center); | |
| for (final localOffset in testOffsets) { | |
| final result = HitTestResult(); | |
| final Offset globalOffset = localOffset + renderParagraphPaintOffset; | |
| binding.hitTestInView(result, globalOffset, textRangeContext.view.view.viewId); | |
| if (result.path.any((HitTestEntry entry) => entry.target == target)) { | |
| return globalOffset; | |
| } | |
| } | |
| spanStart = endIndex; | |
| case (_, final int endIndex): | |
| spanStart = endIndex; | |
| case null: | |
| break; | |
| } | |
| } | |
| return null; | |
| } | |
| Offset _getElementPoint( | |
| finders.FinderBase<Element> finder, | |
| Offset Function(Size size) sizeToPoint, { | |
| required bool warnIfMissed, | |
| required String callee, | |
| }) { | |
| TestAsyncUtils.guardSync(); | |
| final Iterable<Element> elements = finder.evaluate(); | |
| if (elements.isEmpty) { | |
| throw FlutterError( | |
| 'The finder "$finder" (used in a call to "$callee()") could not find any matching widgets.', | |
| ); | |
| } | |
| if (elements.length > 1) { | |
| throw FlutterError( | |
| 'The finder "$finder" (used in a call to "$callee()") ambiguously found multiple matching widgets. The "$callee()" method needs a single target.', | |
| ); | |
| } | |
| final Element element = elements.single; | |
| final RenderObject? renderObject = element.renderObject; | |
| if (renderObject == null) { | |
| throw FlutterError( | |
| 'The finder "$finder" (used in a call to "$callee()") found an element, but it does not have a corresponding render object. ' | |
| 'Maybe the element has not yet been rendered?', | |
| ); | |
| } | |
| if (renderObject is! RenderBox) { | |
| throw FlutterError( | |
| 'The finder "$finder" (used in a call to "$callee()") found an element whose corresponding render object is not a RenderBox (it is a ${renderObject.runtimeType}: "$renderObject"). ' | |
| 'Unfortunately "$callee()" only supports targeting widgets that correspond to RenderBox objects in the rendering.', | |
| ); | |
| } | |
| final box = element.renderObject! as RenderBox; | |
| final Offset location = box.localToGlobal(sizeToPoint(box.size)); | |
| if (warnIfMissed) { | |
| final FlutterView view = _viewOf(finder); | |
| final result = HitTestResult(); | |
| binding.hitTestInView(result, location, view.viewId); | |
| final bool found = result.path.any( | |
| (HitTestEntry entry) => finders.isRenderObjectAncestorOfTarget(box, entry.target), | |
| ); | |
| if (!found) { | |
| final RenderView renderView = binding.renderViews.firstWhere( | |
| (RenderView r) => r.flutterView == view, | |
| ); | |
| final bool outOfBounds = !(Offset.zero & renderView.size).contains(location); | |
| if (hitTestWarningShouldBeFatal) { | |
| throw FlutterError.fromParts(<DiagnosticsNode>[ | |
| ErrorSummary('Finder specifies a widget that would not receive pointer events.'), | |
| ErrorDescription( | |
| 'A call to $callee() with finder "$finder" derived an Offset ($location) that would not hit test on the specified widget.', | |
| ), | |
| ErrorHint( | |
| 'Maybe the widget is actually off-screen, or another widget is obscuring it, or the widget cannot receive pointer events.', | |
| ), | |
| if (outOfBounds) | |
| ErrorHint( | |
| 'Indeed, $location is outside the bounds of the root of the render tree, ${renderView.size}.', | |
| ), | |
| box.toDiagnosticsNode( | |
| name: 'The finder corresponds to this RenderBox', | |
| style: DiagnosticsTreeStyle.singleLine, | |
| ), | |
| ErrorDescription('The hit test result at that offset is: $result'), | |
| ErrorDescription( | |
| 'If you expected this target not to be able to receive pointer events, pass "warnIfMissed: false" to "$callee()".', | |
| ), | |
| ErrorDescription( | |
| 'To make this error into a non-fatal warning, set WidgetController.hitTestWarningShouldBeFatal to false.', | |
| ), | |
| ]); | |
| } | |
| printToConsole( | |
| '\n' | |
| 'Warning: A call to $callee() with finder "$finder" derived an Offset ($location) that would not hit test on the specified widget.\n' | |
| 'Maybe the widget is actually off-screen, or another widget is obscuring it, or the widget cannot receive pointer events.\n' | |
| '${outOfBounds ? "Indeed, $location is outside the bounds of the root of the render tree, ${renderView.size}.\n" : ""}' | |
| 'The finder corresponds to this RenderBox: $box\n' | |
| 'The hit test result at that offset is: $result\n' | |
| '${StackTrace.current}' | |
| 'To silence this warning, pass "warnIfMissed: false" to "$callee()".\n' | |
| 'To make this warning fatal, set WidgetController.hitTestWarningShouldBeFatal to true.\n', | |
| ); | |
| } | |
| } | |
| return location; | |
| } | |
| /// 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(finders.FinderBase<Element> finder) { | |
| TestAsyncUtils.guardSync(); | |
| final Element element = finder.evaluate().single; | |
| final box = element.renderObject! as RenderBox; | |
| return box.size; | |
| } | |
| /// Simulates sending physical key down and up events. | |
| /// | |
| /// This only simulates key events coming from a physical keyboard, not from a | |
| /// soft keyboard. | |
| /// | |
| /// Specify `platform` as one of the platforms allowed in | |
| /// [Platform.operatingSystem] to make the event appear to be from | |
| /// that type of system. If not specified, defaults to "web" on web, and the | |
| /// operating system name based on [defaultTargetPlatform] everywhere else. | |
| /// | |
| /// Specify the `physicalKey` for the event to override what is included in | |
| /// the simulated event. If not specified, it uses a default from the US | |
| /// keyboard layout for the corresponding logical `key`. | |
| /// | |
| /// Specify the `character` for the event to override what is included in the | |
| /// simulated event. If not specified, it uses a default derived from the | |
| /// logical `key`. | |
| /// | |
| /// Keys that are down when the test completes are cleared after each test. | |
| /// | |
| /// This method sends both the key down and the key up events, to simulate a | |
| /// key press. To simulate individual down and/or up events, see | |
| /// [sendKeyDownEvent] and [sendKeyUpEvent]. | |
| /// | |
| /// Returns true if the key down event was handled by the framework. | |
| /// | |
| /// See also: | |
| /// | |
| /// - [sendKeyDownEvent] to simulate only a key down event. | |
| /// - [sendKeyUpEvent] to simulate only a key up event. | |
| Future<bool> sendKeyEvent( | |
| LogicalKeyboardKey key, { | |
| String? platform, | |
| String? character, | |
| PhysicalKeyboardKey? physicalKey, | |
| }) async { | |
| final bool handled = await simulateKeyDownEvent( | |
| key, | |
| platform: platform, | |
| character: character, | |
| physicalKey: physicalKey, | |
| ); | |
| // Internally wrapped in async guard. | |
| await simulateKeyUpEvent(key, platform: platform, physicalKey: physicalKey); | |
| return handled; | |
| } | |
| /// Simulates sending a physical key down event. | |
| /// | |
| /// This only simulates key down events coming from a physical keyboard, not | |
| /// from a soft keyboard. | |
| /// | |
| /// Specify `platform` as one of the platforms allowed in | |
| /// [Platform.operatingSystem] to make the event appear to be from | |
| /// that type of system. If not specified, defaults to "web" on web, and the | |
| /// operating system name based on [defaultTargetPlatform] everywhere else. | |
| /// | |
| /// Specify the `physicalKey` for the event to override what is included in | |
| /// the simulated event. If not specified, it uses a default from the US | |
| /// keyboard layout for the corresponding logical `key`. | |
| /// | |
| /// Specify the `character` for the event to override what is included in the | |
| /// simulated event. If not specified, it uses a default derived from the | |
| /// logical `key`. | |
| /// | |
| /// Keys that are down when the test completes are cleared after each test. | |
| /// | |
| /// Returns true if the key event was handled by the framework. | |
| /// | |
| /// See also: | |
| /// | |
| /// - [sendKeyUpEvent] and [sendKeyRepeatEvent] to simulate the corresponding | |
| /// key up and repeat event. | |
| /// - [sendKeyEvent] to simulate both the key up and key down in the same call. | |
| Future<bool> sendKeyDownEvent( | |
| LogicalKeyboardKey key, { | |
| String? platform, | |
| String? character, | |
| PhysicalKeyboardKey? physicalKey, | |
| }) async { | |
| // Internally wrapped in async guard. | |
| return simulateKeyDownEvent( | |
| key, | |
| platform: platform, | |
| character: character, | |
| physicalKey: physicalKey, | |
| ); | |
| } | |
| /// Simulates sending a physical key up event through the system channel. | |
| /// | |
| /// This only simulates key up events coming from a physical keyboard, | |
| /// not from a soft keyboard. | |
| /// | |
| /// Specify `platform` as one of the platforms allowed in | |
| /// [Platform.operatingSystem] to make the event appear to be from | |
| /// that type of system. If not specified, defaults to "web" on web, and the | |
| /// operating system name based on [defaultTargetPlatform] everywhere else. | |
| /// | |
| /// Specify the `physicalKey` for the event to override what is included in | |
| /// the simulated event. If not specified, it uses a default from the US | |
| /// keyboard layout for the corresponding logical `key`. | |
| /// | |
| /// Returns true if the key event was handled by the framework. | |
| /// | |
| /// See also: | |
| /// | |
| /// - [sendKeyDownEvent] and [sendKeyRepeatEvent] to simulate the | |
| /// corresponding key down and repeat event. | |
| /// - [sendKeyEvent] to simulate both the key up and key down in the same call. | |
| Future<bool> sendKeyUpEvent( | |
| LogicalKeyboardKey key, { | |
| String? platform, | |
| PhysicalKeyboardKey? physicalKey, | |
| }) async { | |
| // Internally wrapped in async guard. | |
| return simulateKeyUpEvent(key, platform: platform, physicalKey: physicalKey); | |
| } | |
| /// Simulates sending a key repeat event from a physical keyboard. | |
| /// | |
| /// This only simulates key repeat events coming from a physical keyboard, not | |
| /// from a soft keyboard. | |
| /// | |
| /// Specify `platform` as one of the platforms allowed in | |
| /// [Platform.operatingSystem] to make the event appear to be from | |
| /// that type of system. If not specified, defaults to "web" on web, and the | |
| /// operating system name based on [defaultTargetPlatform] everywhere else. | |
| /// | |
| /// Specify the `physicalKey` for the event to override what is included in | |
| /// the simulated event. If not specified, it uses a default from the US | |
| /// keyboard layout for the corresponding logical `key`. | |
| /// | |
| /// Specify the `character` for the event to override what is included in the | |
| /// simulated event. If not specified, it uses a default derived from the | |
| /// logical `key`. | |
| /// | |
| /// Keys that are down when the test completes are cleared after each test. | |
| /// | |
| /// Returns true if the key event was handled by the framework. | |
| /// | |
| /// See also: | |
| /// | |
| /// - [sendKeyDownEvent] and [sendKeyUpEvent] to simulate the corresponding | |
| /// key down and up event. | |
| /// - [sendKeyEvent] to simulate both the key up and key down in the same call. | |
| Future<bool> sendKeyRepeatEvent( | |
| LogicalKeyboardKey key, { | |
| String? platform, | |
| String? character, | |
| PhysicalKeyboardKey? physicalKey, | |
| }) async { | |
| // Internally wrapped in async guard. | |
| return simulateKeyRepeatEvent( | |
| key, | |
| platform: platform, | |
| character: character, | |
| physicalKey: physicalKey, | |
| ); | |
| } | |
| /// 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(finders.FinderBase<Element> finder) => | |
| Rect.fromPoints(getTopLeft(finder), getBottomRight(finder)); | |
| /// Attempts to find the [SemanticsNode] of first result from `finder`. | |
| /// | |
| /// If the object identified by the finder doesn't own it's semantic node, | |
| /// this will return the semantics data of the first ancestor with semantics. | |
| /// The ancestor's semantic data will include the child's as well as | |
| /// other nodes that have been merged together. | |
| /// | |
| /// If the [SemanticsNode] of the object identified by the finder is | |
| /// force-merged into an ancestor (e.g. via the [MergeSemantics] widget) | |
| /// the node into which it is merged is returned. That node will include | |
| /// all the semantics information of the nodes merged into it. | |
| /// | |
| /// 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(finders.FinderBase<Element> finder) => semantics.find(finder); | |
| /// Enable semantics in a test by creating a [SemanticsHandle]. | |
| /// | |
| /// The handle must be disposed at the end of the test. | |
| SemanticsHandle ensureSemantics() { | |
| return binding.ensureSemantics(); | |
| } | |
| /// Given a widget `W` specified by [finder] and a [Scrollable] widget `S` in | |
| /// its ancestry tree, this scrolls `S` so as to make `W` visible. | |
| /// | |
| /// Usually the `finder` for this method should be labeled `skipOffstage: | |
| /// false`, so that the [Finder] deals with widgets that are off the screen | |
| /// correctly. | |
| /// | |
| /// This does not work when `S` is long enough, and `W` far away enough from | |
| /// the displayed part of `S`, that `S` has not yet cached `W`'s element. | |
| /// Consider using [scrollUntilVisible] in such a situation. | |
| /// | |
| /// See also: | |
| /// | |
| /// * [Scrollable.ensureVisible], which is the production API used to | |
| /// implement this method. | |
| Future<void> ensureVisible(finders.FinderBase<Element> finder) => | |
| Scrollable.ensureVisible(element(finder)); | |
| /// Repeatedly scrolls a [Scrollable] by `delta` in the | |
| /// [Scrollable.axisDirection] direction until a widget matching `finder` is | |
| /// visible. | |
| /// | |
| /// Between each scroll, advances the clock by `duration` time. | |
| /// | |
| /// Scrolling is performed until the start of the `finder` is visible. This is | |
| /// due to the default parameter values of the [Scrollable.ensureVisible] method. | |
| /// | |
| /// If `scrollable` is `null`, a [Finder] that looks for a [Scrollable] is | |
| /// used instead. | |
| /// | |
| /// If `continuous` is `true`, the gesture will be reused to simulate the effect | |
| /// of actual finger scrolling, which is useful when used alongside listeners | |
| /// like [GestureDetector.onTap]. The default is `false`. | |
| /// | |
| /// Throws a [StateError] if `finder` is not found after `maxScrolls` scrolls. | |
| /// | |
| /// This is different from [ensureVisible] in that this allows looking for | |
| /// `finder` that is not yet built. The caller must specify the scrollable | |
| /// that will build child specified by `finder` when there are multiple | |
| /// [Scrollable]s. | |
| /// | |
| /// See also: | |
| /// | |
| /// * [dragUntilVisible], which implements the body of this method. | |
| Future<void> scrollUntilVisible( | |
| finders.FinderBase<Element> finder, | |
| double delta, { | |
| finders.FinderBase<Element>? scrollable, | |
| int maxScrolls = 50, | |
| Duration duration = const Duration(milliseconds: 50), | |
| bool continuous = false, | |
| }) { | |
| assert(maxScrolls > 0); | |
| scrollable ??= finders.find.byType(Scrollable); | |
| return TestAsyncUtils.guard<void>(() async { | |
| Offset moveStep; | |
| switch (widget<Scrollable>(scrollable!).axisDirection) { | |
| case AxisDirection.up: | |
| moveStep = Offset(0, delta); | |
| case AxisDirection.down: | |
| moveStep = Offset(0, -delta); | |
| case AxisDirection.left: | |
| moveStep = Offset(delta, 0); | |
| case AxisDirection.right: | |
| moveStep = Offset(-delta, 0); | |
| } | |
| await dragUntilVisible( | |
| finder, | |
| scrollable, | |
| moveStep, | |
| maxIteration: maxScrolls, | |
| duration: duration, | |
| continuous: continuous, | |
| ); | |
| }); | |
| } | |
| /// Repeatedly drags `view` by `moveStep` until `finder` is visible. | |
| /// | |
| /// The `moveStep` is the offset by which the virtual finger moves on the | |
| /// virtual screen between drags. Following the standard touch-input | |
| /// convention, a negative [Offset.dy] swipes up (revealing items below), a | |
| /// positive [Offset.dy] swipes down (revealing items above), a positive | |
| /// [Offset.dx] swipes right (revealing items to the left), and a negative | |
| /// [Offset.dx] swipes left (revealing items to the right). | |
| /// | |
| /// Between each drag, advances the clock by `duration`. | |
| /// | |
| /// Throws a [StateError] if `finder` is not found after `maxIteration` | |
| /// drags. | |
| /// | |
| /// See also: | |
| /// | |
| /// * [scrollUntilVisible], which wraps this method with an API that is more | |
| /// convenient when dealing with a [Scrollable]. | |
| Future<void> dragUntilVisible( | |
| finders.FinderBase<Element> finder, | |
| finders.FinderBase<Element> view, | |
| Offset moveStep, { | |
| int maxIteration = 50, | |
| Duration duration = const Duration(milliseconds: 50), | |
| bool continuous = false, | |
| }) { | |
| return TestAsyncUtils.guard<void>(() async { | |
| TestGesture? gesture; | |
| while (maxIteration > 0 && finder.evaluate().isEmpty) { | |
| if (continuous) { | |
| gesture ??= await startGesture(getCenter(view, warnIfMissed: true)); | |
| await gesture.moveBy(moveStep); | |
| } else { | |
| await drag(view, moveStep); | |
| } | |
| await pump(duration); | |
| maxIteration -= 1; | |
| } | |
| await gesture?.up(); | |
| await Scrollable.ensureVisible(element(finder)); | |
| }); | |
| } | |
| } | |
| /// Variant of [WidgetController] that can be used in tests running | |
| /// on a device. | |
| /// | |
| /// This is used, for instance, by [FlutterDriver]. | |
| class LiveWidgetController extends WidgetController { | |
| /// Creates a widget controller that uses the given binding. | |
| LiveWidgetController(super.binding); | |
| @override | |
| Future<void> pump([Duration? duration]) async { | |
| if (duration != null) { | |
| await Future<void>.delayed(duration); | |
| } | |
| binding.scheduleFrame(); | |
| await binding.endOfFrame; | |
| } | |
| @override | |
| Future<int> pumpAndSettle([Duration duration = const Duration(milliseconds: 100)]) { | |
| assert(duration > Duration.zero); | |
| return TestAsyncUtils.guard<int>(() async { | |
| var count = 0; | |
| do { | |
| await pump(duration); | |
| count += 1; | |
| } while (binding.hasScheduledFrame); | |
| return count; | |
| }); | |
| } | |
| @override | |
| Future<List<Duration>> handlePointerEventRecord(List<PointerEventRecord> records) { | |
| assert(records.isNotEmpty); | |
| return TestAsyncUtils.guard<List<Duration>>(() async { | |
| final handleTimeStampDiff = <Duration>[]; | |
| DateTime? startTime; | |
| for (final record in records) { | |
| final DateTime now = clock.now(); | |
| startTime ??= now; | |
| // So that the first event is promised to receive a zero timeDiff. | |
| final Duration timeDiff = record.timeDelay - now.difference(startTime); | |
| if (timeDiff.isNegative) { | |
| // This happens when something (e.g. GC) takes a long time during the | |
| // processing of the events. | |
| // Flush all past events. | |
| handleTimeStampDiff.add(-timeDiff); | |
| record.events.forEach(binding.handlePointerEvent); | |
| } else { | |
| await Future<void>.delayed(timeDiff); | |
| handleTimeStampDiff.add( | |
| // Recalculating the time diff for getting exact time when the event | |
| // packet is sent. For a perfect Future.delayed like the one in a | |
| // fake async this new diff should be zero. | |
| clock.now().difference(startTime) - record.timeDelay, | |
| ); | |
| record.events.forEach(binding.handlePointerEvent); | |
| } | |
| } | |
| return handleTimeStampDiff; | |
| }); | |
| } | |
| } |