| 1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
| 2 | // Use of this source code is governed by a BSD-style license that can be |
| 3 | // found in the LICENSE file. |
| 4 | |
| 5 | /// @docImport 'dart:io'; |
| 6 | /// |
| 7 | /// @docImport 'package:flutter/scheduler.dart'; |
| 8 | /// @docImport 'package:flutter_driver/flutter_driver.dart'; |
| 9 | /// |
| 10 | /// @docImport 'binding.dart'; |
| 11 | /// @docImport 'finders.dart'; |
| 12 | /// @docImport 'matchers.dart'; |
| 13 | /// @docImport 'widget_tester.dart'; |
| 14 | library; |
| 15 | |
| 16 | import 'package:clock/clock.dart' ; |
| 17 | import 'package:flutter/foundation.dart'; |
| 18 | import 'package:flutter/gestures.dart'; |
| 19 | import 'package:flutter/rendering.dart'; |
| 20 | import 'package:flutter/services.dart'; |
| 21 | import 'package:flutter/widgets.dart'; |
| 22 | |
| 23 | import 'event_simulation.dart'; |
| 24 | import 'finders.dart' as finders; |
| 25 | import 'test_async_utils.dart'; |
| 26 | import 'test_pointer.dart'; |
| 27 | import 'tree_traversal.dart'; |
| 28 | import 'window.dart'; |
| 29 | |
| 30 | /// The default drag touch slop used to break up a large drag into multiple |
| 31 | /// smaller moves. |
| 32 | /// |
| 33 | /// This value must be greater than [kTouchSlop]. |
| 34 | const double kDragSlopDefault = 20.0; |
| 35 | |
| 36 | // Finds the end index (exclusive) of the span at `startIndex`, or `endIndex` if |
| 37 | // there are no other spans between `startIndex` and `endIndex`. |
| 38 | // The InlineSpan protocol doesn't expose the length of the span so we'll |
| 39 | // have to iterate through the whole range. |
| 40 | (InlineSpan, int)? _findEndOfSpan(InlineSpan rootSpan, int startIndex, int endIndex) { |
| 41 | assert(endIndex > startIndex); |
| 42 | final InlineSpan? subspan = rootSpan.getSpanForPosition(TextPosition(offset: startIndex)); |
| 43 | if (subspan == null) { |
| 44 | return null; |
| 45 | } |
| 46 | int i = startIndex + 1; |
| 47 | while (i < endIndex && rootSpan.getSpanForPosition(TextPosition(offset: i)) == subspan) { |
| 48 | i += 1; |
| 49 | } |
| 50 | return (subspan, i); |
| 51 | } |
| 52 | |
| 53 | // Examples can assume: |
| 54 | // typedef MyWidget = Placeholder; |
| 55 | |
| 56 | /// Class that programmatically interacts with the [Semantics] tree. |
| 57 | /// |
| 58 | /// Allows for testing of the [Semantics] tree, which is used by assistive |
| 59 | /// technology, search engines, and other analysis software to determine the |
| 60 | /// meaning of an application. |
| 61 | /// |
| 62 | /// Should be accessed through [WidgetController.semantics]. If no custom |
| 63 | /// implementation is provided, a default [SemanticsController] will be created. |
| 64 | class SemanticsController { |
| 65 | /// Creates a [SemanticsController] that uses the given binding. Will be |
| 66 | /// automatically created as part of instantiating a [WidgetController], but |
| 67 | /// a custom implementation can be passed via the [WidgetController] constructor. |
| 68 | SemanticsController._(this._controller); |
| 69 | |
| 70 | static final int _scrollingActions = |
| 71 | SemanticsAction.scrollUp.index | |
| 72 | SemanticsAction.scrollDown.index | |
| 73 | SemanticsAction.scrollLeft.index | |
| 74 | SemanticsAction.scrollRight.index | |
| 75 | SemanticsAction.scrollToOffset.index; |
| 76 | |
| 77 | /// 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). |
| 78 | static final int _importantFlagsForAccessibility = |
| 79 | SemanticsFlag.hasCheckedState.index | |
| 80 | SemanticsFlag.hasToggledState.index | |
| 81 | SemanticsFlag.hasEnabledState.index | |
| 82 | SemanticsFlag.isButton.index | |
| 83 | SemanticsFlag.isTextField.index | |
| 84 | SemanticsFlag.isFocusable.index | |
| 85 | SemanticsFlag.isSlider.index | |
| 86 | SemanticsFlag.isInMutuallyExclusiveGroup.index; |
| 87 | |
| 88 | final WidgetController _controller; |
| 89 | |
| 90 | /// Attempts to find the [SemanticsNode] of first result from `finder`. |
| 91 | /// |
| 92 | /// If the object identified by the finder doesn't own its semantic node, |
| 93 | /// this will return the semantics data of the first ancestor with semantics. |
| 94 | /// The ancestor's semantic data will include the child's as well as |
| 95 | /// other nodes that have been merged together. |
| 96 | /// |
| 97 | /// If the [SemanticsNode] of the object identified by the finder is |
| 98 | /// force-merged into an ancestor (e.g. via the [MergeSemantics] widget) |
| 99 | /// the node into which it is merged is returned. That node will include |
| 100 | /// all the semantics information of the nodes merged into it. |
| 101 | /// |
| 102 | /// Will throw a [StateError] if the finder returns more than one element or |
| 103 | /// if no semantics are found or are not enabled. |
| 104 | SemanticsNode find(finders.FinderBase<Element> finder) { |
| 105 | TestAsyncUtils.guardSync(); |
| 106 | final Iterable<Element> candidates = finder.evaluate(); |
| 107 | if (candidates.isEmpty) { |
| 108 | throw StateError('Finder returned no matching elements.' ); |
| 109 | } |
| 110 | if (candidates.length > 1) { |
| 111 | throw StateError('Finder returned more than one element.' ); |
| 112 | } |
| 113 | final Element element = candidates.single; |
| 114 | RenderObject? renderObject = element.findRenderObject(); |
| 115 | SemanticsNode? result = renderObject?.debugSemantics; |
| 116 | while (renderObject != null && (result == null || result.isMergedIntoParent)) { |
| 117 | renderObject = renderObject.parent; |
| 118 | result = renderObject?.debugSemantics; |
| 119 | } |
| 120 | if (result == null) { |
| 121 | throw StateError('No Semantics data found.' ); |
| 122 | } |
| 123 | return result; |
| 124 | } |
| 125 | |
| 126 | /// Simulates a traversal of the currently visible semantics tree as if by |
| 127 | /// assistive technologies. |
| 128 | /// |
| 129 | /// Starts at the node for `startNode`. If `startNode` is not provided, then |
| 130 | /// the traversal begins with the first accessible node in the tree. If |
| 131 | /// `startNode` finds zero elements or more than one element, a [StateError] |
| 132 | /// will be thrown. |
| 133 | /// |
| 134 | /// Ends at the node for `endNode`, inclusive. If `endNode` is not provided, |
| 135 | /// then the traversal ends with the last accessible node in the currently |
| 136 | /// available tree. If `endNode` finds zero elements or more than one element, |
| 137 | /// a [StateError] will be thrown. |
| 138 | /// |
| 139 | /// If provided, the nodes for `endNode` and `startNode` must be part of the |
| 140 | /// same semantics tree, i.e. they must be part of the same view. |
| 141 | /// |
| 142 | /// If neither `startNode` or `endNode` is provided, `view` can be provided to |
| 143 | /// specify the semantics tree to traverse. If `view` is left unspecified, |
| 144 | /// [WidgetTester.view] is traversed by default. |
| 145 | /// |
| 146 | /// Since the order is simulated, edge cases that differ between platforms |
| 147 | /// (such as how the last visible item in a scrollable list is handled) may be |
| 148 | /// inconsistent with platform behavior, but are expected to be sufficient for |
| 149 | /// testing order, availability to assistive technologies, and interactions. |
| 150 | /// |
| 151 | /// ## Sample Code |
| 152 | /// |
| 153 | /// ```dart |
| 154 | /// testWidgets('MyWidget', (WidgetTester tester) async { |
| 155 | /// await tester.pumpWidget(const MyWidget()); |
| 156 | /// |
| 157 | /// expect( |
| 158 | /// tester.semantics.simulatedAccessibilityTraversal(), |
| 159 | /// containsAllInOrder(<Matcher>[ |
| 160 | /// containsSemantics(label: 'My Widget'), |
| 161 | /// containsSemantics(label: 'is awesome!', isChecked: true), |
| 162 | /// ]), |
| 163 | /// ); |
| 164 | /// }); |
| 165 | /// ``` |
| 166 | /// |
| 167 | /// See also: |
| 168 | /// |
| 169 | /// * [containsSemantics] and [matchesSemantics], which can be used to match |
| 170 | /// against a single node in the traversal. |
| 171 | /// * [containsAllInOrder], which can be given an [Iterable<Matcher>] to fuzzy |
| 172 | /// match the order allowing extra nodes before after and between matching |
| 173 | /// parts of the traversal. |
| 174 | /// * [orderedEquals], which can be given an [Iterable<Matcher>] to exactly |
| 175 | /// match the order of the traversal. |
| 176 | Iterable<SemanticsNode> simulatedAccessibilityTraversal({ |
| 177 | @Deprecated( |
| 178 | 'Use startNode instead. ' |
| 179 | 'This method was originally created before semantics finders were available. ' |
| 180 | 'Semantics finders avoid edge cases where some nodes are not discoverable by widget finders and should be preferred for semantics testing. ' |
| 181 | 'This feature was deprecated after v3.15.0-15.2.pre.' , |
| 182 | ) |
| 183 | finders.FinderBase<Element>? start, |
| 184 | @Deprecated( |
| 185 | 'Use endNode instead. ' |
| 186 | 'This method was originally created before semantics finders were available. ' |
| 187 | 'Semantics finders avoid edge cases where some nodes are not discoverable by widget finders and should be preferred for semantics testing. ' |
| 188 | 'This feature was deprecated after v3.15.0-15.2.pre.' , |
| 189 | ) |
| 190 | finders.FinderBase<Element>? end, |
| 191 | finders.FinderBase<SemanticsNode>? startNode, |
| 192 | finders.FinderBase<SemanticsNode>? endNode, |
| 193 | FlutterView? view, |
| 194 | }) { |
| 195 | TestAsyncUtils.guardSync(); |
| 196 | assert( |
| 197 | start == null || startNode == null, |
| 198 | 'Cannot provide both start and startNode. Prefer startNode as start is deprecated.' , |
| 199 | ); |
| 200 | assert( |
| 201 | end == null || endNode == null, |
| 202 | 'Cannot provide both end and endNode. Prefer endNode as end is deprecated.' , |
| 203 | ); |
| 204 | |
| 205 | FlutterView? startView; |
| 206 | if (start != null) { |
| 207 | startView = _controller.viewOf(start); |
| 208 | if (view != null && startView != view) { |
| 209 | throw StateError( |
| 210 | 'The start node is not part of the provided view.\n' |
| 211 | 'Finder: ${start.toString(describeSelf: true)}\n' |
| 212 | 'View of start node: $startView\n' |
| 213 | 'Specified view: $view' , |
| 214 | ); |
| 215 | } |
| 216 | } else if (startNode != null) { |
| 217 | final SemanticsOwner owner = startNode.evaluate().single.owner!; |
| 218 | final RenderView renderView = _controller.binding.renderViews.firstWhere( |
| 219 | (RenderView render) => render.owner!.semanticsOwner == owner, |
| 220 | ); |
| 221 | startView = renderView.flutterView; |
| 222 | if (view != null && startView != view) { |
| 223 | throw StateError( |
| 224 | 'The start node is not part of the provided view.\n' |
| 225 | 'Finder: ${startNode.toString(describeSelf: true)}\n' |
| 226 | 'View of start node: $startView\n' |
| 227 | 'Specified view: $view' , |
| 228 | ); |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | FlutterView? endView; |
| 233 | if (end != null) { |
| 234 | endView = _controller.viewOf(end); |
| 235 | if (view != null && endView != view) { |
| 236 | throw StateError( |
| 237 | 'The end node is not part of the provided view.\n' |
| 238 | 'Finder: ${end.toString(describeSelf: true)}\n' |
| 239 | 'View of end node: $endView\n' |
| 240 | 'Specified view: $view' , |
| 241 | ); |
| 242 | } |
| 243 | } else if (endNode != null) { |
| 244 | final SemanticsOwner owner = endNode.evaluate().single.owner!; |
| 245 | final RenderView renderView = _controller.binding.renderViews.firstWhere( |
| 246 | (RenderView render) => render.owner!.semanticsOwner == owner, |
| 247 | ); |
| 248 | endView = renderView.flutterView; |
| 249 | if (view != null && endView != view) { |
| 250 | throw StateError( |
| 251 | 'The end node is not part of the provided view.\n' |
| 252 | 'Finder: ${endNode.toString(describeSelf: true)}\n' |
| 253 | 'View of end node: $endView\n' |
| 254 | 'Specified view: $view' , |
| 255 | ); |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | if (endView != null && startView != null && endView != startView) { |
| 260 | throw StateError( |
| 261 | 'The start and end node are in different views.\n' |
| 262 | 'Start finder: ${start!.toString(describeSelf: true)}\n' |
| 263 | 'End finder: ${end!.toString(describeSelf: true)}\n' |
| 264 | 'View of start node: $startView\n' |
| 265 | 'View of end node: $endView' , |
| 266 | ); |
| 267 | } |
| 268 | |
| 269 | final FlutterView actualView = view ?? startView ?? endView ?? _controller.view; |
| 270 | final RenderView renderView = _controller.binding.renderViews.firstWhere( |
| 271 | (RenderView r) => r.flutterView == actualView, |
| 272 | ); |
| 273 | |
| 274 | final List<SemanticsNode> traversal = <SemanticsNode>[]; |
| 275 | _accessibilityTraversal(renderView.owner!.semanticsOwner!.rootSemanticsNode!, traversal); |
| 276 | |
| 277 | // Setting the range |
| 278 | SemanticsNode? node; |
| 279 | String? errorString; |
| 280 | |
| 281 | int startIndex; |
| 282 | if (start != null) { |
| 283 | node = find(start); |
| 284 | startIndex = traversal.indexOf(node); |
| 285 | errorString = start.toString(describeSelf: true); |
| 286 | } else if (startNode != null) { |
| 287 | node = startNode.evaluate().single; |
| 288 | startIndex = traversal.indexOf(node); |
| 289 | errorString = startNode.toString(describeSelf: true); |
| 290 | } else { |
| 291 | startIndex = 0; |
| 292 | } |
| 293 | if (startIndex == -1) { |
| 294 | throw StateError( |
| 295 | 'The expected starting node was not found.\n' |
| 296 | 'Finder: $errorString\n\n' |
| 297 | 'Expected Start Node: $node\n\n' |
| 298 | 'Traversal: [\n ${traversal.join('\n ' )}\n]' , |
| 299 | ); |
| 300 | } |
| 301 | |
| 302 | int? endIndex; |
| 303 | if (end != null) { |
| 304 | node = find(end); |
| 305 | endIndex = traversal.indexOf(node); |
| 306 | errorString = end.toString(describeSelf: true); |
| 307 | } else if (endNode != null) { |
| 308 | node = endNode.evaluate().single; |
| 309 | endIndex = traversal.indexOf(node); |
| 310 | errorString = endNode.toString(describeSelf: true); |
| 311 | } |
| 312 | if (endIndex == -1) { |
| 313 | throw StateError( |
| 314 | 'The expected ending node was not found.\n' |
| 315 | 'Finder: $errorString\n\n' |
| 316 | 'Expected End Node: $node\n\n' |
| 317 | 'Traversal: [\n ${traversal.join('\n ' )}\n]' , |
| 318 | ); |
| 319 | } |
| 320 | endIndex ??= traversal.length - 1; |
| 321 | |
| 322 | return traversal.getRange(startIndex, endIndex + 1); |
| 323 | } |
| 324 | |
| 325 | /// Recursive depth first traversal of the specified `node`, adding nodes |
| 326 | /// that are important for semantics to the `traversal` list. |
| 327 | void _accessibilityTraversal(SemanticsNode node, List<SemanticsNode> traversal) { |
| 328 | if (_isImportantForAccessibility(node)) { |
| 329 | traversal.add(node); |
| 330 | } |
| 331 | |
| 332 | final List<SemanticsNode> children = node.debugListChildrenInOrder( |
| 333 | DebugSemanticsDumpOrder.traversalOrder, |
| 334 | ); |
| 335 | for (final SemanticsNode child in children) { |
| 336 | _accessibilityTraversal(child, traversal); |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | /// Whether or not the node is important for semantics. Should match most cases |
| 341 | /// on the platforms, but certain edge cases will be inconsistent. |
| 342 | /// |
| 343 | /// Based on: |
| 344 | /// |
| 345 | /// * [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) |
| 346 | /// * [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) |
| 347 | bool _isImportantForAccessibility(SemanticsNode node) { |
| 348 | if (node.isMergedIntoParent) { |
| 349 | // If this node is merged, all its information are present on an ancestor |
| 350 | // node. |
| 351 | return false; |
| 352 | } |
| 353 | final SemanticsData data = node.getSemanticsData(); |
| 354 | // If the node scopes a route, it doesn't matter what other flags/actions it |
| 355 | // has, it is _not_ important for accessibility, so we short circuit. |
| 356 | if (data.hasFlag(SemanticsFlag.scopesRoute)) { |
| 357 | return false; |
| 358 | } |
| 359 | |
| 360 | final bool hasNonScrollingAction = data.actions & ~_scrollingActions != 0; |
| 361 | if (hasNonScrollingAction) { |
| 362 | return true; |
| 363 | } |
| 364 | |
| 365 | final bool hasImportantFlag = data.flags & _importantFlagsForAccessibility != 0; |
| 366 | if (hasImportantFlag) { |
| 367 | return true; |
| 368 | } |
| 369 | |
| 370 | final bool hasContent = data.label.isNotEmpty || data.value.isNotEmpty || data.hint.isNotEmpty; |
| 371 | if (hasContent) { |
| 372 | return true; |
| 373 | } |
| 374 | |
| 375 | return false; |
| 376 | } |
| 377 | |
| 378 | /// Performs the given [SemanticsAction] on the [SemanticsNode] found by `finder`. |
| 379 | /// |
| 380 | /// If `args` are provided, they will be passed unmodified with the `action`. |
| 381 | /// The `checkForAction` argument allows for attempting to perform `action` on |
| 382 | /// `node` even if it doesn't report supporting that action. This is useful |
| 383 | /// for implicitly supported actions such as [SemanticsAction.showOnScreen]. |
| 384 | void performAction( |
| 385 | finders.FinderBase<SemanticsNode> finder, |
| 386 | SemanticsAction action, { |
| 387 | Object? args, |
| 388 | bool checkForAction = true, |
| 389 | }) { |
| 390 | final SemanticsNode node = finder.evaluate().single; |
| 391 | if (checkForAction && !node.getSemanticsData().hasAction(action)) { |
| 392 | throw StateError( |
| 393 | 'The given node does not support $action. If the action is implicitly ' |
| 394 | 'supported or an unsupported action is being tested for this node, ' |
| 395 | 'set `checkForAction` to false.\n' |
| 396 | 'Node: $node' , |
| 397 | ); |
| 398 | } |
| 399 | |
| 400 | node.owner!.performAction(node.id, action, args); |
| 401 | } |
| 402 | |
| 403 | /// Performs a [SemanticsAction.tap] action on the [SemanticsNode] found |
| 404 | /// by `finder`. |
| 405 | /// |
| 406 | /// Throws a [StateError] if: |
| 407 | /// * The given `finder` returns zero or more than one result. |
| 408 | /// * The [SemanticsNode] found with `finder` does not support |
| 409 | /// [SemanticsAction.tap]. |
| 410 | void tap(finders.FinderBase<SemanticsNode> finder) { |
| 411 | performAction(finder, SemanticsAction.tap); |
| 412 | } |
| 413 | |
| 414 | /// Performs a [SemanticsAction.longPress] action on the [SemanticsNode] found |
| 415 | /// by `finder`. |
| 416 | /// |
| 417 | /// Throws a [StateError] if: |
| 418 | /// * The given `finder` returns zero or more than one result. |
| 419 | /// * The [SemanticsNode] found with `finder` does not support |
| 420 | /// [SemanticsAction.longPress]. |
| 421 | void longPress(finders.FinderBase<SemanticsNode> finder) { |
| 422 | performAction(finder, SemanticsAction.longPress); |
| 423 | } |
| 424 | |
| 425 | /// Performs a [SemanticsAction.scrollLeft] action on the [SemanticsNode] |
| 426 | /// found by `scrollable` or the first scrollable node in the default |
| 427 | /// semantics tree if no `scrollable` is provided. |
| 428 | /// |
| 429 | /// Throws a [StateError] if: |
| 430 | /// * The given `scrollable` returns zero or more than one result. |
| 431 | /// * The [SemanticsNode] found with `scrollable` does not support |
| 432 | /// [SemanticsAction.scrollLeft]. |
| 433 | void scrollLeft({finders.FinderBase<SemanticsNode>? scrollable}) { |
| 434 | performAction(scrollable ?? finders.find.semantics.scrollable(), SemanticsAction.scrollLeft); |
| 435 | } |
| 436 | |
| 437 | /// Performs a [SemanticsAction.scrollRight] action on the [SemanticsNode] |
| 438 | /// found by `scrollable` or the first scrollable node in the default |
| 439 | /// semantics tree if no `scrollable` is provided. |
| 440 | /// |
| 441 | /// Throws a [StateError] if: |
| 442 | /// * The given `scrollable` returns zero or more than one result. |
| 443 | /// * The [SemanticsNode] found with `scrollable` does not support |
| 444 | /// [SemanticsAction.scrollRight]. |
| 445 | void scrollRight({finders.FinderBase<SemanticsNode>? scrollable}) { |
| 446 | performAction(scrollable ?? finders.find.semantics.scrollable(), SemanticsAction.scrollRight); |
| 447 | } |
| 448 | |
| 449 | /// Performs a [SemanticsAction.scrollUp] action on the [SemanticsNode] found |
| 450 | /// by `scrollable` or the first scrollable node in the default semantics |
| 451 | /// tree if no `scrollable` is provided. |
| 452 | /// |
| 453 | /// Throws a [StateError] if: |
| 454 | /// * The given `scrollable` returns zero or more than one result. |
| 455 | /// * The [SemanticsNode] found with `scrollable` does not support |
| 456 | /// [SemanticsAction.scrollUp]. |
| 457 | void scrollUp({finders.FinderBase<SemanticsNode>? scrollable}) { |
| 458 | performAction(scrollable ?? finders.find.semantics.scrollable(), SemanticsAction.scrollUp); |
| 459 | } |
| 460 | |
| 461 | /// Performs a [SemanticsAction.scrollDown] action on the [SemanticsNode] |
| 462 | /// found by `scrollable` or the first scrollable node in the default |
| 463 | /// semantics tree if no `scrollable` is provided. |
| 464 | /// |
| 465 | /// Throws a [StateError] if: |
| 466 | /// * The given `scrollable` returns zero or more than one result. |
| 467 | /// * The [SemanticsNode] found with `scrollable` does not support |
| 468 | /// [SemanticsAction.scrollDown]. |
| 469 | void scrollDown({finders.FinderBase<SemanticsNode>? scrollable}) { |
| 470 | performAction(scrollable ?? finders.find.semantics.scrollable(), SemanticsAction.scrollDown); |
| 471 | } |
| 472 | |
| 473 | /// Performs a [SemanticsAction.increase] action on the [SemanticsNode] |
| 474 | /// found by `finder`. |
| 475 | /// |
| 476 | /// Throws a [StateError] if: |
| 477 | /// * The given `finder` returns zero or more than one result. |
| 478 | /// * The [SemanticsNode] found with `finder` does not support |
| 479 | /// [SemanticsAction.increase]. |
| 480 | void increase(finders.FinderBase<SemanticsNode> finder) { |
| 481 | performAction(finder, SemanticsAction.increase); |
| 482 | } |
| 483 | |
| 484 | /// Performs a [SemanticsAction.decrease] action on the [SemanticsNode] |
| 485 | /// found by `finder`. |
| 486 | /// |
| 487 | /// Throws a [StateError] if: |
| 488 | /// * The given `finder` returns zero or more than one result. |
| 489 | /// * The [SemanticsNode] found with `finder` does not support |
| 490 | /// [SemanticsAction.decrease]. |
| 491 | void decrease(finders.FinderBase<SemanticsNode> finder) { |
| 492 | performAction(finder, SemanticsAction.decrease); |
| 493 | } |
| 494 | |
| 495 | /// Performs a [SemanticsAction.showOnScreen] action on the [SemanticsNode] |
| 496 | /// found by `finder`. |
| 497 | /// |
| 498 | /// Throws a [StateError] if: |
| 499 | /// * The given `finder` returns zero or more than one result. |
| 500 | /// * The [SemanticsNode] found with `finder` does not support |
| 501 | /// [SemanticsAction.showOnScreen]. |
| 502 | void showOnScreen(finders.FinderBase<SemanticsNode> finder) { |
| 503 | performAction(finder, SemanticsAction.showOnScreen, checkForAction: false); |
| 504 | } |
| 505 | |
| 506 | /// Performs a [SemanticsAction.moveCursorForwardByCharacter] action on the |
| 507 | /// [SemanticsNode] found by `finder`. |
| 508 | /// |
| 509 | /// If `shouldModifySelection` is true, then the cursor will begin or extend |
| 510 | /// a selection. |
| 511 | /// |
| 512 | /// Throws a [StateError] if: |
| 513 | /// * The given `finder` returns zero or more than one result. |
| 514 | /// * The [SemanticsNode] found with `finder` does not support |
| 515 | /// [SemanticsAction.moveCursorForwardByCharacter]. |
| 516 | void moveCursorForwardByCharacter( |
| 517 | finders.FinderBase<SemanticsNode> finder, { |
| 518 | bool shouldModifySelection = false, |
| 519 | }) { |
| 520 | performAction( |
| 521 | finder, |
| 522 | SemanticsAction.moveCursorForwardByCharacter, |
| 523 | args: shouldModifySelection, |
| 524 | ); |
| 525 | } |
| 526 | |
| 527 | /// Performs a [SemanticsAction.moveCursorForwardByWord] action on the |
| 528 | /// [SemanticsNode] found by `finder`. |
| 529 | /// |
| 530 | /// Throws a [StateError] if: |
| 531 | /// * The given `finder` returns zero or more than one result. |
| 532 | /// * The [SemanticsNode] found with `finder` does not support |
| 533 | /// [SemanticsAction.moveCursorForwardByWord]. |
| 534 | void moveCursorForwardByWord( |
| 535 | finders.FinderBase<SemanticsNode> finder, { |
| 536 | bool shouldModifySelection = false, |
| 537 | }) { |
| 538 | performAction(finder, SemanticsAction.moveCursorForwardByWord, args: shouldModifySelection); |
| 539 | } |
| 540 | |
| 541 | /// Performs a [SemanticsAction.moveCursorBackwardByCharacter] action on the |
| 542 | /// [SemanticsNode] found by `finder`. |
| 543 | /// |
| 544 | /// If `shouldModifySelection` is true, then the cursor will begin or extend |
| 545 | /// a selection. |
| 546 | /// |
| 547 | /// Throws a [StateError] if: |
| 548 | /// * The given `finder` returns zero or more than one result. |
| 549 | /// * The [SemanticsNode] found with `finder` does not support |
| 550 | /// [SemanticsAction.moveCursorBackwardByCharacter]. |
| 551 | void moveCursorBackwardByCharacter( |
| 552 | finders.FinderBase<SemanticsNode> finder, { |
| 553 | bool shouldModifySelection = false, |
| 554 | }) { |
| 555 | performAction( |
| 556 | finder, |
| 557 | SemanticsAction.moveCursorBackwardByCharacter, |
| 558 | args: shouldModifySelection, |
| 559 | ); |
| 560 | } |
| 561 | |
| 562 | /// Performs a [SemanticsAction.moveCursorBackwardByWord] action on the |
| 563 | /// [SemanticsNode] found by `finder`. |
| 564 | /// |
| 565 | /// Throws a [StateError] if: |
| 566 | /// * The given `finder` returns zero or more than one result. |
| 567 | /// * The [SemanticsNode] found with `finder` does not support |
| 568 | /// [SemanticsAction.moveCursorBackwardByWord]. |
| 569 | void moveCursorBackwardByWord( |
| 570 | finders.FinderBase<SemanticsNode> finder, { |
| 571 | bool shouldModifySelection = false, |
| 572 | }) { |
| 573 | performAction(finder, SemanticsAction.moveCursorBackwardByWord, args: shouldModifySelection); |
| 574 | } |
| 575 | |
| 576 | /// Performs a [SemanticsAction.setText] action on the [SemanticsNode] |
| 577 | /// found by `finder` using the given `text`. |
| 578 | /// |
| 579 | /// Throws a [StateError] if: |
| 580 | /// * The given `finder` returns zero or more than one result. |
| 581 | /// * The [SemanticsNode] found with `finder` does not support |
| 582 | /// [SemanticsAction.setText]. |
| 583 | void setText(finders.FinderBase<SemanticsNode> finder, String text) { |
| 584 | performAction(finder, SemanticsAction.setText, args: text); |
| 585 | } |
| 586 | |
| 587 | /// Performs a [SemanticsAction.setSelection] action on the [SemanticsNode] |
| 588 | /// found by `finder`. |
| 589 | /// |
| 590 | /// The `base` parameter is the start index of selection, and the `extent` |
| 591 | /// parameter is the length of the selection. Each value should be limited |
| 592 | /// between 0 and the length of the found [SemanticsNode]'s `value`. |
| 593 | /// |
| 594 | /// Throws a [StateError] if: |
| 595 | /// * The given `finder` returns zero or more than one result. |
| 596 | /// * The [SemanticsNode] found with `finder` does not support |
| 597 | /// [SemanticsAction.setSelection]. |
| 598 | void setSelection( |
| 599 | finders.FinderBase<SemanticsNode> finder, { |
| 600 | required int base, |
| 601 | required int extent, |
| 602 | }) { |
| 603 | performAction( |
| 604 | finder, |
| 605 | SemanticsAction.setSelection, |
| 606 | args: <String, int>{'base' : base, 'extent' : extent}, |
| 607 | ); |
| 608 | } |
| 609 | |
| 610 | /// Performs a [SemanticsAction.copy] action on the [SemanticsNode] |
| 611 | /// found by `finder`. |
| 612 | /// |
| 613 | /// Throws a [StateError] if: |
| 614 | /// * The given `finder` returns zero or more than one result. |
| 615 | /// * The [SemanticsNode] found with `finder` does not support |
| 616 | /// [SemanticsAction.copy]. |
| 617 | void copy(finders.FinderBase<SemanticsNode> finder) { |
| 618 | performAction(finder, SemanticsAction.copy); |
| 619 | } |
| 620 | |
| 621 | /// Performs a [SemanticsAction.cut] action on the [SemanticsNode] |
| 622 | /// found by `finder`. |
| 623 | /// |
| 624 | /// Throws a [StateError] if: |
| 625 | /// * The given `finder` returns zero or more than one result. |
| 626 | /// * The [SemanticsNode] found with `finder` does not support |
| 627 | /// [SemanticsAction.cut]. |
| 628 | void cut(finders.FinderBase<SemanticsNode> finder) { |
| 629 | performAction(finder, SemanticsAction.cut); |
| 630 | } |
| 631 | |
| 632 | /// Performs a [SemanticsAction.paste] action on the [SemanticsNode] |
| 633 | /// found by `finder`. |
| 634 | /// |
| 635 | /// Throws a [StateError] if: |
| 636 | /// * The given `finder` returns zero or more than one result. |
| 637 | /// * The [SemanticsNode] found with `finder` does not support |
| 638 | /// [SemanticsAction.paste]. |
| 639 | void paste(finders.FinderBase<SemanticsNode> finder) { |
| 640 | performAction(finder, SemanticsAction.paste); |
| 641 | } |
| 642 | |
| 643 | /// Performs a [SemanticsAction.didGainAccessibilityFocus] action on the |
| 644 | /// [SemanticsNode] found by `finder`. |
| 645 | /// |
| 646 | /// Throws a [StateError] if: |
| 647 | /// * The given `finder` returns zero or more than one result. |
| 648 | /// * The [SemanticsNode] found with `finder` does not support |
| 649 | /// [SemanticsAction.didGainAccessibilityFocus]. |
| 650 | void didGainAccessibilityFocus(finders.FinderBase<SemanticsNode> finder) { |
| 651 | performAction(finder, SemanticsAction.didGainAccessibilityFocus); |
| 652 | } |
| 653 | |
| 654 | /// Performs a [SemanticsAction.didLoseAccessibilityFocus] action on the |
| 655 | /// [SemanticsNode] found by `finder`. |
| 656 | /// |
| 657 | /// Throws a [StateError] if: |
| 658 | /// * The given `finder` returns zero or more than one result. |
| 659 | /// * The [SemanticsNode] found with `finder` does not support |
| 660 | /// [SemanticsAction.didLoseAccessibilityFocus]. |
| 661 | void didLoseAccessibilityFocus(finders.FinderBase<SemanticsNode> finder) { |
| 662 | performAction(finder, SemanticsAction.didLoseAccessibilityFocus); |
| 663 | } |
| 664 | |
| 665 | /// Performs a [SemanticsAction.customAction] action on the |
| 666 | /// [SemanticsNode] found by `finder`. |
| 667 | /// |
| 668 | /// Throws a [StateError] if: |
| 669 | /// * The given `finder` returns zero or more than one result. |
| 670 | /// * The [SemanticsNode] found with `finder` does not support |
| 671 | /// [SemanticsAction.customAction]. |
| 672 | void customAction(finders.FinderBase<SemanticsNode> finder, CustomSemanticsAction action) { |
| 673 | performAction( |
| 674 | finder, |
| 675 | SemanticsAction.customAction, |
| 676 | args: CustomSemanticsAction.getIdentifier(action), |
| 677 | ); |
| 678 | } |
| 679 | |
| 680 | /// Performs a [SemanticsAction.dismiss] action on the [SemanticsNode] |
| 681 | /// found by `finder`. |
| 682 | /// |
| 683 | /// Throws a [StateError] if: |
| 684 | /// * The given `finder` returns zero or more than one result. |
| 685 | /// * The [SemanticsNode] found with `finder` does not support |
| 686 | /// [SemanticsAction.dismiss]. |
| 687 | void dismiss(finders.FinderBase<SemanticsNode> finder) { |
| 688 | performAction(finder, SemanticsAction.dismiss); |
| 689 | } |
| 690 | } |
| 691 | |
| 692 | /// Class that programmatically interacts with widgets. |
| 693 | /// |
| 694 | /// For a variant of this class suited specifically for unit tests, see |
| 695 | /// [WidgetTester]. For one suitable for live tests on a device, consider |
| 696 | /// [LiveWidgetController]. |
| 697 | /// |
| 698 | /// Concrete subclasses must implement the [pump] method. |
| 699 | abstract class WidgetController { |
| 700 | /// Creates a widget controller that uses the given binding. |
| 701 | WidgetController(this.binding); |
| 702 | |
| 703 | /// A reference to the current instance of the binding. |
| 704 | final WidgetsBinding binding; |
| 705 | |
| 706 | /// The [TestPlatformDispatcher] that is being used in this test. |
| 707 | /// |
| 708 | /// This will be injected into the framework such that calls to |
| 709 | /// [WidgetsBinding.platformDispatcher] will use this. This allows |
| 710 | /// users to change platform specific properties for testing. |
| 711 | /// |
| 712 | /// See also: |
| 713 | /// |
| 714 | /// * [TestFlutterView] which allows changing view specific properties |
| 715 | /// for testing |
| 716 | /// * [view] and [viewOf] which are used to find |
| 717 | /// [TestFlutterView]s from the widget tree |
| 718 | TestPlatformDispatcher get platformDispatcher => |
| 719 | binding.platformDispatcher as TestPlatformDispatcher; |
| 720 | |
| 721 | /// The [TestFlutterView] provided by default when testing with |
| 722 | /// [WidgetTester.pumpWidget]. |
| 723 | /// |
| 724 | /// If the test uses multiple views, this will return the view that is painted |
| 725 | /// into by [WidgetTester.pumpWidget]. If a different view needs to be |
| 726 | /// accessed use [viewOf] to ensure that the view related to the widget being |
| 727 | /// evaluated is the one that gets updated. |
| 728 | /// |
| 729 | /// See also: |
| 730 | /// |
| 731 | /// * [viewOf], which can find a [TestFlutterView] related to a given finder. |
| 732 | /// This is how to modify view properties for testing when dealing with |
| 733 | /// multiple views. |
| 734 | TestFlutterView get view => platformDispatcher.implicitView!; |
| 735 | |
| 736 | /// Provides access to a [SemanticsController] for testing anything related to |
| 737 | /// the [Semantics] tree. |
| 738 | /// |
| 739 | /// Assistive technologies, search engines, and other analysis tools all make |
| 740 | /// use of the [Semantics] tree to determine the meaning of an application. |
| 741 | /// If semantics has been disabled for the test, this will throw a [StateError]. |
| 742 | SemanticsController get semantics { |
| 743 | if (!binding.semanticsEnabled) { |
| 744 | throw StateError( |
| 745 | 'Semantics are not enabled. Enable them by passing ' |
| 746 | '`semanticsEnabled: true` to `testWidgets`, or by manually creating a ' |
| 747 | '`SemanticsHandle` with `WidgetController.ensureSemantics()`.' , |
| 748 | ); |
| 749 | } |
| 750 | |
| 751 | return _semantics; |
| 752 | } |
| 753 | |
| 754 | late final SemanticsController _semantics = SemanticsController._(this); |
| 755 | |
| 756 | // FINDER API |
| 757 | |
| 758 | // TODO(ianh): verify that the return values are of type T and throw |
| 759 | // a good message otherwise, in all the generic methods below |
| 760 | |
| 761 | /// Finds the [TestFlutterView] that is the closest ancestor of the widget |
| 762 | /// found by [finder]. |
| 763 | /// |
| 764 | /// [TestFlutterView] can be used to modify view specific properties for testing. |
| 765 | /// |
| 766 | /// See also: |
| 767 | /// |
| 768 | /// * [view] which returns the [TestFlutterView] used when only a single |
| 769 | /// view is being used. |
| 770 | TestFlutterView viewOf(finders.FinderBase<Element> finder) { |
| 771 | return _viewOf(finder) as TestFlutterView; |
| 772 | } |
| 773 | |
| 774 | FlutterView _viewOf(finders.FinderBase<Element> finder) { |
| 775 | return firstWidget<View>( |
| 776 | finders.find.ancestor(of: finder, matching: finders.find.byType(View)), |
| 777 | ).view; |
| 778 | } |
| 779 | |
| 780 | /// Checks if `finder` exists in the tree. |
| 781 | bool any(finders.FinderBase<Element> finder) { |
| 782 | TestAsyncUtils.guardSync(); |
| 783 | return finder.evaluate().isNotEmpty; |
| 784 | } |
| 785 | |
| 786 | /// All widgets currently in the widget tree (lazy pre-order traversal). |
| 787 | /// |
| 788 | /// Can contain duplicates, since widgets can be used in multiple |
| 789 | /// places in the widget tree. |
| 790 | Iterable<Widget> get allWidgets { |
| 791 | TestAsyncUtils.guardSync(); |
| 792 | return allElements.map<Widget>((Element element) => element.widget); |
| 793 | } |
| 794 | |
| 795 | /// The matching widget in the widget tree. |
| 796 | /// |
| 797 | /// Throws a [StateError] if `finder` is empty or matches more than |
| 798 | /// one widget. |
| 799 | /// |
| 800 | /// * Use [firstWidget] if you expect to match several widgets but only want the first. |
| 801 | /// * Use [widgetList] if you expect to match several widgets and want all of them. |
| 802 | T widget<T extends Widget>(finders.FinderBase<Element> finder) { |
| 803 | TestAsyncUtils.guardSync(); |
| 804 | return finder.evaluate().single.widget as T; |
| 805 | } |
| 806 | |
| 807 | /// The first matching widget according to a depth-first pre-order |
| 808 | /// traversal of the widget tree. |
| 809 | /// |
| 810 | /// Throws a [StateError] if `finder` is empty. |
| 811 | /// |
| 812 | /// * Use [widget] if you only expect to match one widget. |
| 813 | T firstWidget<T extends Widget>(finders.FinderBase<Element> finder) { |
| 814 | TestAsyncUtils.guardSync(); |
| 815 | return finder.evaluate().first.widget as T; |
| 816 | } |
| 817 | |
| 818 | /// The matching widgets in the widget tree. |
| 819 | /// |
| 820 | /// * Use [widget] if you only expect to match one widget. |
| 821 | /// * Use [firstWidget] if you expect to match several but only want the first. |
| 822 | Iterable<T> widgetList<T extends Widget>(finders.FinderBase<Element> finder) { |
| 823 | TestAsyncUtils.guardSync(); |
| 824 | return finder.evaluate().map<T>((Element element) { |
| 825 | final T result = element.widget as T; |
| 826 | return result; |
| 827 | }); |
| 828 | } |
| 829 | |
| 830 | /// Find all layers that are children of the provided [finder]. |
| 831 | /// |
| 832 | /// The [finder] must match exactly one element. |
| 833 | Iterable<Layer> layerListOf(finders.FinderBase<Element> finder) { |
| 834 | TestAsyncUtils.guardSync(); |
| 835 | final Element element = finder.evaluate().single; |
| 836 | final RenderObject object = element.renderObject!; |
| 837 | RenderObject current = object; |
| 838 | while (current.debugLayer == null) { |
| 839 | current = current.parent!; |
| 840 | } |
| 841 | final ContainerLayer layer = current.debugLayer!; |
| 842 | return _walkLayers(layer); |
| 843 | } |
| 844 | |
| 845 | /// All elements currently in the widget tree (lazy pre-order traversal). |
| 846 | /// |
| 847 | /// The returned iterable is lazy. It does not walk the entire widget tree |
| 848 | /// immediately, but rather a chunk at a time as the iteration progresses |
| 849 | /// using [Iterator.moveNext]. |
| 850 | Iterable<Element> get allElements { |
| 851 | TestAsyncUtils.guardSync(); |
| 852 | return collectAllElementsFrom(binding.rootElement!, skipOffstage: false); |
| 853 | } |
| 854 | |
| 855 | /// The matching element in the widget tree. |
| 856 | /// |
| 857 | /// Throws a [StateError] if `finder` is empty or matches more than |
| 858 | /// one element. |
| 859 | /// |
| 860 | /// * Use [firstElement] if you expect to match several elements but only want the first. |
| 861 | /// * Use [elementList] if you expect to match several elements and want all of them. |
| 862 | T element<T extends Element>(finders.FinderBase<Element> finder) { |
| 863 | TestAsyncUtils.guardSync(); |
| 864 | return finder.evaluate().single as T; |
| 865 | } |
| 866 | |
| 867 | /// The first matching element according to a depth-first pre-order |
| 868 | /// traversal of the widget tree. |
| 869 | /// |
| 870 | /// Throws a [StateError] if `finder` is empty. |
| 871 | /// |
| 872 | /// * Use [element] if you only expect to match one element. |
| 873 | T firstElement<T extends Element>(finders.FinderBase<Element> finder) { |
| 874 | TestAsyncUtils.guardSync(); |
| 875 | return finder.evaluate().first as T; |
| 876 | } |
| 877 | |
| 878 | /// The matching elements in the widget tree. |
| 879 | /// |
| 880 | /// * Use [element] if you only expect to match one element. |
| 881 | /// * Use [firstElement] if you expect to match several but only want the first. |
| 882 | Iterable<T> elementList<T extends Element>(finders.FinderBase<Element> finder) { |
| 883 | TestAsyncUtils.guardSync(); |
| 884 | return finder.evaluate().cast<T>(); |
| 885 | } |
| 886 | |
| 887 | /// All states currently in the widget tree (lazy pre-order traversal). |
| 888 | /// |
| 889 | /// The returned iterable is lazy. It does not walk the entire widget tree |
| 890 | /// immediately, but rather a chunk at a time as the iteration progresses |
| 891 | /// using [Iterator.moveNext]. |
| 892 | Iterable<State> get allStates { |
| 893 | TestAsyncUtils.guardSync(); |
| 894 | return allElements.whereType<StatefulElement>().map<State>( |
| 895 | (StatefulElement element) => element.state, |
| 896 | ); |
| 897 | } |
| 898 | |
| 899 | /// The matching state in the widget tree. |
| 900 | /// |
| 901 | /// Throws a [StateError] if `finder` is empty, matches more than |
| 902 | /// one state, or matches a widget that has no state. |
| 903 | /// |
| 904 | /// * Use [firstState] if you expect to match several states but only want the first. |
| 905 | /// * Use [stateList] if you expect to match several states and want all of them. |
| 906 | T state<T extends State>(finders.FinderBase<Element> finder) { |
| 907 | TestAsyncUtils.guardSync(); |
| 908 | return _stateOf<T>(finder.evaluate().single, finder); |
| 909 | } |
| 910 | |
| 911 | /// The first matching state according to a depth-first pre-order |
| 912 | /// traversal of the widget tree. |
| 913 | /// |
| 914 | /// Throws a [StateError] if `finder` is empty or if the first |
| 915 | /// matching widget has no state. |
| 916 | /// |
| 917 | /// * Use [state] if you only expect to match one state. |
| 918 | T firstState<T extends State>(finders.FinderBase<Element> finder) { |
| 919 | TestAsyncUtils.guardSync(); |
| 920 | return _stateOf<T>(finder.evaluate().first, finder); |
| 921 | } |
| 922 | |
| 923 | /// The matching states in the widget tree. |
| 924 | /// |
| 925 | /// Throws a [StateError] if any of the elements in `finder` match a widget |
| 926 | /// that has no state. |
| 927 | /// |
| 928 | /// * Use [state] if you only expect to match one state. |
| 929 | /// * Use [firstState] if you expect to match several but only want the first. |
| 930 | Iterable<T> stateList<T extends State>(finders.FinderBase<Element> finder) { |
| 931 | TestAsyncUtils.guardSync(); |
| 932 | return finder.evaluate().map<T>((Element element) => _stateOf<T>(element, finder)); |
| 933 | } |
| 934 | |
| 935 | T _stateOf<T extends State>(Element element, finders.FinderBase<Element> finder) { |
| 936 | TestAsyncUtils.guardSync(); |
| 937 | if (element is StatefulElement) { |
| 938 | return element.state as T; |
| 939 | } |
| 940 | throw StateError( |
| 941 | 'Widget of type ${element.widget.runtimeType}, with ${finder.describeMatch(finders.Plurality.many)}, is not a StatefulWidget.' , |
| 942 | ); |
| 943 | } |
| 944 | |
| 945 | /// Render objects of all the widgets currently in the widget tree |
| 946 | /// (lazy pre-order traversal). |
| 947 | /// |
| 948 | /// This will almost certainly include many duplicates since the |
| 949 | /// render object of a [StatelessWidget] or [StatefulWidget] is the |
| 950 | /// render object of its child; only [RenderObjectWidget]s have |
| 951 | /// their own render object. |
| 952 | Iterable<RenderObject> get allRenderObjects { |
| 953 | TestAsyncUtils.guardSync(); |
| 954 | return allElements.map<RenderObject>((Element element) => element.renderObject!); |
| 955 | } |
| 956 | |
| 957 | /// The render object of the matching widget in the widget tree. |
| 958 | /// |
| 959 | /// Throws a [StateError] if `finder` is empty or matches more than |
| 960 | /// one widget (even if they all have the same render object). |
| 961 | /// |
| 962 | /// * Use [firstRenderObject] if you expect to match several render objects but only want the first. |
| 963 | /// * Use [renderObjectList] if you expect to match several render objects and want all of them. |
| 964 | T renderObject<T extends RenderObject>(finders.FinderBase<Element> finder) { |
| 965 | TestAsyncUtils.guardSync(); |
| 966 | return finder.evaluate().single.renderObject! as T; |
| 967 | } |
| 968 | |
| 969 | /// The render object of the first matching widget according to a |
| 970 | /// depth-first pre-order traversal of the widget tree. |
| 971 | /// |
| 972 | /// Throws a [StateError] if `finder` is empty. |
| 973 | /// |
| 974 | /// * Use [renderObject] if you only expect to match one render object. |
| 975 | T firstRenderObject<T extends RenderObject>(finders.FinderBase<Element> finder) { |
| 976 | TestAsyncUtils.guardSync(); |
| 977 | return finder.evaluate().first.renderObject! as T; |
| 978 | } |
| 979 | |
| 980 | /// The render objects of the matching widgets in the widget tree. |
| 981 | /// |
| 982 | /// * Use [renderObject] if you only expect to match one render object. |
| 983 | /// * Use [firstRenderObject] if you expect to match several but only want the first. |
| 984 | Iterable<T> renderObjectList<T extends RenderObject>(finders.FinderBase<Element> finder) { |
| 985 | TestAsyncUtils.guardSync(); |
| 986 | return finder.evaluate().map<T>((Element element) { |
| 987 | final T result = element.renderObject! as T; |
| 988 | return result; |
| 989 | }); |
| 990 | } |
| 991 | |
| 992 | /// Returns a list of all the [Layer] objects in the rendering. |
| 993 | List<Layer> get layers { |
| 994 | return <Layer>[ |
| 995 | for (final RenderView renderView in binding.renderViews) |
| 996 | ..._walkLayers(renderView.debugLayer!), |
| 997 | ]; |
| 998 | } |
| 999 | |
| 1000 | Iterable<Layer> _walkLayers(Layer layer) sync* { |
| 1001 | TestAsyncUtils.guardSync(); |
| 1002 | yield layer; |
| 1003 | if (layer is ContainerLayer) { |
| 1004 | final ContainerLayer root = layer; |
| 1005 | Layer? child = root.firstChild; |
| 1006 | while (child != null) { |
| 1007 | yield* _walkLayers(child); |
| 1008 | child = child.nextSibling; |
| 1009 | } |
| 1010 | } |
| 1011 | } |
| 1012 | |
| 1013 | // INTERACTION |
| 1014 | |
| 1015 | /// Dispatch a pointer down / pointer up sequence at the center of |
| 1016 | /// the given widget, assuming it is exposed. |
| 1017 | /// |
| 1018 | /// {@template flutter.flutter_test.WidgetController.tap.warnIfMissed} |
| 1019 | /// The `warnIfMissed` argument, if true (the default), causes a warning to be |
| 1020 | /// displayed on the console if the specified [Finder] indicates a widget and |
| 1021 | /// location that, were a pointer event to be sent to that location, would not |
| 1022 | /// actually send any events to the widget (e.g. because the widget is |
| 1023 | /// obscured, or the location is off-screen, or the widget is transparent to |
| 1024 | /// pointer events). |
| 1025 | /// |
| 1026 | /// Set the argument to false to silence that warning if you intend to not |
| 1027 | /// actually hit the specified element. |
| 1028 | /// {@endtemplate} |
| 1029 | /// |
| 1030 | /// For example, a test that verifies that tapping a disabled button does not |
| 1031 | /// trigger the button would set `warnIfMissed` to false, because the button |
| 1032 | /// would ignore the tap. |
| 1033 | Future<void> tap( |
| 1034 | finders.FinderBase<Element> finder, { |
| 1035 | int? pointer, |
| 1036 | int buttons = kPrimaryButton, |
| 1037 | bool warnIfMissed = true, |
| 1038 | PointerDeviceKind kind = PointerDeviceKind.touch, |
| 1039 | }) { |
| 1040 | return tapAt( |
| 1041 | getCenter(finder, warnIfMissed: warnIfMissed, callee: 'tap' ), |
| 1042 | pointer: pointer, |
| 1043 | buttons: buttons, |
| 1044 | kind: kind, |
| 1045 | ); |
| 1046 | } |
| 1047 | |
| 1048 | /// Dispatch a pointer down / pointer up sequence at a hit-testable |
| 1049 | /// [InlineSpan] (typically a [TextSpan]) within the given text range. |
| 1050 | /// |
| 1051 | /// This method performs a more spatially precise tap action on a piece of |
| 1052 | /// static text, than the widget-based [tap] method. |
| 1053 | /// |
| 1054 | /// The given [Finder] must find one and only one matching substring, and the |
| 1055 | /// substring must be hit-testable (meaning, it must not be off-screen, or be |
| 1056 | /// obscured by other widgets, or in a disabled widget). Otherwise this method |
| 1057 | /// throws a [FlutterError]. |
| 1058 | /// |
| 1059 | /// If the target substring contains more than one hit-testable [InlineSpan]s, |
| 1060 | /// [tapOnText] taps on one of them, but does not guarantee which. |
| 1061 | /// |
| 1062 | /// The `pointer` and `button` arguments specify [PointerEvent.pointer] and |
| 1063 | /// [PointerEvent.buttons] of the tap event. |
| 1064 | Future<void> tapOnText( |
| 1065 | finders.FinderBase<finders.TextRangeContext> textRangeFinder, { |
| 1066 | int? pointer, |
| 1067 | int buttons = kPrimaryButton, |
| 1068 | }) { |
| 1069 | final Iterable<finders.TextRangeContext> ranges = textRangeFinder.evaluate(); |
| 1070 | if (ranges.isEmpty) { |
| 1071 | throw FlutterError(textRangeFinder.toString()); |
| 1072 | } |
| 1073 | if (ranges.length > 1) { |
| 1074 | throw FlutterError( |
| 1075 | ' $textRangeFinder. The "tapOnText" method needs a single non-empty TextRange.' , |
| 1076 | ); |
| 1077 | } |
| 1078 | final Offset? tapLocation = _findHitTestableOffsetIn(ranges.single); |
| 1079 | if (tapLocation == null) { |
| 1080 | final finders.TextRangeContext found = textRangeFinder.evaluate().single; |
| 1081 | throw FlutterError.fromParts(<DiagnosticsNode>[ |
| 1082 | ErrorSummary('Finder specifies a TextRange that can not receive pointer events.' ), |
| 1083 | ErrorDescription('The finder used was: ${textRangeFinder.toString(describeSelf: true)}' ), |
| 1084 | ErrorDescription( |
| 1085 | 'Found a matching substring in a static text widget, within ${found.textRange}.' , |
| 1086 | ), |
| 1087 | ErrorDescription( |
| 1088 | 'But the "tapOnText" method could not find a hit-testable Offset with in that text range.' , |
| 1089 | ), |
| 1090 | found.renderObject.toDiagnosticsNode( |
| 1091 | name: 'The RenderBox of that static text widget was' , |
| 1092 | style: DiagnosticsTreeStyle.shallow, |
| 1093 | ), |
| 1094 | ]); |
| 1095 | } |
| 1096 | return tapAt(tapLocation, pointer: pointer, buttons: buttons); |
| 1097 | } |
| 1098 | |
| 1099 | /// Dispatch a pointer down / pointer up sequence at the given location. |
| 1100 | Future<void> tapAt( |
| 1101 | Offset location, { |
| 1102 | int? pointer, |
| 1103 | int buttons = kPrimaryButton, |
| 1104 | PointerDeviceKind kind = PointerDeviceKind.touch, |
| 1105 | }) { |
| 1106 | return TestAsyncUtils.guard<void>(() async { |
| 1107 | final TestGesture gesture = await startGesture( |
| 1108 | location, |
| 1109 | pointer: pointer, |
| 1110 | buttons: buttons, |
| 1111 | kind: kind, |
| 1112 | ); |
| 1113 | await gesture.up(); |
| 1114 | }); |
| 1115 | } |
| 1116 | |
| 1117 | /// Dispatch a pointer down at the center of the given widget, assuming it is |
| 1118 | /// exposed. |
| 1119 | /// |
| 1120 | /// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed} |
| 1121 | /// |
| 1122 | /// The return value is a [TestGesture] object that can be used to continue the |
| 1123 | /// gesture (e.g. moving the pointer or releasing it). |
| 1124 | /// |
| 1125 | /// See also: |
| 1126 | /// |
| 1127 | /// * [tap], which presses and releases a pointer at the given location. |
| 1128 | /// * [longPress], which presses and releases a pointer with a gap in |
| 1129 | /// between long enough to trigger the long-press gesture. |
| 1130 | Future<TestGesture> press( |
| 1131 | finders.FinderBase<Element> finder, { |
| 1132 | int? pointer, |
| 1133 | int buttons = kPrimaryButton, |
| 1134 | bool warnIfMissed = true, |
| 1135 | PointerDeviceKind kind = PointerDeviceKind.touch, |
| 1136 | }) { |
| 1137 | return TestAsyncUtils.guard<TestGesture>(() { |
| 1138 | return startGesture( |
| 1139 | getCenter(finder, warnIfMissed: warnIfMissed, callee: 'press' ), |
| 1140 | pointer: pointer, |
| 1141 | buttons: buttons, |
| 1142 | kind: kind, |
| 1143 | ); |
| 1144 | }); |
| 1145 | } |
| 1146 | |
| 1147 | /// Dispatch a pointer down / pointer up sequence (with a delay of |
| 1148 | /// [kLongPressTimeout] + [kPressTimeout] between the two events) at the |
| 1149 | /// center of the given widget, assuming it is exposed. |
| 1150 | /// |
| 1151 | /// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed} |
| 1152 | /// |
| 1153 | /// For example, consider a widget that, when long-pressed, shows an overlay |
| 1154 | /// that obscures the original widget. A test for that widget might first |
| 1155 | /// long-press that widget with `warnIfMissed` at its default value true, then |
| 1156 | /// later verify that long-pressing the same location (using the same finder) |
| 1157 | /// has no effect (since the widget is now obscured), setting `warnIfMissed` |
| 1158 | /// to false on that second call. |
| 1159 | Future<void> longPress( |
| 1160 | finders.FinderBase<Element> finder, { |
| 1161 | int? pointer, |
| 1162 | int buttons = kPrimaryButton, |
| 1163 | bool warnIfMissed = true, |
| 1164 | PointerDeviceKind kind = PointerDeviceKind.touch, |
| 1165 | }) { |
| 1166 | return longPressAt( |
| 1167 | getCenter(finder, warnIfMissed: warnIfMissed, callee: 'longPress' ), |
| 1168 | pointer: pointer, |
| 1169 | buttons: buttons, |
| 1170 | kind: kind, |
| 1171 | ); |
| 1172 | } |
| 1173 | |
| 1174 | /// Dispatch a pointer down / pointer up sequence at the given location with |
| 1175 | /// a delay of [kLongPressTimeout] + [kPressTimeout] between the two events. |
| 1176 | Future<void> longPressAt( |
| 1177 | Offset location, { |
| 1178 | int? pointer, |
| 1179 | int buttons = kPrimaryButton, |
| 1180 | PointerDeviceKind kind = PointerDeviceKind.touch, |
| 1181 | }) { |
| 1182 | return TestAsyncUtils.guard<void>(() async { |
| 1183 | final TestGesture gesture = await startGesture( |
| 1184 | location, |
| 1185 | pointer: pointer, |
| 1186 | buttons: buttons, |
| 1187 | kind: kind, |
| 1188 | ); |
| 1189 | await pump(kLongPressTimeout + kPressTimeout); |
| 1190 | await gesture.up(); |
| 1191 | }); |
| 1192 | } |
| 1193 | |
| 1194 | /// Attempts a fling gesture starting from the center of the given |
| 1195 | /// widget, moving the given distance, reaching the given speed. |
| 1196 | /// |
| 1197 | /// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed} |
| 1198 | /// |
| 1199 | /// {@template flutter.flutter_test.WidgetController.fling.offset} |
| 1200 | /// The `offset` represents a distance the pointer moves in the global |
| 1201 | /// coordinate system of the screen. |
| 1202 | /// |
| 1203 | /// Positive [Offset.dy] values mean the pointer moves downward. Negative |
| 1204 | /// [Offset.dy] values mean the pointer moves upwards. Accordingly, positive |
| 1205 | /// [Offset.dx] values mean the pointer moves towards the right. Negative |
| 1206 | /// [Offset.dx] values mean the pointer moves towards left. |
| 1207 | /// {@endtemplate} |
| 1208 | /// |
| 1209 | /// {@template flutter.flutter_test.WidgetController.fling} |
| 1210 | /// This can pump frames. |
| 1211 | /// |
| 1212 | /// Exactly 50 pointer events are synthesized. |
| 1213 | /// |
| 1214 | /// The `speed` is in pixels per second in the direction given by `offset`. |
| 1215 | /// |
| 1216 | /// The `offset` and `speed` control the interval between each pointer event. |
| 1217 | /// For example, if the `offset` is 200 pixels down, and the `speed` is 800 |
| 1218 | /// pixels per second, the pointer events will be sent for each increment |
| 1219 | /// of 4 pixels (200/50), over 250ms (200/800), meaning events will be sent |
| 1220 | /// every 1.25ms (250/200). |
| 1221 | /// |
| 1222 | /// To make tests more realistic, frames may be pumped during this time (using |
| 1223 | /// calls to [pump]). If the total duration is longer than `frameInterval`, |
| 1224 | /// then one frame is pumped each time that amount of time elapses while |
| 1225 | /// sending events, or each time an event is synthesized, whichever is rarer. |
| 1226 | /// |
| 1227 | /// See [LiveTestWidgetsFlutterBindingFramePolicy.benchmarkLive] if the method |
| 1228 | /// is used in a live environment and accurate time control is important. |
| 1229 | /// |
| 1230 | /// The `initialOffset` argument, if non-zero, causes the pointer to first |
| 1231 | /// apply that offset, then pump a delay of `initialOffsetDelay`. This can be |
| 1232 | /// used to simulate a drag followed by a fling, including dragging in the |
| 1233 | /// opposite direction of the fling (e.g. dragging 200 pixels to the right, |
| 1234 | /// then fling to the left over 200 pixels, ending at the exact point that the |
| 1235 | /// drag started). |
| 1236 | /// {@endtemplate} |
| 1237 | /// |
| 1238 | /// A fling is essentially a drag that ends at a particular speed. If you |
| 1239 | /// just want to drag and end without a fling, use [drag]. |
| 1240 | Future<void> fling( |
| 1241 | finders.FinderBase<Element> finder, |
| 1242 | Offset offset, |
| 1243 | double speed, { |
| 1244 | int? pointer, |
| 1245 | int buttons = kPrimaryButton, |
| 1246 | Duration frameInterval = const Duration(milliseconds: 16), |
| 1247 | Offset initialOffset = Offset.zero, |
| 1248 | Duration initialOffsetDelay = const Duration(seconds: 1), |
| 1249 | bool warnIfMissed = true, |
| 1250 | PointerDeviceKind deviceKind = PointerDeviceKind.touch, |
| 1251 | }) { |
| 1252 | return flingFrom( |
| 1253 | getCenter(finder, warnIfMissed: warnIfMissed, callee: 'fling' ), |
| 1254 | offset, |
| 1255 | speed, |
| 1256 | pointer: pointer, |
| 1257 | buttons: buttons, |
| 1258 | frameInterval: frameInterval, |
| 1259 | initialOffset: initialOffset, |
| 1260 | initialOffsetDelay: initialOffsetDelay, |
| 1261 | deviceKind: deviceKind, |
| 1262 | ); |
| 1263 | } |
| 1264 | |
| 1265 | /// Attempts a fling gesture starting from the given location, moving the |
| 1266 | /// given distance, reaching the given speed. |
| 1267 | /// |
| 1268 | /// {@macro flutter.flutter_test.WidgetController.fling} |
| 1269 | /// |
| 1270 | /// A fling is essentially a drag that ends at a particular speed. If you |
| 1271 | /// just want to drag and end without a fling, use [dragFrom]. |
| 1272 | Future<void> flingFrom( |
| 1273 | Offset startLocation, |
| 1274 | Offset offset, |
| 1275 | double speed, { |
| 1276 | int? pointer, |
| 1277 | int buttons = kPrimaryButton, |
| 1278 | Duration frameInterval = const Duration(milliseconds: 16), |
| 1279 | Offset initialOffset = Offset.zero, |
| 1280 | Duration initialOffsetDelay = const Duration(seconds: 1), |
| 1281 | PointerDeviceKind deviceKind = PointerDeviceKind.touch, |
| 1282 | }) { |
| 1283 | assert(offset.distance > 0.0); |
| 1284 | assert(speed > 0.0); // speed is pixels/second |
| 1285 | return TestAsyncUtils.guard<void>(() async { |
| 1286 | final TestPointer testPointer = TestPointer( |
| 1287 | pointer ?? _getNextPointer(), |
| 1288 | deviceKind, |
| 1289 | null, |
| 1290 | buttons, |
| 1291 | ); |
| 1292 | const int kMoveCount = |
| 1293 | 50; // Needs to be >= kHistorySize, see _LeastSquaresVelocityTrackerStrategy |
| 1294 | final double timeStampDelta = 1000000.0 * offset.distance / (kMoveCount * speed); |
| 1295 | double timeStamp = 0.0; |
| 1296 | double lastTimeStamp = timeStamp; |
| 1297 | await sendEventToBinding( |
| 1298 | testPointer.down(startLocation, timeStamp: Duration(microseconds: timeStamp.round())), |
| 1299 | ); |
| 1300 | if (initialOffset.distance > 0.0) { |
| 1301 | await sendEventToBinding( |
| 1302 | testPointer.move( |
| 1303 | startLocation + initialOffset, |
| 1304 | timeStamp: Duration(microseconds: timeStamp.round()), |
| 1305 | ), |
| 1306 | ); |
| 1307 | timeStamp += initialOffsetDelay.inMicroseconds; |
| 1308 | await pump(initialOffsetDelay); |
| 1309 | } |
| 1310 | for (int i = 0; i <= kMoveCount; i += 1) { |
| 1311 | final Offset location = |
| 1312 | startLocation + initialOffset + Offset.lerp(Offset.zero, offset, i / kMoveCount)!; |
| 1313 | await sendEventToBinding( |
| 1314 | testPointer.move(location, timeStamp: Duration(microseconds: timeStamp.round())), |
| 1315 | ); |
| 1316 | timeStamp += timeStampDelta; |
| 1317 | if (timeStamp - lastTimeStamp > frameInterval.inMicroseconds) { |
| 1318 | await pump(Duration(microseconds: (timeStamp - lastTimeStamp).truncate())); |
| 1319 | lastTimeStamp = timeStamp; |
| 1320 | } |
| 1321 | } |
| 1322 | await sendEventToBinding( |
| 1323 | testPointer.up(timeStamp: Duration(microseconds: timeStamp.round())), |
| 1324 | ); |
| 1325 | }); |
| 1326 | } |
| 1327 | |
| 1328 | /// Attempts a trackpad fling gesture starting from the center of the given |
| 1329 | /// widget, moving the given distance, reaching the given speed. A trackpad |
| 1330 | /// fling sends PointerPanZoom events instead of a sequence of touch events. |
| 1331 | /// |
| 1332 | /// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed} |
| 1333 | /// |
| 1334 | /// {@macro flutter.flutter_test.WidgetController.fling} |
| 1335 | /// |
| 1336 | /// A fling is essentially a drag that ends at a particular speed. If you |
| 1337 | /// just want to drag and end without a fling, use [drag]. |
| 1338 | Future<void> trackpadFling( |
| 1339 | finders.FinderBase<Element> finder, |
| 1340 | Offset offset, |
| 1341 | double speed, { |
| 1342 | int? pointer, |
| 1343 | int buttons = kPrimaryButton, |
| 1344 | Duration frameInterval = const Duration(milliseconds: 16), |
| 1345 | Offset initialOffset = Offset.zero, |
| 1346 | Duration initialOffsetDelay = const Duration(seconds: 1), |
| 1347 | bool warnIfMissed = true, |
| 1348 | }) { |
| 1349 | return trackpadFlingFrom( |
| 1350 | getCenter(finder, warnIfMissed: warnIfMissed, callee: 'fling' ), |
| 1351 | offset, |
| 1352 | speed, |
| 1353 | pointer: pointer, |
| 1354 | buttons: buttons, |
| 1355 | frameInterval: frameInterval, |
| 1356 | initialOffset: initialOffset, |
| 1357 | initialOffsetDelay: initialOffsetDelay, |
| 1358 | ); |
| 1359 | } |
| 1360 | |
| 1361 | /// Attempts a fling gesture starting from the given location, moving the |
| 1362 | /// given distance, reaching the given speed. A trackpad fling sends |
| 1363 | /// PointerPanZoom events instead of a sequence of touch events. |
| 1364 | /// |
| 1365 | /// {@macro flutter.flutter_test.WidgetController.fling} |
| 1366 | /// |
| 1367 | /// A fling is essentially a drag that ends at a particular speed. If you |
| 1368 | /// just want to drag and end without a fling, use [dragFrom]. |
| 1369 | Future<void> trackpadFlingFrom( |
| 1370 | Offset startLocation, |
| 1371 | Offset offset, |
| 1372 | double speed, { |
| 1373 | int? pointer, |
| 1374 | int buttons = kPrimaryButton, |
| 1375 | Duration frameInterval = const Duration(milliseconds: 16), |
| 1376 | Offset initialOffset = Offset.zero, |
| 1377 | Duration initialOffsetDelay = const Duration(seconds: 1), |
| 1378 | }) { |
| 1379 | assert(offset.distance > 0.0); |
| 1380 | assert(speed > 0.0); // speed is pixels/second |
| 1381 | return TestAsyncUtils.guard<void>(() async { |
| 1382 | final TestPointer testPointer = TestPointer( |
| 1383 | pointer ?? _getNextPointer(), |
| 1384 | PointerDeviceKind.trackpad, |
| 1385 | null, |
| 1386 | buttons, |
| 1387 | ); |
| 1388 | const int kMoveCount = |
| 1389 | 50; // Needs to be >= kHistorySize, see _LeastSquaresVelocityTrackerStrategy |
| 1390 | final double timeStampDelta = 1000000.0 * offset.distance / (kMoveCount * speed); |
| 1391 | double timeStamp = 0.0; |
| 1392 | double lastTimeStamp = timeStamp; |
| 1393 | await sendEventToBinding( |
| 1394 | testPointer.panZoomStart( |
| 1395 | startLocation, |
| 1396 | timeStamp: Duration(microseconds: timeStamp.round()), |
| 1397 | ), |
| 1398 | ); |
| 1399 | if (initialOffset.distance > 0.0) { |
| 1400 | await sendEventToBinding( |
| 1401 | testPointer.panZoomUpdate( |
| 1402 | startLocation, |
| 1403 | pan: initialOffset, |
| 1404 | timeStamp: Duration(microseconds: timeStamp.round()), |
| 1405 | ), |
| 1406 | ); |
| 1407 | timeStamp += initialOffsetDelay.inMicroseconds; |
| 1408 | await pump(initialOffsetDelay); |
| 1409 | } |
| 1410 | for (int i = 0; i <= kMoveCount; i += 1) { |
| 1411 | final Offset pan = initialOffset + Offset.lerp(Offset.zero, offset, i / kMoveCount)!; |
| 1412 | await sendEventToBinding( |
| 1413 | testPointer.panZoomUpdate( |
| 1414 | startLocation, |
| 1415 | pan: pan, |
| 1416 | timeStamp: Duration(microseconds: timeStamp.round()), |
| 1417 | ), |
| 1418 | ); |
| 1419 | timeStamp += timeStampDelta; |
| 1420 | if (timeStamp - lastTimeStamp > frameInterval.inMicroseconds) { |
| 1421 | await pump(Duration(microseconds: (timeStamp - lastTimeStamp).truncate())); |
| 1422 | lastTimeStamp = timeStamp; |
| 1423 | } |
| 1424 | } |
| 1425 | await sendEventToBinding( |
| 1426 | testPointer.panZoomEnd(timeStamp: Duration(microseconds: timeStamp.round())), |
| 1427 | ); |
| 1428 | }); |
| 1429 | } |
| 1430 | |
| 1431 | /// A simulator of how the framework handles a series of [PointerEvent]s |
| 1432 | /// received from the Flutter engine. |
| 1433 | /// |
| 1434 | /// The [PointerEventRecord.timeDelay] is used as the time delay of the events |
| 1435 | /// injection relative to the starting point of the method call. |
| 1436 | /// |
| 1437 | /// Returns a list of the difference between the real delay time when the |
| 1438 | /// [PointerEventRecord.events] are processed and |
| 1439 | /// [PointerEventRecord.timeDelay]. |
| 1440 | /// - For [AutomatedTestWidgetsFlutterBinding] where the clock is fake, the |
| 1441 | /// return value should be exact zeros. |
| 1442 | /// - For [LiveTestWidgetsFlutterBinding], the values are typically small |
| 1443 | /// positives, meaning the event happens a little later than the set time, |
| 1444 | /// but a very small portion may have a tiny negative value for about tens of |
| 1445 | /// microseconds. This is due to the nature of [Future.delayed]. |
| 1446 | /// |
| 1447 | /// The closer the return values are to zero the more faithful it is to the |
| 1448 | /// `records`. |
| 1449 | /// |
| 1450 | /// See [PointerEventRecord]. |
| 1451 | Future<List<Duration>> handlePointerEventRecord(List<PointerEventRecord> records); |
| 1452 | |
| 1453 | /// Called to indicate that there should be a new frame after an optional |
| 1454 | /// delay. |
| 1455 | /// |
| 1456 | /// The frame is pumped after a delay of [duration] if [duration] is not null, |
| 1457 | /// or immediately otherwise. |
| 1458 | /// |
| 1459 | /// This is invoked by [flingFrom], for instance, so that the sequence of |
| 1460 | /// pointer events occurs over time. |
| 1461 | /// |
| 1462 | /// The [WidgetTester] subclass implements this by deferring to the [binding]. |
| 1463 | /// |
| 1464 | /// See also: |
| 1465 | /// |
| 1466 | /// * [SchedulerBinding.endOfFrame], which returns a future that could be |
| 1467 | /// appropriate to return in the implementation of this method. |
| 1468 | Future<void> pump([Duration duration]); |
| 1469 | |
| 1470 | /// Repeatedly calls [pump] with the given `duration` until there are no |
| 1471 | /// longer any frames scheduled. This will call [pump] at least once, even if |
| 1472 | /// no frames are scheduled when the function is called, to flush any pending |
| 1473 | /// microtasks which may themselves schedule a frame. |
| 1474 | /// |
| 1475 | /// This essentially waits for all animations to have completed. |
| 1476 | /// |
| 1477 | /// If it takes longer that the given `timeout` to settle, then the test will |
| 1478 | /// fail (this method will throw an exception). In particular, this means that |
| 1479 | /// if there is an infinite animation in progress (for example, if there is an |
| 1480 | /// indeterminate progress indicator spinning), this method will throw. |
| 1481 | /// |
| 1482 | /// The default timeout is ten minutes, which is longer than most reasonable |
| 1483 | /// finite animations would last. |
| 1484 | /// |
| 1485 | /// If the function returns, it returns the number of pumps that it performed. |
| 1486 | /// |
| 1487 | /// In general, it is better practice to figure out exactly why each frame is |
| 1488 | /// needed, and then to [pump] exactly as many frames as necessary. This will |
| 1489 | /// help catch regressions where, for instance, an animation is being started |
| 1490 | /// one frame later than it should. |
| 1491 | /// |
| 1492 | /// Alternatively, one can check that the return value from this function |
| 1493 | /// matches the expected number of pumps. |
| 1494 | Future<int> pumpAndSettle([Duration duration = const Duration(milliseconds: 100)]); |
| 1495 | |
| 1496 | /// Attempts to drag the given widget by the given offset, by |
| 1497 | /// starting a drag in the middle of the widget. |
| 1498 | /// |
| 1499 | /// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed} |
| 1500 | /// |
| 1501 | /// If you want the drag to end with a speed so that the gesture recognition |
| 1502 | /// system identifies the gesture as a fling, consider using [fling] instead. |
| 1503 | /// |
| 1504 | /// The operation happens at once. If you want the drag to last for a period |
| 1505 | /// of time, consider using [timedDrag]. |
| 1506 | /// |
| 1507 | /// {@macro flutter.flutter_test.WidgetController.fling.offset} |
| 1508 | /// |
| 1509 | /// {@template flutter.flutter_test.WidgetController.drag} |
| 1510 | /// By default, if the x or y component of offset is greater than |
| 1511 | /// [kDragSlopDefault], the gesture is broken up into two separate moves |
| 1512 | /// calls. Changing `touchSlopX` or `touchSlopY` will change the minimum |
| 1513 | /// amount of movement in the respective axis before the drag will be broken |
| 1514 | /// into multiple calls. To always send the drag with just a single call to |
| 1515 | /// [TestGesture.moveBy], `touchSlopX` and `touchSlopY` should be set to 0. |
| 1516 | /// |
| 1517 | /// Breaking the drag into multiple moves is necessary for accurate execution |
| 1518 | /// of drag update calls with a [DragStartBehavior] variable set to |
| 1519 | /// [DragStartBehavior.start]. Without such a change, the dragUpdate callback |
| 1520 | /// from a drag recognizer will never be invoked. |
| 1521 | /// |
| 1522 | /// To force this function to a send a single move event, the `touchSlopX` and |
| 1523 | /// `touchSlopY` variables should be set to 0. However, generally, these values |
| 1524 | /// should be left to their default values. |
| 1525 | /// {@endtemplate} |
| 1526 | Future<void> drag( |
| 1527 | finders.FinderBase<Element> finder, |
| 1528 | Offset offset, { |
| 1529 | int? pointer, |
| 1530 | int buttons = kPrimaryButton, |
| 1531 | double touchSlopX = kDragSlopDefault, |
| 1532 | double touchSlopY = kDragSlopDefault, |
| 1533 | bool warnIfMissed = true, |
| 1534 | PointerDeviceKind kind = PointerDeviceKind.touch, |
| 1535 | }) { |
| 1536 | return dragFrom( |
| 1537 | getCenter(finder, warnIfMissed: warnIfMissed, callee: 'drag' ), |
| 1538 | offset, |
| 1539 | pointer: pointer, |
| 1540 | buttons: buttons, |
| 1541 | touchSlopX: touchSlopX, |
| 1542 | touchSlopY: touchSlopY, |
| 1543 | kind: kind, |
| 1544 | ); |
| 1545 | } |
| 1546 | |
| 1547 | /// Attempts a drag gesture consisting of a pointer down, a move by |
| 1548 | /// the given offset, and a pointer up. |
| 1549 | /// |
| 1550 | /// If you want the drag to end with a speed so that the gesture recognition |
| 1551 | /// system identifies the gesture as a fling, consider using [flingFrom] |
| 1552 | /// instead. |
| 1553 | /// |
| 1554 | /// The operation happens at once. If you want the drag to last for a period |
| 1555 | /// of time, consider using [timedDragFrom]. |
| 1556 | /// |
| 1557 | /// {@macro flutter.flutter_test.WidgetController.drag} |
| 1558 | Future<void> dragFrom( |
| 1559 | Offset startLocation, |
| 1560 | Offset offset, { |
| 1561 | int? pointer, |
| 1562 | int buttons = kPrimaryButton, |
| 1563 | double touchSlopX = kDragSlopDefault, |
| 1564 | double touchSlopY = kDragSlopDefault, |
| 1565 | PointerDeviceKind kind = PointerDeviceKind.touch, |
| 1566 | }) { |
| 1567 | assert(kDragSlopDefault > kTouchSlop); |
| 1568 | return TestAsyncUtils.guard<void>(() async { |
| 1569 | final TestGesture gesture = await startGesture( |
| 1570 | startLocation, |
| 1571 | pointer: pointer, |
| 1572 | buttons: buttons, |
| 1573 | kind: kind, |
| 1574 | ); |
| 1575 | |
| 1576 | final double xSign = offset.dx.sign; |
| 1577 | final double ySign = offset.dy.sign; |
| 1578 | |
| 1579 | final double offsetX = offset.dx; |
| 1580 | final double offsetY = offset.dy; |
| 1581 | |
| 1582 | final bool separateX = offset.dx.abs() > touchSlopX && touchSlopX > 0; |
| 1583 | final bool separateY = offset.dy.abs() > touchSlopY && touchSlopY > 0; |
| 1584 | |
| 1585 | if (separateY || separateX) { |
| 1586 | final double offsetSlope = offsetY / offsetX; |
| 1587 | final double inverseOffsetSlope = offsetX / offsetY; |
| 1588 | final double slopSlope = touchSlopY / touchSlopX; |
| 1589 | final double absoluteOffsetSlope = offsetSlope.abs(); |
| 1590 | final double signedSlopX = touchSlopX * xSign; |
| 1591 | final double signedSlopY = touchSlopY * ySign; |
| 1592 | if (absoluteOffsetSlope != slopSlope) { |
| 1593 | // The drag goes through one or both of the extents of the edges of the box. |
| 1594 | if (absoluteOffsetSlope < slopSlope) { |
| 1595 | assert(offsetX.abs() > touchSlopX); |
| 1596 | // The drag goes through the vertical edge of the box. |
| 1597 | // It is guaranteed that the |offsetX| > touchSlopX. |
| 1598 | final double diffY = offsetSlope.abs() * touchSlopX * ySign; |
| 1599 | |
| 1600 | // The vector from the origin to the vertical edge. |
| 1601 | await gesture.moveBy(Offset(signedSlopX, diffY)); |
| 1602 | if (offsetY.abs() <= touchSlopY) { |
| 1603 | // The drag ends on or before getting to the horizontal extension of the horizontal edge. |
| 1604 | await gesture.moveBy(Offset(offsetX - signedSlopX, offsetY - diffY)); |
| 1605 | } else { |
| 1606 | final double diffY2 = signedSlopY - diffY; |
| 1607 | final double diffX2 = inverseOffsetSlope * diffY2; |
| 1608 | |
| 1609 | // The vector from the edge of the box to the horizontal extension of the horizontal edge. |
| 1610 | await gesture.moveBy(Offset(diffX2, diffY2)); |
| 1611 | await gesture.moveBy(Offset(offsetX - diffX2 - signedSlopX, offsetY - signedSlopY)); |
| 1612 | } |
| 1613 | } else { |
| 1614 | assert(offsetY.abs() > touchSlopY); |
| 1615 | // The drag goes through the horizontal edge of the box. |
| 1616 | // It is guaranteed that the |offsetY| > touchSlopY. |
| 1617 | final double diffX = inverseOffsetSlope.abs() * touchSlopY * xSign; |
| 1618 | |
| 1619 | // The vector from the origin to the vertical edge. |
| 1620 | await gesture.moveBy(Offset(diffX, signedSlopY)); |
| 1621 | if (offsetX.abs() <= touchSlopX) { |
| 1622 | // The drag ends on or before getting to the vertical extension of the vertical edge. |
| 1623 | await gesture.moveBy(Offset(offsetX - diffX, offsetY - signedSlopY)); |
| 1624 | } else { |
| 1625 | final double diffX2 = signedSlopX - diffX; |
| 1626 | final double diffY2 = offsetSlope * diffX2; |
| 1627 | |
| 1628 | // The vector from the edge of the box to the vertical extension of the vertical edge. |
| 1629 | await gesture.moveBy(Offset(diffX2, diffY2)); |
| 1630 | await gesture.moveBy(Offset(offsetX - signedSlopX, offsetY - diffY2 - signedSlopY)); |
| 1631 | } |
| 1632 | } |
| 1633 | } else { |
| 1634 | // The drag goes through the corner of the box. |
| 1635 | await gesture.moveBy(Offset(signedSlopX, signedSlopY)); |
| 1636 | await gesture.moveBy(Offset(offsetX - signedSlopX, offsetY - signedSlopY)); |
| 1637 | } |
| 1638 | } else { |
| 1639 | // The drag ends inside the box. |
| 1640 | await gesture.moveBy(offset); |
| 1641 | } |
| 1642 | await gesture.up(); |
| 1643 | }); |
| 1644 | } |
| 1645 | |
| 1646 | /// Attempts to drag the given widget by the given offset in the `duration` |
| 1647 | /// time, starting in the middle of the widget. |
| 1648 | /// |
| 1649 | /// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed} |
| 1650 | /// |
| 1651 | /// {@macro flutter.flutter_test.WidgetController.fling.offset} |
| 1652 | /// |
| 1653 | /// This is the timed version of [drag]. This may or may not result in a |
| 1654 | /// [fling] or ballistic animation, depending on the speed from |
| 1655 | /// `offset/duration`. |
| 1656 | /// |
| 1657 | /// {@template flutter.flutter_test.WidgetController.timedDrag} |
| 1658 | /// The move events are sent at a given `frequency` in Hz (or events per |
| 1659 | /// second). It defaults to 60Hz. |
| 1660 | /// |
| 1661 | /// The movement is linear in time. |
| 1662 | /// |
| 1663 | /// See also [LiveTestWidgetsFlutterBindingFramePolicy.benchmarkLive] for |
| 1664 | /// more accurate time control. |
| 1665 | /// {@endtemplate} |
| 1666 | Future<void> timedDrag( |
| 1667 | finders.FinderBase<Element> finder, |
| 1668 | Offset offset, |
| 1669 | Duration duration, { |
| 1670 | int? pointer, |
| 1671 | int buttons = kPrimaryButton, |
| 1672 | double frequency = 60.0, |
| 1673 | bool warnIfMissed = true, |
| 1674 | }) { |
| 1675 | return timedDragFrom( |
| 1676 | getCenter(finder, warnIfMissed: warnIfMissed, callee: 'timedDrag' ), |
| 1677 | offset, |
| 1678 | duration, |
| 1679 | pointer: pointer, |
| 1680 | buttons: buttons, |
| 1681 | frequency: frequency, |
| 1682 | ); |
| 1683 | } |
| 1684 | |
| 1685 | /// Attempts a series of [PointerEvent]s to simulate a drag operation in the |
| 1686 | /// `duration` time. |
| 1687 | /// |
| 1688 | /// This is the timed version of [dragFrom]. This may or may not result in a |
| 1689 | /// [flingFrom] or ballistic animation, depending on the speed from |
| 1690 | /// `offset/duration`. |
| 1691 | /// |
| 1692 | /// {@macro flutter.flutter_test.WidgetController.timedDrag} |
| 1693 | Future<void> timedDragFrom( |
| 1694 | Offset startLocation, |
| 1695 | Offset offset, |
| 1696 | Duration duration, { |
| 1697 | int? pointer, |
| 1698 | int buttons = kPrimaryButton, |
| 1699 | double frequency = 60.0, |
| 1700 | }) { |
| 1701 | assert(frequency > 0); |
| 1702 | final int intervals = duration.inMicroseconds * frequency ~/ 1E6; |
| 1703 | assert(intervals > 1); |
| 1704 | pointer ??= _getNextPointer(); |
| 1705 | final List<Duration> timeStamps = <Duration>[ |
| 1706 | for (int t = 0; t <= intervals; t += 1) duration * t ~/ intervals, |
| 1707 | ]; |
| 1708 | final List<Offset> offsets = <Offset>[ |
| 1709 | startLocation, |
| 1710 | for (int t = 0; t <= intervals; t += 1) startLocation + offset * (t / intervals), |
| 1711 | ]; |
| 1712 | final List<PointerEventRecord> records = <PointerEventRecord>[ |
| 1713 | PointerEventRecord(Duration.zero, <PointerEvent>[ |
| 1714 | PointerAddedEvent(position: startLocation), |
| 1715 | PointerDownEvent(position: startLocation, pointer: pointer, buttons: buttons), |
| 1716 | ]), |
| 1717 | ...<PointerEventRecord>[ |
| 1718 | for (int t = 0; t <= intervals; t += 1) |
| 1719 | PointerEventRecord(timeStamps[t], <PointerEvent>[ |
| 1720 | PointerMoveEvent( |
| 1721 | timeStamp: timeStamps[t], |
| 1722 | position: offsets[t + 1], |
| 1723 | delta: offsets[t + 1] - offsets[t], |
| 1724 | pointer: pointer, |
| 1725 | buttons: buttons, |
| 1726 | ), |
| 1727 | ]), |
| 1728 | ], |
| 1729 | PointerEventRecord(duration, <PointerEvent>[ |
| 1730 | PointerUpEvent( |
| 1731 | timeStamp: duration, |
| 1732 | position: offsets.last, |
| 1733 | pointer: pointer, |
| 1734 | // The PointerData received from the engine with |
| 1735 | // change = PointerChange.up, which translates to PointerUpEvent, |
| 1736 | // doesn't provide the button field. |
| 1737 | // buttons: buttons, |
| 1738 | ), |
| 1739 | ]), |
| 1740 | ]; |
| 1741 | return TestAsyncUtils.guard<void>(() async { |
| 1742 | await handlePointerEventRecord(records); |
| 1743 | }); |
| 1744 | } |
| 1745 | |
| 1746 | /// The next available pointer identifier. |
| 1747 | /// |
| 1748 | /// This is the default pointer identifier that will be used the next time the |
| 1749 | /// [startGesture] method is called without an explicit pointer identifier. |
| 1750 | int get nextPointer => _nextPointer; |
| 1751 | |
| 1752 | static int _nextPointer = 1; |
| 1753 | |
| 1754 | static int _getNextPointer() { |
| 1755 | final int result = _nextPointer; |
| 1756 | _nextPointer += 1; |
| 1757 | return result; |
| 1758 | } |
| 1759 | |
| 1760 | TestGesture _createGesture({ |
| 1761 | int? pointer, |
| 1762 | required PointerDeviceKind kind, |
| 1763 | required int buttons, |
| 1764 | }) { |
| 1765 | return TestGesture( |
| 1766 | dispatcher: sendEventToBinding, |
| 1767 | kind: kind, |
| 1768 | pointer: pointer ?? _getNextPointer(), |
| 1769 | buttons: buttons, |
| 1770 | ); |
| 1771 | } |
| 1772 | |
| 1773 | /// Creates gesture and returns the [TestGesture] object which you can use |
| 1774 | /// to continue the gesture using calls on the [TestGesture] object. |
| 1775 | /// |
| 1776 | /// You can use [startGesture] instead if your gesture begins with a down |
| 1777 | /// event. |
| 1778 | Future<TestGesture> createGesture({ |
| 1779 | int? pointer, |
| 1780 | PointerDeviceKind kind = PointerDeviceKind.touch, |
| 1781 | int buttons = kPrimaryButton, |
| 1782 | }) async { |
| 1783 | return _createGesture(pointer: pointer, kind: kind, buttons: buttons); |
| 1784 | } |
| 1785 | |
| 1786 | /// Creates a gesture with an initial appropriate starting gesture at a |
| 1787 | /// particular point, and returns the [TestGesture] object which you can use |
| 1788 | /// to continue the gesture. Usually, the starting gesture will be a down event, |
| 1789 | /// but if [kind] is set to [PointerDeviceKind.trackpad], the gesture will start |
| 1790 | /// with a panZoomStart gesture. |
| 1791 | /// |
| 1792 | /// You can use [createGesture] if your gesture doesn't begin with an initial |
| 1793 | /// down or panZoomStart gesture. |
| 1794 | /// |
| 1795 | /// See also: |
| 1796 | /// * [WidgetController.drag], a method to simulate a drag. |
| 1797 | /// * [WidgetController.timedDrag], a method to simulate the drag of a given |
| 1798 | /// widget in a given duration. It sends move events at a given frequency and |
| 1799 | /// it is useful when there are listeners involved. |
| 1800 | /// * [WidgetController.fling], a method to simulate a fling. |
| 1801 | Future<TestGesture> startGesture( |
| 1802 | Offset downLocation, { |
| 1803 | int? pointer, |
| 1804 | PointerDeviceKind kind = PointerDeviceKind.touch, |
| 1805 | int buttons = kPrimaryButton, |
| 1806 | }) async { |
| 1807 | final TestGesture result = _createGesture(pointer: pointer, kind: kind, buttons: buttons); |
| 1808 | if (kind == PointerDeviceKind.trackpad) { |
| 1809 | await result.panZoomStart(downLocation); |
| 1810 | } else { |
| 1811 | await result.down(downLocation); |
| 1812 | } |
| 1813 | return result; |
| 1814 | } |
| 1815 | |
| 1816 | /// Forwards the given location to the binding's hitTest logic. |
| 1817 | HitTestResult hitTestOnBinding(Offset location, {int? viewId}) { |
| 1818 | viewId ??= view.viewId; |
| 1819 | final HitTestResult result = HitTestResult(); |
| 1820 | binding.hitTestInView(result, location, viewId); |
| 1821 | return result; |
| 1822 | } |
| 1823 | |
| 1824 | /// Forwards the given pointer event to the binding. |
| 1825 | Future<void> sendEventToBinding(PointerEvent event) { |
| 1826 | return TestAsyncUtils.guard<void>(() async { |
| 1827 | binding.handlePointerEvent(event); |
| 1828 | }); |
| 1829 | } |
| 1830 | |
| 1831 | /// Calls [debugPrint] with the given message. |
| 1832 | /// |
| 1833 | /// This is overridden by the WidgetTester subclass to use the test binding's |
| 1834 | /// [TestWidgetsFlutterBinding.debugPrintOverride], so that it appears on the |
| 1835 | /// console even if the test is logging output from the application. |
| 1836 | @protected |
| 1837 | void printToConsole(String message) { |
| 1838 | debugPrint(message); |
| 1839 | } |
| 1840 | |
| 1841 | // GEOMETRY |
| 1842 | |
| 1843 | /// Returns the point at the center of the given widget. |
| 1844 | /// |
| 1845 | /// {@template flutter.flutter_test.WidgetController.getCenter.warnIfMissed} |
| 1846 | /// If `warnIfMissed` is true (the default is false), then the returned |
| 1847 | /// coordinate is checked to see if a hit test at the returned location would |
| 1848 | /// actually include the specified element in the [HitTestResult], and if not, |
| 1849 | /// a warning is printed to the console. |
| 1850 | /// |
| 1851 | /// The `callee` argument is used to identify the method that should be |
| 1852 | /// referenced in messages regarding `warnIfMissed`. It can be ignored unless |
| 1853 | /// this method is being called from another that is forwarding its own |
| 1854 | /// `warnIfMissed` parameter (see e.g. the implementation of [tap]). |
| 1855 | /// {@endtemplate} |
| 1856 | Offset getCenter( |
| 1857 | finders.FinderBase<Element> finder, { |
| 1858 | bool warnIfMissed = false, |
| 1859 | String callee = 'getCenter' , |
| 1860 | }) { |
| 1861 | return _getElementPoint( |
| 1862 | finder, |
| 1863 | (Size size) => size.center(Offset.zero), |
| 1864 | warnIfMissed: warnIfMissed, |
| 1865 | callee: callee, |
| 1866 | ); |
| 1867 | } |
| 1868 | |
| 1869 | /// Returns the point at the top left of the given widget. |
| 1870 | /// |
| 1871 | /// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed} |
| 1872 | Offset getTopLeft( |
| 1873 | finders.FinderBase<Element> finder, { |
| 1874 | bool warnIfMissed = false, |
| 1875 | String callee = 'getTopLeft' , |
| 1876 | }) { |
| 1877 | return _getElementPoint( |
| 1878 | finder, |
| 1879 | (Size size) => Offset.zero, |
| 1880 | warnIfMissed: warnIfMissed, |
| 1881 | callee: callee, |
| 1882 | ); |
| 1883 | } |
| 1884 | |
| 1885 | /// Returns the point at the top right of the given widget. This |
| 1886 | /// point is not inside the object's hit test area. |
| 1887 | /// |
| 1888 | /// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed} |
| 1889 | Offset getTopRight( |
| 1890 | finders.FinderBase<Element> finder, { |
| 1891 | bool warnIfMissed = false, |
| 1892 | String callee = 'getTopRight' , |
| 1893 | }) { |
| 1894 | return _getElementPoint( |
| 1895 | finder, |
| 1896 | (Size size) => size.topRight(Offset.zero), |
| 1897 | warnIfMissed: warnIfMissed, |
| 1898 | callee: callee, |
| 1899 | ); |
| 1900 | } |
| 1901 | |
| 1902 | /// Returns the point at the bottom left of the given widget. This |
| 1903 | /// point is not inside the object's hit test area. |
| 1904 | /// |
| 1905 | /// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed} |
| 1906 | Offset getBottomLeft( |
| 1907 | finders.FinderBase<Element> finder, { |
| 1908 | bool warnIfMissed = false, |
| 1909 | String callee = 'getBottomLeft' , |
| 1910 | }) { |
| 1911 | return _getElementPoint( |
| 1912 | finder, |
| 1913 | (Size size) => size.bottomLeft(Offset.zero), |
| 1914 | warnIfMissed: warnIfMissed, |
| 1915 | callee: callee, |
| 1916 | ); |
| 1917 | } |
| 1918 | |
| 1919 | /// Returns the point at the bottom right of the given widget. This |
| 1920 | /// point is not inside the object's hit test area. |
| 1921 | /// |
| 1922 | /// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed} |
| 1923 | Offset getBottomRight( |
| 1924 | finders.FinderBase<Element> finder, { |
| 1925 | bool warnIfMissed = false, |
| 1926 | String callee = 'getBottomRight' , |
| 1927 | }) { |
| 1928 | return _getElementPoint( |
| 1929 | finder, |
| 1930 | (Size size) => size.bottomRight(Offset.zero), |
| 1931 | warnIfMissed: warnIfMissed, |
| 1932 | callee: callee, |
| 1933 | ); |
| 1934 | } |
| 1935 | |
| 1936 | /// Whether warnings relating to hit tests not hitting their mark should be |
| 1937 | /// fatal (cause the test to fail). |
| 1938 | /// |
| 1939 | /// Some methods, e.g. [tap], have an argument `warnIfMissed` which causes a |
| 1940 | /// warning to be displayed if the specified [Finder] indicates a widget and |
| 1941 | /// location that, were a pointer event to be sent to that location, would not |
| 1942 | /// actually send any events to the widget (e.g. because the widget is |
| 1943 | /// obscured, or the location is off-screen, or the widget is transparent to |
| 1944 | /// pointer events). |
| 1945 | /// |
| 1946 | /// This warning was added in 2021. In ordinary operation this warning is |
| 1947 | /// non-fatal since making it fatal would be a significantly breaking change |
| 1948 | /// for anyone who already has tests relying on the ability to target events |
| 1949 | /// using finders where the events wouldn't reach the widgets specified by the |
| 1950 | /// finders in question. |
| 1951 | /// |
| 1952 | /// However, doing this is usually unintentional. To make the warning fatal, |
| 1953 | /// thus failing any tests where it occurs, this property can be set to true. |
| 1954 | /// |
| 1955 | /// Typically this is done using a `flutter_test_config.dart` file, as described |
| 1956 | /// in the documentation for the [flutter_test] library. |
| 1957 | static bool hitTestWarningShouldBeFatal = false; |
| 1958 | |
| 1959 | /// Finds one hit-testable Offset in the given `textRangeContext`'s render |
| 1960 | /// object. |
| 1961 | Offset? _findHitTestableOffsetIn(finders.TextRangeContext textRangeContext) { |
| 1962 | TestAsyncUtils.guardSync(); |
| 1963 | final TextRange range = textRangeContext.textRange; |
| 1964 | assert(range.isNormalized); |
| 1965 | assert(range.isValid); |
| 1966 | final Offset renderParagraphPaintOffset = textRangeContext.renderObject.localToGlobal( |
| 1967 | Offset.zero, |
| 1968 | ); |
| 1969 | assert(renderParagraphPaintOffset.isFinite); |
| 1970 | |
| 1971 | int spanStart = range.start; |
| 1972 | while (spanStart < range.end) { |
| 1973 | switch (_findEndOfSpan(textRangeContext.renderObject.text, spanStart, range.end)) { |
| 1974 | case (final HitTestTarget target, final int endIndex): |
| 1975 | // Uses BoxHeightStyle.tight in getBoxesForSelection to make sure the |
| 1976 | // returned boxes don't extend outside of the hit-testable region. |
| 1977 | final Iterable<Offset> testOffsets = textRangeContext.renderObject |
| 1978 | .getBoxesForSelection(TextSelection(baseOffset: spanStart, extentOffset: endIndex)) |
| 1979 | // Try hit-testing the center of each TextBox. |
| 1980 | .map((TextBox textBox) => textBox.toRect().center); |
| 1981 | |
| 1982 | for (final Offset localOffset in testOffsets) { |
| 1983 | final HitTestResult result = HitTestResult(); |
| 1984 | final Offset globalOffset = localOffset + renderParagraphPaintOffset; |
| 1985 | binding.hitTestInView(result, globalOffset, textRangeContext.view.view.viewId); |
| 1986 | if (result.path.any((HitTestEntry entry) => entry.target == target)) { |
| 1987 | return globalOffset; |
| 1988 | } |
| 1989 | } |
| 1990 | spanStart = endIndex; |
| 1991 | case (_, final int endIndex): |
| 1992 | spanStart = endIndex; |
| 1993 | case null: |
| 1994 | break; |
| 1995 | } |
| 1996 | } |
| 1997 | return null; |
| 1998 | } |
| 1999 | |
| 2000 | Offset _getElementPoint( |
| 2001 | finders.FinderBase<Element> finder, |
| 2002 | Offset Function(Size size) sizeToPoint, { |
| 2003 | required bool warnIfMissed, |
| 2004 | required String callee, |
| 2005 | }) { |
| 2006 | TestAsyncUtils.guardSync(); |
| 2007 | final Iterable<Element> elements = finder.evaluate(); |
| 2008 | if (elements.isEmpty) { |
| 2009 | throw FlutterError( |
| 2010 | 'The finder " $finder" (used in a call to " $callee()") could not find any matching widgets.' , |
| 2011 | ); |
| 2012 | } |
| 2013 | if (elements.length > 1) { |
| 2014 | throw FlutterError( |
| 2015 | 'The finder " $finder" (used in a call to " $callee()") ambiguously found multiple matching widgets. The " $callee()" method needs a single target.' , |
| 2016 | ); |
| 2017 | } |
| 2018 | final Element element = elements.single; |
| 2019 | final RenderObject? renderObject = element.renderObject; |
| 2020 | if (renderObject == null) { |
| 2021 | throw FlutterError( |
| 2022 | 'The finder " $finder" (used in a call to " $callee()") found an element, but it does not have a corresponding render object. ' |
| 2023 | 'Maybe the element has not yet been rendered?' , |
| 2024 | ); |
| 2025 | } |
| 2026 | if (renderObject is! RenderBox) { |
| 2027 | throw FlutterError( |
| 2028 | '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"). ' |
| 2029 | 'Unfortunately " $callee()" only supports targeting widgets that correspond to RenderBox objects in the rendering.' , |
| 2030 | ); |
| 2031 | } |
| 2032 | final RenderBox box = element.renderObject! as RenderBox; |
| 2033 | final Offset location = box.localToGlobal(sizeToPoint(box.size)); |
| 2034 | if (warnIfMissed) { |
| 2035 | final FlutterView view = _viewOf(finder); |
| 2036 | final HitTestResult result = HitTestResult(); |
| 2037 | binding.hitTestInView(result, location, view.viewId); |
| 2038 | final bool found = result.path.any((HitTestEntry entry) => entry.target == box); |
| 2039 | if (!found) { |
| 2040 | final RenderView renderView = binding.renderViews.firstWhere( |
| 2041 | (RenderView r) => r.flutterView == view, |
| 2042 | ); |
| 2043 | final bool outOfBounds = !(Offset.zero & renderView.size).contains(location); |
| 2044 | if (hitTestWarningShouldBeFatal) { |
| 2045 | throw FlutterError.fromParts(<DiagnosticsNode>[ |
| 2046 | ErrorSummary('Finder specifies a widget that would not receive pointer events.' ), |
| 2047 | ErrorDescription( |
| 2048 | 'A call to $callee() with finder " $finder" derived an Offset ( $location) that would not hit test on the specified widget.' , |
| 2049 | ), |
| 2050 | ErrorHint( |
| 2051 | 'Maybe the widget is actually off-screen, or another widget is obscuring it, or the widget cannot receive pointer events.' , |
| 2052 | ), |
| 2053 | if (outOfBounds) |
| 2054 | ErrorHint( |
| 2055 | 'Indeed, $location is outside the bounds of the root of the render tree, ${renderView.size}.' , |
| 2056 | ), |
| 2057 | box.toDiagnosticsNode( |
| 2058 | name: 'The finder corresponds to this RenderBox' , |
| 2059 | style: DiagnosticsTreeStyle.singleLine, |
| 2060 | ), |
| 2061 | ErrorDescription('The hit test result at that offset is: $result' ), |
| 2062 | ErrorDescription( |
| 2063 | 'If you expected this target not to be able to receive pointer events, pass "warnIfMissed: false" to " $callee()".' , |
| 2064 | ), |
| 2065 | ErrorDescription( |
| 2066 | 'To make this error into a non-fatal warning, set WidgetController.hitTestWarningShouldBeFatal to false.' , |
| 2067 | ), |
| 2068 | ]); |
| 2069 | } |
| 2070 | printToConsole( |
| 2071 | '\n' |
| 2072 | 'Warning: A call to $callee() with finder " $finder" derived an Offset ( $location) that would not hit test on the specified widget.\n' |
| 2073 | 'Maybe the widget is actually off-screen, or another widget is obscuring it, or the widget cannot receive pointer events.\n' |
| 2074 | ' ${outOfBounds ? "Indeed, $location is outside the bounds of the root of the render tree, ${renderView.size}.\n" : "" }' |
| 2075 | 'The finder corresponds to this RenderBox: $box\n' |
| 2076 | 'The hit test result at that offset is: $result\n' |
| 2077 | ' ${StackTrace.current}' |
| 2078 | 'To silence this warning, pass "warnIfMissed: false" to " $callee()".\n' |
| 2079 | 'To make this warning fatal, set WidgetController.hitTestWarningShouldBeFatal to true.\n' , |
| 2080 | ); |
| 2081 | } |
| 2082 | } |
| 2083 | return location; |
| 2084 | } |
| 2085 | |
| 2086 | /// Returns the size of the given widget. This is only valid once |
| 2087 | /// the widget's render object has been laid out at least once. |
| 2088 | Size getSize(finders.FinderBase<Element> finder) { |
| 2089 | TestAsyncUtils.guardSync(); |
| 2090 | final Element element = finder.evaluate().single; |
| 2091 | final RenderBox box = element.renderObject! as RenderBox; |
| 2092 | return box.size; |
| 2093 | } |
| 2094 | |
| 2095 | /// Simulates sending physical key down and up events. |
| 2096 | /// |
| 2097 | /// This only simulates key events coming from a physical keyboard, not from a |
| 2098 | /// soft keyboard. |
| 2099 | /// |
| 2100 | /// Specify `platform` as one of the platforms allowed in |
| 2101 | /// [Platform.operatingSystem] to make the event appear to be from |
| 2102 | /// that type of system. If not specified, defaults to "web" on web, and the |
| 2103 | /// operating system name based on [defaultTargetPlatform] everywhere else. |
| 2104 | /// |
| 2105 | /// Specify the `physicalKey` for the event to override what is included in |
| 2106 | /// the simulated event. If not specified, it uses a default from the US |
| 2107 | /// keyboard layout for the corresponding logical `key`. |
| 2108 | /// |
| 2109 | /// Specify the `character` for the event to override what is included in the |
| 2110 | /// simulated event. If not specified, it uses a default derived from the |
| 2111 | /// logical `key`. |
| 2112 | /// |
| 2113 | /// Keys that are down when the test completes are cleared after each test. |
| 2114 | /// |
| 2115 | /// This method sends both the key down and the key up events, to simulate a |
| 2116 | /// key press. To simulate individual down and/or up events, see |
| 2117 | /// [sendKeyDownEvent] and [sendKeyUpEvent]. |
| 2118 | /// |
| 2119 | /// Returns true if the key down event was handled by the framework. |
| 2120 | /// |
| 2121 | /// See also: |
| 2122 | /// |
| 2123 | /// - [sendKeyDownEvent] to simulate only a key down event. |
| 2124 | /// - [sendKeyUpEvent] to simulate only a key up event. |
| 2125 | Future<bool> sendKeyEvent( |
| 2126 | LogicalKeyboardKey key, { |
| 2127 | String? platform, |
| 2128 | String? character, |
| 2129 | PhysicalKeyboardKey? physicalKey, |
| 2130 | }) async { |
| 2131 | final bool handled = await simulateKeyDownEvent( |
| 2132 | key, |
| 2133 | platform: platform, |
| 2134 | character: character, |
| 2135 | physicalKey: physicalKey, |
| 2136 | ); |
| 2137 | // Internally wrapped in async guard. |
| 2138 | await simulateKeyUpEvent(key, platform: platform, physicalKey: physicalKey); |
| 2139 | return handled; |
| 2140 | } |
| 2141 | |
| 2142 | /// Simulates sending a physical key down event. |
| 2143 | /// |
| 2144 | /// This only simulates key down events coming from a physical keyboard, not |
| 2145 | /// from a soft keyboard. |
| 2146 | /// |
| 2147 | /// Specify `platform` as one of the platforms allowed in |
| 2148 | /// [Platform.operatingSystem] to make the event appear to be from |
| 2149 | /// that type of system. If not specified, defaults to "web" on web, and the |
| 2150 | /// operating system name based on [defaultTargetPlatform] everywhere else. |
| 2151 | /// |
| 2152 | /// Specify the `physicalKey` for the event to override what is included in |
| 2153 | /// the simulated event. If not specified, it uses a default from the US |
| 2154 | /// keyboard layout for the corresponding logical `key`. |
| 2155 | /// |
| 2156 | /// Specify the `character` for the event to override what is included in the |
| 2157 | /// simulated event. If not specified, it uses a default derived from the |
| 2158 | /// logical `key`. |
| 2159 | /// |
| 2160 | /// Keys that are down when the test completes are cleared after each test. |
| 2161 | /// |
| 2162 | /// Returns true if the key event was handled by the framework. |
| 2163 | /// |
| 2164 | /// See also: |
| 2165 | /// |
| 2166 | /// - [sendKeyUpEvent] and [sendKeyRepeatEvent] to simulate the corresponding |
| 2167 | /// key up and repeat event. |
| 2168 | /// - [sendKeyEvent] to simulate both the key up and key down in the same call. |
| 2169 | Future<bool> sendKeyDownEvent( |
| 2170 | LogicalKeyboardKey key, { |
| 2171 | String? platform, |
| 2172 | String? character, |
| 2173 | PhysicalKeyboardKey? physicalKey, |
| 2174 | }) async { |
| 2175 | // Internally wrapped in async guard. |
| 2176 | return simulateKeyDownEvent( |
| 2177 | key, |
| 2178 | platform: platform, |
| 2179 | character: character, |
| 2180 | physicalKey: physicalKey, |
| 2181 | ); |
| 2182 | } |
| 2183 | |
| 2184 | /// Simulates sending a physical key up event through the system channel. |
| 2185 | /// |
| 2186 | /// This only simulates key up events coming from a physical keyboard, |
| 2187 | /// not from a soft keyboard. |
| 2188 | /// |
| 2189 | /// Specify `platform` as one of the platforms allowed in |
| 2190 | /// [Platform.operatingSystem] to make the event appear to be from |
| 2191 | /// that type of system. If not specified, defaults to "web" on web, and the |
| 2192 | /// operating system name based on [defaultTargetPlatform] everywhere else. |
| 2193 | /// |
| 2194 | /// Specify the `physicalKey` for the event to override what is included in |
| 2195 | /// the simulated event. If not specified, it uses a default from the US |
| 2196 | /// keyboard layout for the corresponding logical `key`. |
| 2197 | /// |
| 2198 | /// Returns true if the key event was handled by the framework. |
| 2199 | /// |
| 2200 | /// See also: |
| 2201 | /// |
| 2202 | /// - [sendKeyDownEvent] and [sendKeyRepeatEvent] to simulate the |
| 2203 | /// corresponding key down and repeat event. |
| 2204 | /// - [sendKeyEvent] to simulate both the key up and key down in the same call. |
| 2205 | Future<bool> sendKeyUpEvent( |
| 2206 | LogicalKeyboardKey key, { |
| 2207 | String? platform, |
| 2208 | PhysicalKeyboardKey? physicalKey, |
| 2209 | }) async { |
| 2210 | // Internally wrapped in async guard. |
| 2211 | return simulateKeyUpEvent(key, platform: platform, physicalKey: physicalKey); |
| 2212 | } |
| 2213 | |
| 2214 | /// Simulates sending a key repeat event from a physical keyboard. |
| 2215 | /// |
| 2216 | /// This only simulates key repeat events coming from a physical keyboard, not |
| 2217 | /// from a soft keyboard. |
| 2218 | /// |
| 2219 | /// Specify `platform` as one of the platforms allowed in |
| 2220 | /// [Platform.operatingSystem] to make the event appear to be from |
| 2221 | /// that type of system. If not specified, defaults to "web" on web, and the |
| 2222 | /// operating system name based on [defaultTargetPlatform] everywhere else. |
| 2223 | /// |
| 2224 | /// Specify the `physicalKey` for the event to override what is included in |
| 2225 | /// the simulated event. If not specified, it uses a default from the US |
| 2226 | /// keyboard layout for the corresponding logical `key`. |
| 2227 | /// |
| 2228 | /// Specify the `character` for the event to override what is included in the |
| 2229 | /// simulated event. If not specified, it uses a default derived from the |
| 2230 | /// logical `key`. |
| 2231 | /// |
| 2232 | /// Keys that are down when the test completes are cleared after each test. |
| 2233 | /// |
| 2234 | /// Returns true if the key event was handled by the framework. |
| 2235 | /// |
| 2236 | /// See also: |
| 2237 | /// |
| 2238 | /// - [sendKeyDownEvent] and [sendKeyUpEvent] to simulate the corresponding |
| 2239 | /// key down and up event. |
| 2240 | /// - [sendKeyEvent] to simulate both the key up and key down in the same call. |
| 2241 | Future<bool> sendKeyRepeatEvent( |
| 2242 | LogicalKeyboardKey key, { |
| 2243 | String? platform, |
| 2244 | String? character, |
| 2245 | PhysicalKeyboardKey? physicalKey, |
| 2246 | }) async { |
| 2247 | // Internally wrapped in async guard. |
| 2248 | return simulateKeyRepeatEvent( |
| 2249 | key, |
| 2250 | platform: platform, |
| 2251 | character: character, |
| 2252 | physicalKey: physicalKey, |
| 2253 | ); |
| 2254 | } |
| 2255 | |
| 2256 | /// Returns the rect of the given widget. This is only valid once |
| 2257 | /// the widget's render object has been laid out at least once. |
| 2258 | Rect getRect(finders.FinderBase<Element> finder) => |
| 2259 | Rect.fromPoints(getTopLeft(finder), getBottomRight(finder)); |
| 2260 | |
| 2261 | /// Attempts to find the [SemanticsNode] of first result from `finder`. |
| 2262 | /// |
| 2263 | /// If the object identified by the finder doesn't own it's semantic node, |
| 2264 | /// this will return the semantics data of the first ancestor with semantics. |
| 2265 | /// The ancestor's semantic data will include the child's as well as |
| 2266 | /// other nodes that have been merged together. |
| 2267 | /// |
| 2268 | /// If the [SemanticsNode] of the object identified by the finder is |
| 2269 | /// force-merged into an ancestor (e.g. via the [MergeSemantics] widget) |
| 2270 | /// the node into which it is merged is returned. That node will include |
| 2271 | /// all the semantics information of the nodes merged into it. |
| 2272 | /// |
| 2273 | /// Will throw a [StateError] if the finder returns more than one element or |
| 2274 | /// if no semantics are found or are not enabled. |
| 2275 | // TODO(pdblasi-google): Deprecate this and point references to semantics.find. See https://github.com/flutter/flutter/issues/112670. |
| 2276 | SemanticsNode getSemantics(finders.FinderBase<Element> finder) => semantics.find(finder); |
| 2277 | |
| 2278 | /// Enable semantics in a test by creating a [SemanticsHandle]. |
| 2279 | /// |
| 2280 | /// The handle must be disposed at the end of the test. |
| 2281 | SemanticsHandle ensureSemantics() { |
| 2282 | return binding.ensureSemantics(); |
| 2283 | } |
| 2284 | |
| 2285 | /// Given a widget `W` specified by [finder] and a [Scrollable] widget `S` in |
| 2286 | /// its ancestry tree, this scrolls `S` so as to make `W` visible. |
| 2287 | /// |
| 2288 | /// Usually the `finder` for this method should be labeled `skipOffstage: |
| 2289 | /// false`, so that the [Finder] deals with widgets that are off the screen |
| 2290 | /// correctly. |
| 2291 | /// |
| 2292 | /// This does not work when `S` is long enough, and `W` far away enough from |
| 2293 | /// the displayed part of `S`, that `S` has not yet cached `W`'s element. |
| 2294 | /// Consider using [scrollUntilVisible] in such a situation. |
| 2295 | /// |
| 2296 | /// See also: |
| 2297 | /// |
| 2298 | /// * [Scrollable.ensureVisible], which is the production API used to |
| 2299 | /// implement this method. |
| 2300 | Future<void> ensureVisible(finders.FinderBase<Element> finder) => |
| 2301 | Scrollable.ensureVisible(element(finder)); |
| 2302 | |
| 2303 | /// Repeatedly scrolls a [Scrollable] by `delta` in the |
| 2304 | /// [Scrollable.axisDirection] direction until a widget matching `finder` is |
| 2305 | /// visible. |
| 2306 | /// |
| 2307 | /// Between each scroll, advances the clock by `duration` time. |
| 2308 | /// |
| 2309 | /// Scrolling is performed until the start of the `finder` is visible. This is |
| 2310 | /// due to the default parameter values of the [Scrollable.ensureVisible] method. |
| 2311 | /// |
| 2312 | /// If `scrollable` is `null`, a [Finder] that looks for a [Scrollable] is |
| 2313 | /// used instead. |
| 2314 | /// |
| 2315 | /// If `continuous` is `true`, the gesture will be reused to simulate the effect |
| 2316 | /// of actual finger scrolling, which is useful when used alongside listeners |
| 2317 | /// like [GestureDetector.onTap]. The default is `false`. |
| 2318 | /// |
| 2319 | /// Throws a [StateError] if `finder` is not found after `maxScrolls` scrolls. |
| 2320 | /// |
| 2321 | /// This is different from [ensureVisible] in that this allows looking for |
| 2322 | /// `finder` that is not yet built. The caller must specify the scrollable |
| 2323 | /// that will build child specified by `finder` when there are multiple |
| 2324 | /// [Scrollable]s. |
| 2325 | /// |
| 2326 | /// See also: |
| 2327 | /// |
| 2328 | /// * [dragUntilVisible], which implements the body of this method. |
| 2329 | Future<void> scrollUntilVisible( |
| 2330 | finders.FinderBase<Element> finder, |
| 2331 | double delta, { |
| 2332 | finders.FinderBase<Element>? scrollable, |
| 2333 | int maxScrolls = 50, |
| 2334 | Duration duration = const Duration(milliseconds: 50), |
| 2335 | bool continuous = false, |
| 2336 | }) { |
| 2337 | assert(maxScrolls > 0); |
| 2338 | scrollable ??= finders.find.byType(Scrollable); |
| 2339 | return TestAsyncUtils.guard<void>(() async { |
| 2340 | Offset moveStep; |
| 2341 | switch (widget<Scrollable>(scrollable!).axisDirection) { |
| 2342 | case AxisDirection.up: |
| 2343 | moveStep = Offset(0, delta); |
| 2344 | case AxisDirection.down: |
| 2345 | moveStep = Offset(0, -delta); |
| 2346 | case AxisDirection.left: |
| 2347 | moveStep = Offset(delta, 0); |
| 2348 | case AxisDirection.right: |
| 2349 | moveStep = Offset(-delta, 0); |
| 2350 | } |
| 2351 | await dragUntilVisible( |
| 2352 | finder, |
| 2353 | scrollable, |
| 2354 | moveStep, |
| 2355 | maxIteration: maxScrolls, |
| 2356 | duration: duration, |
| 2357 | continuous: continuous, |
| 2358 | ); |
| 2359 | }); |
| 2360 | } |
| 2361 | |
| 2362 | /// Repeatedly drags `view` by `moveStep` until `finder` is visible. |
| 2363 | /// |
| 2364 | /// Between each drag, advances the clock by `duration`. |
| 2365 | /// |
| 2366 | /// Throws a [StateError] if `finder` is not found after `maxIteration` |
| 2367 | /// drags. |
| 2368 | /// |
| 2369 | /// See also: |
| 2370 | /// |
| 2371 | /// * [scrollUntilVisible], which wraps this method with an API that is more |
| 2372 | /// convenient when dealing with a [Scrollable]. |
| 2373 | Future<void> dragUntilVisible( |
| 2374 | finders.FinderBase<Element> finder, |
| 2375 | finders.FinderBase<Element> view, |
| 2376 | Offset moveStep, { |
| 2377 | int maxIteration = 50, |
| 2378 | Duration duration = const Duration(milliseconds: 50), |
| 2379 | bool continuous = false, |
| 2380 | }) { |
| 2381 | return TestAsyncUtils.guard<void>(() async { |
| 2382 | TestGesture? gesture; |
| 2383 | while (maxIteration > 0 && finder.evaluate().isEmpty) { |
| 2384 | if (continuous) { |
| 2385 | gesture ??= await startGesture(getCenter(view, warnIfMissed: true)); |
| 2386 | await gesture.moveBy(moveStep); |
| 2387 | } else { |
| 2388 | await drag(view, moveStep); |
| 2389 | } |
| 2390 | await pump(duration); |
| 2391 | maxIteration -= 1; |
| 2392 | } |
| 2393 | await gesture?.up(); |
| 2394 | await Scrollable.ensureVisible(element(finder)); |
| 2395 | }); |
| 2396 | } |
| 2397 | } |
| 2398 | |
| 2399 | /// Variant of [WidgetController] that can be used in tests running |
| 2400 | /// on a device. |
| 2401 | /// |
| 2402 | /// This is used, for instance, by [FlutterDriver]. |
| 2403 | class LiveWidgetController extends WidgetController { |
| 2404 | /// Creates a widget controller that uses the given binding. |
| 2405 | LiveWidgetController(super.binding); |
| 2406 | |
| 2407 | @override |
| 2408 | Future<void> pump([Duration? duration]) async { |
| 2409 | if (duration != null) { |
| 2410 | await Future<void>.delayed(duration); |
| 2411 | } |
| 2412 | binding.scheduleFrame(); |
| 2413 | await binding.endOfFrame; |
| 2414 | } |
| 2415 | |
| 2416 | @override |
| 2417 | Future<int> pumpAndSettle([Duration duration = const Duration(milliseconds: 100)]) { |
| 2418 | assert(duration > Duration.zero); |
| 2419 | return TestAsyncUtils.guard<int>(() async { |
| 2420 | int count = 0; |
| 2421 | do { |
| 2422 | await pump(duration); |
| 2423 | count += 1; |
| 2424 | } while (binding.hasScheduledFrame); |
| 2425 | return count; |
| 2426 | }); |
| 2427 | } |
| 2428 | |
| 2429 | @override |
| 2430 | Future<List<Duration>> handlePointerEventRecord(List<PointerEventRecord> records) { |
| 2431 | assert(records.isNotEmpty); |
| 2432 | return TestAsyncUtils.guard<List<Duration>>(() async { |
| 2433 | final List<Duration> handleTimeStampDiff = <Duration>[]; |
| 2434 | DateTime? startTime; |
| 2435 | for (final PointerEventRecord record in records) { |
| 2436 | final DateTime now = clock.now(); |
| 2437 | startTime ??= now; |
| 2438 | // So that the first event is promised to receive a zero timeDiff. |
| 2439 | final Duration timeDiff = record.timeDelay - now.difference(startTime); |
| 2440 | if (timeDiff.isNegative) { |
| 2441 | // This happens when something (e.g. GC) takes a long time during the |
| 2442 | // processing of the events. |
| 2443 | // Flush all past events. |
| 2444 | handleTimeStampDiff.add(-timeDiff); |
| 2445 | record.events.forEach(binding.handlePointerEvent); |
| 2446 | } else { |
| 2447 | await Future<void>.delayed(timeDiff); |
| 2448 | handleTimeStampDiff.add( |
| 2449 | // Recalculating the time diff for getting exact time when the event |
| 2450 | // packet is sent. For a perfect Future.delayed like the one in a |
| 2451 | // fake async this new diff should be zero. |
| 2452 | clock.now().difference(startTime) - record.timeDelay, |
| 2453 | ); |
| 2454 | record.events.forEach(binding.handlePointerEvent); |
| 2455 | } |
| 2456 | } |
| 2457 | |
| 2458 | return handleTimeStampDiff; |
| 2459 | }); |
| 2460 | } |
| 2461 | } |
| 2462 | |