blob: 65d702bd2f2f111d1a7fed5c1621132a2fca60f2 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'all_elements.dart';
import 'event_simulation.dart';
import 'finders.dart';
import 'test_async_utils.dart';
import 'test_pointer.dart';
/// The default drag touch slop used to break up a large drag into multiple
/// smaller moves.
///
/// This value must be greater than [kTouchSlop].
const double kDragSlopDefault = 20.0;
const String _defaultPlatform = kIsWeb ? 'web' : 'android';
/// Class that programmatically interacts with widgets.
///
/// For a variant of this class suited specifically for unit tests, see
/// [WidgetTester]. For one suitable for live tests on a device, consider
/// [LiveWidgetController].
///
/// Concrete subclasses must implement the [pump] method.
abstract class WidgetController {
/// Creates a widget controller that uses the given binding.
WidgetController(this.binding);
/// A reference to the current instance of the binding.
final WidgetsBinding binding;
// FINDER API
// TODO(ianh): verify that the return values are of type T and throw
// a good message otherwise, in all the generic methods below
/// Checks if `finder` exists in the tree.
bool any(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().isNotEmpty;
}
/// All widgets currently in the widget tree (lazy pre-order traversal).
///
/// Can contain duplicates, since widgets can be used in multiple
/// places in the widget tree.
Iterable<Widget> get allWidgets {
TestAsyncUtils.guardSync();
return allElements.map<Widget>((Element element) => element.widget);
}
/// The matching widget in the widget tree.
///
/// Throws a [StateError] if `finder` is empty or matches more than
/// one widget.
///
/// * Use [firstWidget] if you expect to match several widgets but only want the first.
/// * Use [widgetList] if you expect to match several widgets and want all of them.
T widget<T extends Widget>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().single.widget as T;
}
/// The first matching widget according to a depth-first pre-order
/// traversal of the widget tree.
///
/// Throws a [StateError] if `finder` is empty.
///
/// * Use [widget] if you only expect to match one widget.
T firstWidget<T extends Widget>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().first.widget as T;
}
/// The matching widgets in the widget tree.
///
/// * Use [widget] if you only expect to match one widget.
/// * Use [firstWidget] if you expect to match several but only want the first.
Iterable<T> widgetList<T extends Widget>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().map<T>((Element element) {
final T result = element.widget as T;
return result;
});
}
/// All elements currently in the widget tree (lazy pre-order traversal).
///
/// The returned iterable is lazy. It does not walk the entire widget tree
/// immediately, but rather a chunk at a time as the iteration progresses
/// using [Iterator.moveNext].
Iterable<Element> get allElements {
TestAsyncUtils.guardSync();
return collectAllElementsFrom(binding.renderViewElement!, skipOffstage: false);
}
/// The matching element in the widget tree.
///
/// Throws a [StateError] if `finder` is empty or matches more than
/// one element.
///
/// * Use [firstElement] if you expect to match several elements but only want the first.
/// * Use [elementList] if you expect to match several elements and want all of them.
T element<T extends Element>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().single as T;
}
/// The first matching element according to a depth-first pre-order
/// traversal of the widget tree.
///
/// Throws a [StateError] if `finder` is empty.
///
/// * Use [element] if you only expect to match one element.
T firstElement<T extends Element>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().first as T;
}
/// The matching elements in the widget tree.
///
/// * Use [element] if you only expect to match one element.
/// * Use [firstElement] if you expect to match several but only want the first.
Iterable<T> elementList<T extends Element>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().cast<T>();
}
/// All states currently in the widget tree (lazy pre-order traversal).
///
/// The returned iterable is lazy. It does not walk the entire widget tree
/// immediately, but rather a chunk at a time as the iteration progresses
/// using [Iterator.moveNext].
Iterable<State> get allStates {
TestAsyncUtils.guardSync();
return allElements.whereType<StatefulElement>().map<State>((StatefulElement element) => element.state);
}
/// The matching state in the widget tree.
///
/// Throws a [StateError] if `finder` is empty, matches more than
/// one state, or matches a widget that has no state.
///
/// * Use [firstState] if you expect to match several states but only want the first.
/// * Use [stateList] if you expect to match several states and want all of them.
T state<T extends State>(Finder finder) {
TestAsyncUtils.guardSync();
return _stateOf<T>(finder.evaluate().single, finder);
}
/// The first matching state according to a depth-first pre-order
/// traversal of the widget tree.
///
/// Throws a [StateError] if `finder` is empty or if the first
/// matching widget has no state.
///
/// * Use [state] if you only expect to match one state.
T firstState<T extends State>(Finder finder) {
TestAsyncUtils.guardSync();
return _stateOf<T>(finder.evaluate().first, finder);
}
/// The matching states in the widget tree.
///
/// Throws a [StateError] if any of the elements in `finder` match a widget
/// that has no state.
///
/// * Use [state] if you only expect to match one state.
/// * Use [firstState] if you expect to match several but only want the first.
Iterable<T> stateList<T extends State>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().map<T>((Element element) => _stateOf<T>(element, finder));
}
T _stateOf<T extends State>(Element element, Finder finder) {
TestAsyncUtils.guardSync();
if (element is StatefulElement)
return element.state as T;
throw StateError('Widget of type ${element.widget.runtimeType}, with ${finder.description}, is not a StatefulWidget.');
}
/// Render objects of all the widgets currently in the widget tree
/// (lazy pre-order traversal).
///
/// This will almost certainly include many duplicates since the
/// render object of a [StatelessWidget] or [StatefulWidget] is the
/// render object of its child; only [RenderObjectWidget]s have
/// their own render object.
Iterable<RenderObject> get allRenderObjects {
TestAsyncUtils.guardSync();
return allElements.map<RenderObject>((Element element) => element.renderObject!);
}
/// The render object of the matching widget in the widget tree.
///
/// Throws a [StateError] if `finder` is empty or matches more than
/// one widget (even if they all have the same render object).
///
/// * Use [firstRenderObject] if you expect to match several render objects but only want the first.
/// * Use [renderObjectList] if you expect to match several render objects and want all of them.
T renderObject<T extends RenderObject>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().single.renderObject! as T;
}
/// The render object of the first matching widget according to a
/// depth-first pre-order traversal of the widget tree.
///
/// Throws a [StateError] if `finder` is empty.
///
/// * Use [renderObject] if you only expect to match one render object.
T firstRenderObject<T extends RenderObject>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().first.renderObject! as T;
}
/// The render objects of the matching widgets in the widget tree.
///
/// * Use [renderObject] if you only expect to match one render object.
/// * Use [firstRenderObject] if you expect to match several but only want the first.
Iterable<T> renderObjectList<T extends RenderObject>(Finder finder) {
TestAsyncUtils.guardSync();
return finder.evaluate().map<T>((Element element) {
final T result = element.renderObject! as T;
return result;
});
}
/// Returns a list of all the [Layer] objects in the rendering.
List<Layer> get layers => _walkLayers(binding.renderView.debugLayer!).toList();
Iterable<Layer> _walkLayers(Layer layer) sync* {
TestAsyncUtils.guardSync();
yield layer;
if (layer is ContainerLayer) {
final ContainerLayer root = layer;
Layer? child = root.firstChild;
while (child != null) {
yield* _walkLayers(child);
child = child.nextSibling;
}
}
}
// INTERACTION
/// Dispatch a pointer down / pointer up sequence at the center of
/// the given widget, assuming it is exposed.
///
/// {@template flutter.flutter_test.WidgetController.tap.warnIfMissed}
/// The `warnIfMissed` argument, if true (the default), causes a warning to be
/// displayed on the console if the specified [Finder] indicates a widget and
/// location that, were a pointer event to be sent to that location, would not
/// actually send any events to the widget (e.g. because the widget is
/// obscured, or the location is off-screen, or the widget is transparent to
/// pointer events).
///
/// Set the argument to false to silence that warning if you intend to not
/// actually hit the specified element.
/// {@endtemplate}
///
/// For example, a test that verifies that tapping a disabled button does not
/// trigger the button would set `warnIfMissed` to false, because the button
/// would ignore the tap.
Future<void> tap(Finder finder, {int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = true}) {
return tapAt(getCenter(finder, warnIfMissed: warnIfMissed, callee: 'tap'), pointer: pointer, buttons: buttons);
}
/// Dispatch a pointer down / pointer up sequence at the given location.
Future<void> tapAt(Offset location, {int? pointer, int buttons = kPrimaryButton}) {
return TestAsyncUtils.guard<void>(() async {
final TestGesture gesture = await startGesture(location, pointer: pointer, buttons: buttons);
await gesture.up();
});
}
/// Dispatch a pointer down at the center of the given widget, assuming it is
/// exposed.
///
/// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed}
///
/// The return value is a [TestGesture] object that can be used to continue the
/// gesture (e.g. moving the pointer or releasing it).
///
/// See also:
///
/// * [tap], which presses and releases a pointer at the given location.
/// * [longPress], which presses and releases a pointer with a gap in
/// between long enough to trigger the long-press gesture.
Future<TestGesture> press(Finder finder, {int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = true}) {
return TestAsyncUtils.guard<TestGesture>(() {
return startGesture(getCenter(finder, warnIfMissed: warnIfMissed, callee: 'press'), pointer: pointer, buttons: buttons);
});
}
/// Dispatch a pointer down / pointer up sequence (with a delay of
/// [kLongPressTimeout] + [kPressTimeout] between the two events) at the
/// center of the given widget, assuming it is exposed.
///
/// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed}
///
/// For example, consider a widget that, when long-pressed, shows an overlay
/// that obscures the original widget. A test for that widget might first
/// long-press that widget with `warnIfMissed` at its default value true, then
/// later verify that long-pressing the same location (using the same finder)
/// has no effect (since the widget is now obscured), setting `warnIfMissed`
/// to false on that second call.
Future<void> longPress(Finder finder, {int? pointer, int buttons = kPrimaryButton, bool warnIfMissed = true}) {
return longPressAt(getCenter(finder, warnIfMissed: warnIfMissed, callee: 'longPress'), pointer: pointer, buttons: buttons);
}
/// Dispatch a pointer down / pointer up sequence at the given location with
/// a delay of [kLongPressTimeout] + [kPressTimeout] between the two events.
Future<void> longPressAt(Offset location, {int? pointer, int buttons = kPrimaryButton}) {
return TestAsyncUtils.guard<void>(() async {
final TestGesture gesture = await startGesture(location, pointer: pointer, buttons: buttons);
await pump(kLongPressTimeout + kPressTimeout);
await gesture.up();
});
}
/// Attempts a fling gesture starting from the center of the given
/// widget, moving the given distance, reaching the given speed.
///
/// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed}
///
/// {@template flutter.flutter_test.WidgetController.fling}
/// This can pump frames.
///
/// Exactly 50 pointer events are synthesized.
///
/// The `speed` is in pixels per second in the direction given by `offset`.
///
/// The `offset` and `speed` control the interval between each pointer event.
/// For example, if the `offset` is 200 pixels down, and the `speed` is 800
/// pixels per second, the pointer events will be sent for each increment
/// of 4 pixels (200/50), over 250ms (200/800), meaning events will be sent
/// every 1.25ms (250/200).
///
/// To make tests more realistic, frames may be pumped during this time (using
/// calls to [pump]). If the total duration is longer than `frameInterval`,
/// then one frame is pumped each time that amount of time elapses while
/// sending events, or each time an event is synthesized, whichever is rarer.
///
/// See [LiveTestWidgetsFlutterBindingFramePolicy.benchmarkLive] if the method
/// is used in a live environment and accurate time control is important.
///
/// The `initialOffset` argument, if non-zero, causes the pointer to first
/// apply that offset, then pump a delay of `initialOffsetDelay`. This can be
/// used to simulate a drag followed by a fling, including dragging in the
/// opposite direction of the fling (e.g. dragging 200 pixels to the right,
/// then fling to the left over 200 pixels, ending at the exact point that the
/// drag started).
/// {@endtemplate}
///
/// A fling is essentially a drag that ends at a particular speed. If you
/// just want to drag and end without a fling, use [drag].
Future<void> fling(
Finder finder,
Offset offset,
double speed, {
int? pointer,
int buttons = kPrimaryButton,
Duration frameInterval = const Duration(milliseconds: 16),
Offset initialOffset = Offset.zero,
Duration initialOffsetDelay = const Duration(seconds: 1),
bool warnIfMissed = true,
}) {
return flingFrom(
getCenter(finder, warnIfMissed: warnIfMissed, callee: 'fling'),
offset,
speed,
pointer: pointer,
buttons: buttons,
frameInterval: frameInterval,
initialOffset: initialOffset,
initialOffsetDelay: initialOffsetDelay,
);
}
/// Attempts a fling gesture starting from the given location, moving the
/// given distance, reaching the given speed.
///
/// {@macro flutter.flutter_test.WidgetController.fling}
///
/// A fling is essentially a drag that ends at a particular speed. If you
/// just want to drag and end without a fling, use [dragFrom].
Future<void> flingFrom(
Offset startLocation,
Offset offset,
double speed, {
int? pointer,
int buttons = kPrimaryButton,
Duration frameInterval = const Duration(milliseconds: 16),
Offset initialOffset = Offset.zero,
Duration initialOffsetDelay = const Duration(seconds: 1),
}) {
assert(offset.distance > 0.0);
assert(speed > 0.0); // speed is pixels/second
return TestAsyncUtils.guard<void>(() async {
final TestPointer testPointer = TestPointer(pointer ?? _getNextPointer(), PointerDeviceKind.touch, null, buttons);
const int kMoveCount = 50; // Needs to be >= kHistorySize, see _LeastSquaresVelocityTrackerStrategy
final double timeStampDelta = 1000000.0 * offset.distance / (kMoveCount * speed);
double timeStamp = 0.0;
double lastTimeStamp = timeStamp;
await sendEventToBinding(testPointer.down(startLocation, timeStamp: Duration(microseconds: timeStamp.round())));
if (initialOffset.distance > 0.0) {
await sendEventToBinding(testPointer.move(startLocation + initialOffset, timeStamp: Duration(microseconds: timeStamp.round())));
timeStamp += initialOffsetDelay.inMicroseconds;
await pump(initialOffsetDelay);
}
for (int i = 0; i <= kMoveCount; i += 1) {
final Offset location = startLocation + initialOffset + Offset.lerp(Offset.zero, offset, i / kMoveCount)!;
await sendEventToBinding(testPointer.move(location, timeStamp: Duration(microseconds: timeStamp.round())));
timeStamp += timeStampDelta;
if (timeStamp - lastTimeStamp > frameInterval.inMicroseconds) {
await pump(Duration(microseconds: (timeStamp - lastTimeStamp).truncate()));
lastTimeStamp = timeStamp;
}
}
await sendEventToBinding(testPointer.up(timeStamp: Duration(microseconds: timeStamp.round())));
});
}
/// A simulator of how the framework handles a series of [PointerEvent]s
/// received from the Flutter engine.
///
/// The [PointerEventRecord.timeDelay] is used as the time delay of the events
/// injection relative to the starting point of the method call.
///
/// Returns a list of the difference between the real delay time when the
/// [PointerEventRecord.events] are processed and
/// [PointerEventRecord.timeDelay].
/// - For [AutomatedTestWidgetsFlutterBinding] where the clock is fake, the
/// return value should be exact zeros.
/// - For [LiveTestWidgetsFlutterBinding], the values are typically small
/// positives, meaning the event happens a little later than the set time,
/// but a very small portion may have a tiny negative value for about tens of
/// microseconds. This is due to the nature of [Future.delayed].
///
/// The closer the return values are to zero the more faithful it is to the
/// `records`.
///
/// See [PointerEventRecord].
Future<List<Duration>> handlePointerEventRecord(List<PointerEventRecord> records);
/// Called to indicate that there should be a new frame after an optional
/// delay.
///
/// The frame is pumped after a delay of [duration] if [duration] is not null,
/// or immediately otherwise.
///
/// This is invoked by [flingFrom], for instance, so that the sequence of
/// pointer events occurs over time.
///
/// The [WidgetTester] subclass implements this by deferring to the [binding].
///
/// See also [SchedulerBinding.endOfFrame], which returns a future that could
/// be appropriate to return in the implementation of this method.
Future<void> pump([Duration duration]);
/// Repeatedly calls [pump] with the given `duration` until there are no
/// longer any frames scheduled. This will call [pump] at least once, even if
/// no frames are scheduled when the function is called, to flush any pending
/// microtasks which may themselves schedule a frame.
///
/// This essentially waits for all animations to have completed.
///
/// If it takes longer that the given `timeout` to settle, then the test will
/// fail (this method will throw an exception). In particular, this means that
/// if there is an infinite animation in progress (for example, if there is an
/// indeterminate progress indicator spinning), this method will throw.
///
/// The default timeout is ten minutes, which is longer than most reasonable
/// finite animations would last.
///
/// If the function returns, it returns the number of pumps that it performed.
///
/// In general, it is better practice to figure out exactly why each frame is
/// needed, and then to [pump] exactly as many frames as necessary. This will
/// help catch regressions where, for instance, an animation is being started
/// one frame later than it should.
///
/// Alternatively, one can check that the return value from this function
/// matches the expected number of pumps.
Future<int> pumpAndSettle([
Duration duration = const Duration(milliseconds: 100),
]);
/// Attempts to drag the given widget by the given offset, by
/// starting a drag in the middle of the widget.
///
/// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed}
///
/// If you want the drag to end with a speed so that the gesture recognition
/// system identifies the gesture as a fling, consider using [fling] instead.
///
/// The operation happens at once. If you want the drag to last for a period
/// of time, consider using [timedDrag].
///
/// {@template flutter.flutter_test.WidgetController.drag}
/// By default, if the x or y component of offset is greater than
/// [kDragSlopDefault], the gesture is broken up into two separate moves
/// calls. Changing `touchSlopX` or `touchSlopY` will change the minimum
/// amount of movement in the respective axis before the drag will be broken
/// into multiple calls. To always send the drag with just a single call to
/// [TestGesture.moveBy], `touchSlopX` and `touchSlopY` should be set to 0.
///
/// Breaking the drag into multiple moves is necessary for accurate execution
/// of drag update calls with a [DragStartBehavior] variable set to
/// [DragStartBehavior.start]. Without such a change, the dragUpdate callback
/// from a drag recognizer will never be invoked.
///
/// To force this function to a send a single move event, the `touchSlopX` and
/// `touchSlopY` variables should be set to 0. However, generally, these values
/// should be left to their default values.
/// {@endtemplate}
Future<void> drag(
Finder finder,
Offset offset, {
int? pointer,
int buttons = kPrimaryButton,
double touchSlopX = kDragSlopDefault,
double touchSlopY = kDragSlopDefault,
bool warnIfMissed = true,
PointerDeviceKind kind = PointerDeviceKind.touch,
}) {
return dragFrom(
getCenter(finder, warnIfMissed: warnIfMissed, callee: 'drag'),
offset,
pointer: pointer,
buttons: buttons,
touchSlopX: touchSlopX,
touchSlopY: touchSlopY,
kind: kind,
);
}
/// Attempts a drag gesture consisting of a pointer down, a move by
/// the given offset, and a pointer up.
///
/// If you want the drag to end with a speed so that the gesture recognition
/// system identifies the gesture as a fling, consider using [flingFrom]
/// instead.
///
/// The operation happens at once. If you want the drag to last for a period
/// of time, consider using [timedDragFrom].
///
/// {@macro flutter.flutter_test.WidgetController.drag}
Future<void> dragFrom(
Offset startLocation,
Offset offset, {
int? pointer,
int buttons = kPrimaryButton,
double touchSlopX = kDragSlopDefault,
double touchSlopY = kDragSlopDefault,
PointerDeviceKind kind = PointerDeviceKind.touch,
}) {
assert(kDragSlopDefault > kTouchSlop);
return TestAsyncUtils.guard<void>(() async {
final TestGesture gesture = await startGesture(startLocation, pointer: pointer, buttons: buttons, kind: kind);
assert(gesture != null);
final double xSign = offset.dx.sign;
final double ySign = offset.dy.sign;
final double offsetX = offset.dx;
final double offsetY = offset.dy;
final bool separateX = offset.dx.abs() > touchSlopX && touchSlopX > 0;
final bool separateY = offset.dy.abs() > touchSlopY && touchSlopY > 0;
if (separateY || separateX) {
final double offsetSlope = offsetY / offsetX;
final double inverseOffsetSlope = offsetX / offsetY;
final double slopSlope = touchSlopY / touchSlopX;
final double absoluteOffsetSlope = offsetSlope.abs();
final double signedSlopX = touchSlopX * xSign;
final double signedSlopY = touchSlopY * ySign;
if (absoluteOffsetSlope != slopSlope) {
// The drag goes through one or both of the extents of the edges of the box.
if (absoluteOffsetSlope < slopSlope) {
assert(offsetX.abs() > touchSlopX);
// The drag goes through the vertical edge of the box.
// It is guaranteed that the |offsetX| > touchSlopX.
final double diffY = offsetSlope.abs() * touchSlopX * ySign;
// The vector from the origin to the vertical edge.
await gesture.moveBy(Offset(signedSlopX, diffY));
if (offsetY.abs() <= touchSlopY) {
// The drag ends on or before getting to the horizontal extension of the horizontal edge.
await gesture.moveBy(Offset(offsetX - signedSlopX, offsetY - diffY));
} else {
final double diffY2 = signedSlopY - diffY;
final double diffX2 = inverseOffsetSlope * diffY2;
// The vector from the edge of the box to the horizontal extension of the horizontal edge.
await gesture.moveBy(Offset(diffX2, diffY2));
await gesture.moveBy(Offset(offsetX - diffX2 - signedSlopX, offsetY - signedSlopY));
}
} else {
assert(offsetY.abs() > touchSlopY);
// The drag goes through the horizontal edge of the box.
// It is guaranteed that the |offsetY| > touchSlopY.
final double diffX = inverseOffsetSlope.abs() * touchSlopY * xSign;
// The vector from the origin to the vertical edge.
await gesture.moveBy(Offset(diffX, signedSlopY));
if (offsetX.abs() <= touchSlopX) {
// The drag ends on or before getting to the vertical extension of the vertical edge.
await gesture.moveBy(Offset(offsetX - diffX, offsetY - signedSlopY));
} else {
final double diffX2 = signedSlopX - diffX;
final double diffY2 = offsetSlope * diffX2;
// The vector from the edge of the box to the vertical extension of the vertical edge.
await gesture.moveBy(Offset(diffX2, diffY2));
await gesture.moveBy(Offset(offsetX - signedSlopX, offsetY - diffY2 - signedSlopY));
}
}
} else { // The drag goes through the corner of the box.
await gesture.moveBy(Offset(signedSlopX, signedSlopY));
await gesture.moveBy(Offset(offsetX - signedSlopX, offsetY - signedSlopY));
}
} else { // The drag ends inside the box.
await gesture.moveBy(offset);
}
await gesture.up();
});
}
/// Attempts to drag the given widget by the given offset in the `duration`
/// time, starting in the middle of the widget.
///
/// {@macro flutter.flutter_test.WidgetController.tap.warnIfMissed}
///
/// This is the timed version of [drag]. This may or may not result in a
/// [fling] or ballistic animation, depending on the speed from
/// `offset/duration`.
///
/// {@template flutter.flutter_test.WidgetController.timedDrag}
/// The move events are sent at a given `frequency` in Hz (or events per
/// second). It defaults to 60Hz.
///
/// The movement is linear in time.
///
/// See also [LiveTestWidgetsFlutterBindingFramePolicy.benchmarkLive] for
/// more accurate time control.
/// {@endtemplate}
Future<void> timedDrag(
Finder finder,
Offset offset,
Duration duration, {
int? pointer,
int buttons = kPrimaryButton,
double frequency = 60.0,
bool warnIfMissed = true,
}) {
return timedDragFrom(
getCenter(finder, warnIfMissed: warnIfMissed, callee: 'timedDrag'),
offset,
duration,
pointer: pointer,
buttons: buttons,
frequency: frequency,
);
}
/// Attempts a series of [PointerEvent]s to simulate a drag operation in the
/// `duration` time.
///
/// This is the timed version of [dragFrom]. This may or may not result in a
/// [flingFrom] or ballistic animation, depending on the speed from
/// `offset/duration`.
///
/// {@macro flutter.flutter_test.WidgetController.timedDrag}
Future<void> timedDragFrom(
Offset startLocation,
Offset offset,
Duration duration, {
int? pointer,
int buttons = kPrimaryButton,
double frequency = 60.0,
}) {
assert(frequency > 0);
final int intervals = duration.inMicroseconds * frequency ~/ 1E6;
assert(intervals > 1);
pointer ??= _getNextPointer();
final List<Duration> timeStamps = <Duration>[
for (int t = 0; t <= intervals; t += 1)
duration * t ~/ intervals,
];
final List<Offset> offsets = <Offset>[
startLocation,
for (int t = 0; t <= intervals; t += 1)
startLocation + offset * (t / intervals),
];
final List<PointerEventRecord> records = <PointerEventRecord>[
PointerEventRecord(Duration.zero, <PointerEvent>[
PointerAddedEvent(
timeStamp: Duration.zero,
position: startLocation,
),
PointerDownEvent(
timeStamp: Duration.zero,
position: startLocation,
pointer: pointer,
buttons: buttons,
),
]),
...<PointerEventRecord>[
for(int t = 0; t <= intervals; t += 1)
PointerEventRecord(timeStamps[t], <PointerEvent>[
PointerMoveEvent(
timeStamp: timeStamps[t],
position: offsets[t+1],
delta: offsets[t+1] - offsets[t],
pointer: pointer,
buttons: buttons,
)
]),
],
PointerEventRecord(duration, <PointerEvent>[
PointerUpEvent(
timeStamp: duration,
position: offsets.last,
pointer: pointer,
// The PointerData received from the engine with
// change = PointerChange.up, which translates to PointerUpEvent,
// doesn't provide the button field.
// buttons: buttons,
)
]),
];
return TestAsyncUtils.guard<void>(() async {
await handlePointerEventRecord(records);
});
}
/// The next available pointer identifier.
///
/// This is the default pointer identifier that will be used the next time the
/// [startGesture] method is called without an explicit pointer identifier.
int get nextPointer => _nextPointer;
static int _nextPointer = 1;
static int _getNextPointer() {
final int result = _nextPointer;
_nextPointer += 1;
return result;
}
/// Creates gesture and returns the [TestGesture] object which you can use
/// to continue the gesture using calls on the [TestGesture] object.
///
/// You can use [startGesture] instead if your gesture begins with a down
/// event.
Future<TestGesture> createGesture({
int? pointer,
PointerDeviceKind kind = PointerDeviceKind.touch,
int buttons = kPrimaryButton,
}) async {
return TestGesture(
dispatcher: sendEventToBinding,
kind: kind,
pointer: pointer ?? _getNextPointer(),
buttons: buttons,
);
}
/// Creates a gesture with an initial down gesture at a particular point, and
/// returns the [TestGesture] object which you can use to continue the
/// gesture.
///
/// You can use [createGesture] if your gesture doesn't begin with an initial
/// down gesture.
Future<TestGesture> startGesture(
Offset downLocation, {
int? pointer,
PointerDeviceKind kind = PointerDeviceKind.touch,
int buttons = kPrimaryButton,
}) async {
assert(downLocation != null);
final TestGesture result = await createGesture(
pointer: pointer,
kind: kind,
buttons: buttons,
);
await result.down(downLocation);
return result;
}
/// Forwards the given location to the binding's hitTest logic.
HitTestResult hitTestOnBinding(Offset location) {
final HitTestResult result = HitTestResult();
binding.hitTest(result, location);
return result;
}
/// Forwards the given pointer event to the binding.
Future<void> sendEventToBinding(PointerEvent event) {
return TestAsyncUtils.guard<void>(() async {
binding.handlePointerEvent(event);
});
}
/// Calls [debugPrint] with the given message.
///
/// This is overridden by the WidgetTester subclass to use the test binding's
/// [TestWidgetsFlutterBinding.debugPrintOverride], so that it appears on the
/// console even if the test is logging output from the application.
@protected
void printToConsole(String message) {
debugPrint(message);
}
// GEOMETRY
/// Returns the point at the center of the given widget.
///
/// {@template flutter.flutter_test.WidgetController.getCenter.warnIfMissed}
/// If `warnIfMissed` is true (the default is false), then the returned
/// coordinate is checked to see if a hit test at the returned location would
/// actually include the specified element in the [HitTestResult], and if not,
/// a warning is printed to the console.
///
/// The `callee` argument is used to identify the method that should be
/// referenced in messages regarding `warnIfMissed`. It can be ignored unless
/// this method is being called from another that is forwarding its own
/// `warnIfMissed` parameter (see e.g. the implementation of [tap]).
/// {@endtemplate}
Offset getCenter(Finder finder, { bool warnIfMissed = false, String callee = 'getCenter' }) {
return _getElementPoint(finder, (Size size) => size.center(Offset.zero), warnIfMissed: warnIfMissed, callee: callee);
}
/// Returns the point at the top left of the given widget.
///
/// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed}
Offset getTopLeft(Finder finder, { bool warnIfMissed = false, String callee = 'getTopLeft' }) {
return _getElementPoint(finder, (Size size) => Offset.zero, warnIfMissed: warnIfMissed, callee: callee);
}
/// Returns the point at the top right of the given widget. This
/// point is not inside the object's hit test area.
///
/// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed}
Offset getTopRight(Finder finder, { bool warnIfMissed = false, String callee = 'getTopRight' }) {
return _getElementPoint(finder, (Size size) => size.topRight(Offset.zero), warnIfMissed: warnIfMissed, callee: callee);
}
/// Returns the point at the bottom left of the given widget. This
/// point is not inside the object's hit test area.
///
/// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed}
Offset getBottomLeft(Finder finder, { bool warnIfMissed = false, String callee = 'getBottomLeft' }) {
return _getElementPoint(finder, (Size size) => size.bottomLeft(Offset.zero), warnIfMissed: warnIfMissed, callee: callee);
}
/// Returns the point at the bottom right of the given widget. This
/// point is not inside the object's hit test area.
///
/// {@macro flutter.flutter_test.WidgetController.getCenter.warnIfMissed}
Offset getBottomRight(Finder finder, { bool warnIfMissed = false, String callee = 'getBottomRight' }) {
return _getElementPoint(finder, (Size size) => size.bottomRight(Offset.zero), warnIfMissed: warnIfMissed, callee: callee);
}
/// Whether warnings relating to hit tests not hitting their mark should be
/// fatal (cause the test to fail).
///
/// Some methods, e.g. [tap], have an argument `warnIfMissed` which causes a
/// warning to be displayed if the specified [Finder] indicates a widget and
/// location that, were a pointer event to be sent to that location, would not
/// actually send any events to the widget (e.g. because the widget is
/// obscured, or the location is off-screen, or the widget is transparent to
/// pointer events).
///
/// This warning was added in 2021. In ordinary operation this warning is
/// non-fatal since making it fatal would be a significantly breaking change
/// for anyone who already has tests relying on the ability to target events
/// using finders where the events wouldn't reach the widgets specified by the
/// finders in question.
///
/// However, doing this is usually unintentional. To make the warning fatal,
/// thus failing any tests where it occurs, this property can be set to true.
///
/// Typically this is done using a `flutter_test_config.dart` file, as described
/// in the documentation for the [flutter_test] library.
static bool hitTestWarningShouldBeFatal = false;
Offset _getElementPoint(Finder finder, Offset Function(Size size) sizeToPoint, { required bool warnIfMissed, required String callee }) {
TestAsyncUtils.guardSync();
final Iterable<Element> elements = finder.evaluate();
if (elements.isEmpty) {
throw FlutterError('The finder "$finder" (used in a call to "$callee()") could not find any matching widgets.');
}
if (elements.length > 1) {
throw FlutterError('The finder "$finder" (used in a call to "$callee()") ambiguously found multiple matching widgets. The "$callee()" method needs a single target.');
}
final Element element = elements.single;
final RenderObject? renderObject = element.renderObject;
if (renderObject == null) {
throw FlutterError(
'The finder "$finder" (used in a call to "$callee()") found an element, but it does not have a corresponding render object. '
'Maybe the element has not yet been rendered?'
);
}
if (renderObject is! RenderBox) {
throw FlutterError(
'The finder "$finder" (used in a call to "$callee()") found an element whose corresponding render object is not a RenderBox (it is a ${renderObject.runtimeType}: "$renderObject"). '
'Unfortunately "$callee()" only supports targeting widgets that correspond to RenderBox objects in the rendering.'
);
}
final RenderBox box = element.renderObject! as RenderBox;
final Offset location = box.localToGlobal(sizeToPoint(box.size));
if (warnIfMissed) {
final HitTestResult result = HitTestResult();
binding.hitTest(result, location);
bool found = false;
for (final HitTestEntry entry in result.path) {
if (entry.target == box) {
found = true;
break;
}
}
if (!found) {
bool outOfBounds = false;
if (binding.renderView != null && binding.renderView.size != null) {
outOfBounds = !(Offset.zero & binding.renderView.size).contains(location);
}
if (hitTestWarningShouldBeFatal) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Finder specifies a widget that would not receive pointer events.'),
ErrorDescription('A call to $callee() with finder "$finder" derived an Offset ($location) that would not hit test on the specified widget.'),
ErrorHint('Maybe the widget is actually off-screen, or another widget is obscuring it, or the widget cannot receive pointer events.'),
if (outOfBounds)
ErrorHint('Indeed, $location is outside the bounds of the root of the render tree, ${binding.renderView.size}.'),
box.toDiagnosticsNode(name: 'The finder corresponds to this RenderBox', style: DiagnosticsTreeStyle.singleLine),
ErrorDescription('The hit test result at that offset is: $result'),
ErrorDescription('If you expected this target not to be able to receive pointer events, pass "warnIfMissed: false" to "$callee()".'),
ErrorDescription('To make this error into a non-fatal warning, set WidgetController.hitTestWarningShouldBeFatal to false.'),
]);
}
printToConsole(
'\n'
'Warning: A call to $callee() with finder "$finder" derived an Offset ($location) that would not hit test on the specified widget.\n'
'Maybe the widget is actually off-screen, or another widget is obscuring it, or the widget cannot receive pointer events.\n'
'${outOfBounds ? "Indeed, $location is outside the bounds of the root of the render tree, ${binding.renderView.size}.\n" : ""}'
'The finder corresponds to this RenderBox: $box\n'
'The hit test result at that offset is: $result\n'
'${StackTrace.current}'
'To silence this warning, pass "warnIfMissed: false" to "$callee()".\n'
'To make this warning fatal, set WidgetController.hitTestWarningShouldBeFatal to true.\n',
);
}
}
return location;
}
/// Returns the size of the given widget. This is only valid once
/// the widget's render object has been laid out at least once.
Size getSize(Finder finder) {
TestAsyncUtils.guardSync();
final Element element = finder.evaluate().single;
final RenderBox box = element.renderObject! as RenderBox;
return box.size;
}
/// Simulates sending physical key down and up events through the system channel.
///
/// This only simulates key events coming from a physical keyboard, not from a
/// soft keyboard.
///
/// Specify `platform` as one of the platforms allowed in
/// [platform.Platform.operatingSystem] to make the event appear to be from
/// that type of system. Defaults to "web" on web, and "android" everywhere
/// else. Must not be null. Some platforms (e.g. Windows, iOS) are not yet
/// supported.
///
/// Keys that are down when the test completes are cleared after each test.
///
/// This method sends both the key down and the key up events, to simulate a
/// key press. To simulate individual down and/or up events, see
/// [sendKeyDownEvent] and [sendKeyUpEvent].
///
/// Returns true if the key down event was handled by the framework.
///
/// See also:
///
/// - [sendKeyDownEvent] to simulate only a key down event.
/// - [sendKeyUpEvent] to simulate only a key up event.
Future<bool> sendKeyEvent(LogicalKeyboardKey key, { String platform = _defaultPlatform }) async {
assert(platform != null);
final bool handled = await simulateKeyDownEvent(key, platform: platform);
// Internally wrapped in async guard.
await simulateKeyUpEvent(key, platform: platform);
return handled;
}
/// Simulates sending a physical key down event through the system channel.
///
/// This only simulates key down events coming from a physical keyboard, not
/// from a soft keyboard.
///
/// Specify `platform` as one of the platforms allowed in
/// [platform.Platform.operatingSystem] to make the event appear to be from
/// that type of system. Defaults to "web" on web, and "android" everywhere
/// else. Must not be null. Some platforms (e.g. Windows, iOS) are not yet
/// supported.
///
/// Keys that are down when the test completes are cleared after each test.
///
/// Returns true if the key event was handled by the framework.
///
/// See also:
///
/// - [sendKeyUpEvent] to simulate the corresponding key up event.
/// - [sendKeyEvent] to simulate both the key up and key down in the same call.
Future<bool> sendKeyDownEvent(LogicalKeyboardKey key, { String? character, String platform = _defaultPlatform }) async {
assert(platform != null);
// Internally wrapped in async guard.
return simulateKeyDownEvent(key, character: character, platform: platform);
}
/// Simulates sending a physical key up event through the system channel.
///
/// This only simulates key up events coming from a physical keyboard,
/// not from a soft keyboard.
///
/// Specify `platform` as one of the platforms allowed in
/// [platform.Platform.operatingSystem] to make the event appear to be from
/// that type of system. Defaults to "web" on web, and "android" everywhere
/// else. May not be null.
///
/// Returns true if the key event was handled by the framework.
///
/// See also:
///
/// - [sendKeyDownEvent] to simulate the corresponding key down event.
/// - [sendKeyEvent] to simulate both the key up and key down in the same call.
Future<bool> sendKeyUpEvent(LogicalKeyboardKey key, { String platform = _defaultPlatform }) async {
assert(platform != null);
// Internally wrapped in async guard.
return simulateKeyUpEvent(key, platform: platform);
}
/// Returns the rect of the given widget. This is only valid once
/// the widget's render object has been laid out at least once.
Rect getRect(Finder finder) => getTopLeft(finder) & getSize(finder);
/// Attempts to find the [SemanticsNode] of first result from `finder`.
///
/// If the object identified by the finder doesn't own it's semantic node,
/// this will return the semantics data of the first ancestor with semantics.
/// The ancestor's semantic data will include the child's as well as
/// other nodes that have been merged together.
///
/// If the [SemanticsNode] of the object identified by the finder is
/// force-merged into an ancestor (e.g. via the [MergeSemantics] widget)
/// the node into which it is merged is returned. That node will include
/// all the semantics information of the nodes merged into it.
///
/// Will throw a [StateError] if the finder returns more than one element or
/// if no semantics are found or are not enabled.
SemanticsNode getSemantics(Finder finder) {
if (binding.pipelineOwner.semanticsOwner == null)
throw StateError('Semantics are not enabled.');
final Iterable<Element> candidates = finder.evaluate();
if (candidates.isEmpty) {
throw StateError('Finder returned no matching elements.');
}
if (candidates.length > 1) {
throw StateError('Finder returned more than one element.');
}
final Element element = candidates.single;
RenderObject? renderObject = element.findRenderObject();
SemanticsNode? result = renderObject?.debugSemantics;
while (renderObject != null && (result == null || result.isMergedIntoParent)) {
renderObject = renderObject.parent as RenderObject?;
result = renderObject?.debugSemantics;
}
if (result == null)
throw StateError('No Semantics data found.');
return result;
}
/// Enable semantics in a test by creating a [SemanticsHandle].
///
/// The handle must be disposed at the end of the test.
SemanticsHandle ensureSemantics() {
return binding.pipelineOwner.ensureSemantics();
}
/// Given a widget `W` specified by [finder] and a [Scrollable] widget `S` in
/// its ancestry tree, this scrolls `S` so as to make `W` visible.
///
/// Usually the `finder` for this method should be labeled `skipOffstage:
/// false`, so that the [Finder] deals with widgets that are off the screen
/// correctly.
///
/// This does not work when `S` is long enough, and `W` far away enough from
/// the displayed part of `S`, that `S` has not yet cached `W`'s element.
/// Consider using [scrollUntilVisible] in such a situation.
///
/// See also:
///
/// * [Scrollable.ensureVisible], which is the production API used to
/// implement this method.
Future<void> ensureVisible(Finder finder) => Scrollable.ensureVisible(element(finder));
/// Repeatedly scrolls a [Scrollable] by `delta` in the
/// [Scrollable.axisDirection] direction until a widget matching `finder` is
/// visible.
///
/// Between each scroll, advances the clock by `duration` time.
///
/// Scrolling is performed until the start of the `finder` is visible. This is
/// due to the default parameter values of the [Scrollable.ensureVisible] method.
///
/// If `scrollable` is `null`, a [Finder] that looks for a [Scrollable] is
/// used instead.
///
/// Throws a [StateError] if `finder` is not found after `maxScrolls` scrolls.
///
/// This is different from [ensureVisible] in that this allows looking for
/// `finder` that is not yet built. The caller must specify the scrollable
/// that will build child specified by `finder` when there are multiple
/// [Scrollable]s.
///
/// See also:
///
/// * [dragUntilVisible], which implements the body of this method.
Future<void> scrollUntilVisible(
Finder finder,
double delta, {
Finder? scrollable,
int maxScrolls = 50,
Duration duration = const Duration(milliseconds: 50),
}
) {
assert(maxScrolls > 0);
scrollable ??= find.byType(Scrollable);
return TestAsyncUtils.guard<void>(() async {
Offset moveStep;
switch (widget<Scrollable>(scrollable!).axisDirection) {
case AxisDirection.up:
moveStep = Offset(0, delta);
break;
case AxisDirection.down:
moveStep = Offset(0, -delta);
break;
case AxisDirection.left:
moveStep = Offset(delta, 0);
break;
case AxisDirection.right:
moveStep = Offset(-delta, 0);
break;
}
await dragUntilVisible(
finder,
scrollable,
moveStep,
maxIteration: maxScrolls,
duration: duration,
);
});
}
/// Repeatedly drags `view` by `moveStep` until `finder` is visible.
///
/// Between each drag, advances the clock by `duration`.
///
/// Throws a [StateError] if `finder` is not found after `maxIteration`
/// drags.
///
/// See also:
///
/// * [scrollUntilVisible], which wraps this method with an API that is more
/// convenient when dealing with a [Scrollable].
Future<void> dragUntilVisible(
Finder finder,
Finder view,
Offset moveStep, {
int maxIteration = 50,
Duration duration = const Duration(milliseconds: 50),
}) {
return TestAsyncUtils.guard<void>(() async {
while (maxIteration > 0 && finder.evaluate().isEmpty) {
await drag(view, moveStep);
await pump(duration);
maxIteration -= 1;
}
await Scrollable.ensureVisible(element(finder));
});
}
}
/// Variant of [WidgetController] that can be used in tests running
/// on a device.
///
/// This is used, for instance, by [FlutterDriver].
class LiveWidgetController extends WidgetController {
/// Creates a widget controller that uses the given binding.
LiveWidgetController(WidgetsBinding binding) : super(binding);
@override
Future<void> pump([Duration? duration]) async {
if (duration != null)
await Future<void>.delayed(duration);
binding.scheduleFrame();
await binding.endOfFrame;
}
@override
Future<int> pumpAndSettle([
Duration duration = const Duration(milliseconds: 100),
]) {
assert(duration != null);
assert(duration > Duration.zero);
return TestAsyncUtils.guard<int>(() async {
int count = 0;
do {
await pump(duration);
count += 1;
} while (binding.hasScheduledFrame);
return count;
});
}
@override
Future<List<Duration>> handlePointerEventRecord(List<PointerEventRecord> records) {
assert(records != null);
assert(records.isNotEmpty);
return TestAsyncUtils.guard<List<Duration>>(() async {
// hitTestHistory is an equivalence of _hitTests in [GestureBinding],
// used as state for all pointers which are currently down.
final Map<int, HitTestResult> hitTestHistory = <int, HitTestResult>{};
final List<Duration> handleTimeStampDiff = <Duration>[];
DateTime? startTime;
for (final PointerEventRecord record in records) {
final DateTime now = clock.now();
startTime ??= now;
// So that the first event is promised to receive a zero timeDiff
final Duration timeDiff = record.timeDelay - now.difference(startTime);
if (timeDiff.isNegative) {
// This happens when something (e.g. GC) takes a long time during the
// processing of the events.
// Flush all past events
handleTimeStampDiff.add(-timeDiff);
record.events.forEach(binding.handlePointerEvent);
} else {
await Future<void>.delayed(timeDiff);
handleTimeStampDiff.add(
// Recalculating the time diff for getting exact time when the event
// packet is sent. For a perfect Future.delayed like the one in a
// fake async this new diff should be zero.
clock.now().difference(startTime) - record.timeDelay,
);
record.events.forEach(binding.handlePointerEvent);
}
}
// This makes sure that a gesture is completed, with no more pointers
// active.
assert(hitTestHistory.isEmpty);
return handleTimeStampDiff;
});
}
}