mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
Reland: Make LiveTestWidgetsFlutterBinding work with setSurfaceSize and live tests (#86739)
This PR fixes several bugs related to live tests, adds more tests, and completes the documentation of several methods related to pointer events.
This commit is contained in:
parent
50ee8d83c0
commit
cf3d9409b3
@ -15,6 +15,7 @@ void main() {
|
||||
|
||||
testWidgets('Should show event indicator for pointer events', (WidgetTester tester) async {
|
||||
final AnimationSheetBuilder animationSheet = AnimationSheetBuilder(frameSize: const Size(200, 200), allLayers: true);
|
||||
int tapped = 0;
|
||||
final Widget target = Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 25, 20),
|
||||
child: animationSheet.record(
|
||||
@ -25,9 +26,12 @@ void main() {
|
||||
border: Border.all(color: const Color.fromARGB(255, 0, 0, 0)),
|
||||
),
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
child: const Text('Test'),
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: GestureDetector(
|
||||
onTapDown: (_) { tapped += 1; },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -39,23 +43,78 @@ void main() {
|
||||
|
||||
await tester.pumpFrames(target, const Duration(milliseconds: 50));
|
||||
|
||||
final TestGesture gesture1 = await tester.createGesture();
|
||||
await gesture1.down(tester.getCenter(find.byType(Text)) + const Offset(10, 10));
|
||||
final TestGesture gesture1 = await tester.createGesture(pointer: 1);
|
||||
await gesture1.down(tester.getCenter(find.byType(GestureDetector)) + const Offset(10, 10));
|
||||
|
||||
await tester.pumpFrames(target, const Duration(milliseconds: 100));
|
||||
expect(tapped, 1);
|
||||
|
||||
final TestGesture gesture2 = await tester.createGesture();
|
||||
await gesture2.down(tester.getTopLeft(find.byType(Text)) + const Offset(30, -10));
|
||||
final TestGesture gesture2 = await tester.createGesture(pointer: 2);
|
||||
await gesture2.down(tester.getTopLeft(find.byType(GestureDetector)) + const Offset(30, -10));
|
||||
await gesture1.moveBy(const Offset(50, 50));
|
||||
|
||||
await tester.pumpFrames(target, const Duration(milliseconds: 100));
|
||||
await gesture1.up();
|
||||
await gesture2.up();
|
||||
await tester.pumpFrames(target, const Duration(milliseconds: 50));
|
||||
expect(tapped, 1);
|
||||
|
||||
await expectLater(
|
||||
animationSheet.collate(6),
|
||||
matchesGoldenFile('LiveBinding.press.animation.png'),
|
||||
);
|
||||
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/42767
|
||||
|
||||
testWidgets('Should show event indicator for pointer events with setSurfaceSize', (WidgetTester tester) async {
|
||||
final AnimationSheetBuilder animationSheet = AnimationSheetBuilder(frameSize: const Size(200, 200), allLayers: true);
|
||||
int tapped = 0;
|
||||
final Widget target = Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 25, 20),
|
||||
child: animationSheet.record(
|
||||
MaterialApp(
|
||||
home: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color.fromARGB(255, 128, 128, 128),
|
||||
border: Border.all(color: const Color.fromARGB(255, 0, 0, 0)),
|
||||
),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: GestureDetector(
|
||||
onTapDown: (_) { tapped += 1; },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(300, 300));
|
||||
await tester.pumpWidget(target);
|
||||
|
||||
await tester.pumpFrames(target, const Duration(milliseconds: 50));
|
||||
|
||||
final TestGesture gesture1 = await tester.createGesture(pointer: 1);
|
||||
await gesture1.down(tester.getCenter(find.byType(GestureDetector)) + const Offset(10, 10));
|
||||
|
||||
await tester.pumpFrames(target, const Duration(milliseconds: 100));
|
||||
expect(tapped, 1);
|
||||
|
||||
final TestGesture gesture2 = await tester.createGesture(pointer: 2);
|
||||
await gesture2.down(tester.getTopLeft(find.byType(GestureDetector)) + const Offset(30, -10));
|
||||
await gesture1.moveBy(const Offset(50, 50));
|
||||
|
||||
await tester.pumpFrames(target, const Duration(milliseconds: 100));
|
||||
await gesture1.up();
|
||||
await gesture2.up();
|
||||
await tester.pumpFrames(target, const Duration(milliseconds: 50));
|
||||
expect(tapped, 1);
|
||||
|
||||
await expectLater(
|
||||
animationSheet.collate(6),
|
||||
matchesGoldenFile('LiveBinding.press.animation.2.png'),
|
||||
);
|
||||
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/42767
|
||||
}
|
||||
|
@ -119,6 +119,20 @@ mixin TestDefaultBinaryMessengerBinding on BindingBase, ServicesBinding {
|
||||
/// that actually needs to make a network call should provide its own
|
||||
/// `HttpClient` to the code making the call, so that it can appropriately mock
|
||||
/// or fake responses.
|
||||
///
|
||||
/// ### Coordinate spaces
|
||||
///
|
||||
/// [TestWidgetsFlutterBinding] might be run on devices of different screen
|
||||
/// sizes, while the testing widget is still told the same size to ensure
|
||||
/// consistent results. Consequently, code that deals with positions (such as
|
||||
/// pointer events or painting) must distinguish between two coordinate spaces:
|
||||
///
|
||||
/// * The _local coordinate space_ is the one used by the testing widget
|
||||
/// (typically an 800 by 600 window, but can be altered by [setSurfaceSize]).
|
||||
/// * The _global coordinate space_ is the one used by the device.
|
||||
///
|
||||
/// Positions can be transformed between coordinate spaces with [localToGlobal]
|
||||
/// and [globalToLocal].
|
||||
abstract class TestWidgetsFlutterBinding extends BindingBase
|
||||
with SchedulerBinding,
|
||||
ServicesBinding,
|
||||
@ -447,14 +461,16 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
|
||||
});
|
||||
}
|
||||
|
||||
/// Convert the given point from the global coordinate system (as used by
|
||||
/// pointer events from the device) to the coordinate system used by the
|
||||
/// tests (an 800 by 600 window).
|
||||
/// Convert the given point from the global coordinate space to the local
|
||||
/// one.
|
||||
///
|
||||
/// For definitions for coordinate spaces, see [TestWidgetsFlutterBinding].
|
||||
Offset globalToLocal(Offset point) => point;
|
||||
|
||||
/// Convert the given point from the coordinate system used by the tests (an
|
||||
/// 800 by 600 window) to the global coordinate system (as used by pointer
|
||||
/// events from the device).
|
||||
/// Convert the given point from the local coordinate space to the global
|
||||
/// one.
|
||||
///
|
||||
/// For definitions for coordinate spaces, see [TestWidgetsFlutterBinding].
|
||||
Offset localToGlobal(Offset point) => point;
|
||||
|
||||
/// The source of the current pointer event.
|
||||
@ -462,15 +478,34 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
|
||||
/// The [pointerEventSource] is set as the `source` parameter of
|
||||
/// [handlePointerEventForSource] and can be used in the immediate enclosing
|
||||
/// [dispatchEvent].
|
||||
///
|
||||
/// When [handlePointerEvent] is called directly, [pointerEventSource]
|
||||
/// is [TestBindingEventSource.device].
|
||||
TestBindingEventSource get pointerEventSource => _pointerEventSource;
|
||||
TestBindingEventSource _pointerEventSource = TestBindingEventSource.device;
|
||||
|
||||
/// Dispatch an event to the targets found by a hit test on its position,
|
||||
/// and remember its source as [pointerEventSource].
|
||||
///
|
||||
/// This method sets [pointerEventSource] to `source`, runs
|
||||
/// This method sets [pointerEventSource] to `source`, forwards the call to
|
||||
/// [handlePointerEvent], then resets [pointerEventSource] to the previous
|
||||
/// value.
|
||||
///
|
||||
/// If `source` is [TestBindingEventSource.device], then the `event` is based
|
||||
/// in the global coordinate space (for definitions for coordinate spaces,
|
||||
/// see [TestWidgetsFlutterBinding]) and the event is likely triggered by the
|
||||
/// user physically interacting with the screen during a live test on a real
|
||||
/// device (see [LiveTestWidgetsFlutterBinding]).
|
||||
///
|
||||
/// If `source` is [TestBindingEventSource.test], then the `event` is based
|
||||
/// in the local coordinate space and the event is likely triggered by
|
||||
/// programatically simulated pointer events, such as:
|
||||
///
|
||||
/// * [WidgetController.tap] and alike methods, as well as directly using
|
||||
/// [TestGesture]. They are usually used in
|
||||
/// [AutomatedTestWidgetsFlutterBinding] but sometimes in live tests too.
|
||||
/// * [WidgetController.timedDrag] and alike methods. They are usually used
|
||||
/// in macrobenchmarks.
|
||||
void handlePointerEventForSource(
|
||||
PointerEvent event, {
|
||||
TestBindingEventSource source = TestBindingEventSource.device,
|
||||
@ -482,7 +517,7 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
|
||||
/// to the previous value.
|
||||
@protected
|
||||
void withPointerEventSource(TestBindingEventSource source, VoidCallback task) {
|
||||
final TestBindingEventSource previousSource = source;
|
||||
final TestBindingEventSource previousSource = _pointerEventSource;
|
||||
_pointerEventSource = source;
|
||||
try {
|
||||
task();
|
||||
@ -1497,11 +1532,15 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding {
|
||||
/// Events dispatched by [TestGesture] are not affected by this.
|
||||
HitTestDispatcher? deviceEventDispatcher;
|
||||
|
||||
|
||||
/// Dispatch an event to the targets found by a hit test on its position.
|
||||
///
|
||||
/// Apart from forwarding the event to [GestureBinding.dispatchEvent],
|
||||
/// This also paint all events that's down on the screen.
|
||||
/// If the [pointerEventSource] is [TestBindingEventSource.test], then
|
||||
/// the event is forwarded to [GestureBinding.dispatchEvent] as usual;
|
||||
/// additionally, down pointers are painted on the screen.
|
||||
///
|
||||
/// If the [pointerEventSource] is [TestBindingEventSource.device], then
|
||||
/// the event, after being transformed to the local coordinate system, is
|
||||
/// forwarded to [deviceEventDispatcher].
|
||||
@override
|
||||
void handlePointerEvent(PointerEvent event) {
|
||||
switch (pointerEventSource) {
|
||||
@ -1523,8 +1562,12 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding {
|
||||
break;
|
||||
case TestBindingEventSource.device:
|
||||
if (deviceEventDispatcher != null) {
|
||||
// The pointer events received with this source has a global position
|
||||
// (see [handlePointerEventForSource]). Transform it to the local
|
||||
// coordinate space used by the testing widgets.
|
||||
final PointerEvent localEvent = event.copyWith(position: globalToLocal(event.position));
|
||||
withPointerEventSource(TestBindingEventSource.device,
|
||||
() => super.handlePointerEvent(event)
|
||||
() => super.handlePointerEvent(localEvent)
|
||||
);
|
||||
}
|
||||
break;
|
||||
@ -1538,9 +1581,10 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding {
|
||||
super.dispatchEvent(event, hitTestResult);
|
||||
break;
|
||||
case TestBindingEventSource.device:
|
||||
assert(hitTestResult != null);
|
||||
assert(hitTestResult != null || event is PointerAddedEvent || event is PointerRemovedEvent);
|
||||
assert(deviceEventDispatcher != null);
|
||||
deviceEventDispatcher!.dispatchEvent(event, hitTestResult!);
|
||||
if (hitTestResult != null)
|
||||
deviceEventDispatcher!.dispatchEvent(event, hitTestResult);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -1775,15 +1819,6 @@ class _LiveTestRenderView extends RenderView {
|
||||
onNeedPaint();
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTest(HitTestResult result, { required Offset position }) {
|
||||
final Matrix4 transform = configuration.toHitTestMatrix();
|
||||
final double det = transform.invert();
|
||||
assert(det != 0.0);
|
||||
position = MatrixUtils.transformPoint(transform, position);
|
||||
return super.hitTest(result, position: position);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
assert(offset == Offset.zero);
|
||||
|
@ -59,6 +59,21 @@ export 'package:test_api/test_api.dart' hide
|
||||
/// Signature for callback to [testWidgets] and [benchmarkWidgets].
|
||||
typedef WidgetTesterCallback = Future<void> Function(WidgetTester widgetTester);
|
||||
|
||||
// Return the last element that satisifes `test`, or return null if not found.
|
||||
E? _lastWhereOrNull<E>(Iterable<E> list, bool Function(E) test) {
|
||||
late E result;
|
||||
bool foundMatching = false;
|
||||
for (final E element in list) {
|
||||
if (test(element)) {
|
||||
result = element;
|
||||
foundMatching = true;
|
||||
}
|
||||
}
|
||||
if (foundMatching)
|
||||
return result;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Runs the [callback] inside the Flutter test environment.
|
||||
///
|
||||
/// Use this function for testing custom [StatelessWidget]s and
|
||||
@ -806,15 +821,12 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker
|
||||
.map((HitTestEntry candidate) => candidate.target)
|
||||
.whereType<RenderObject>()
|
||||
.first;
|
||||
final Element? innerTargetElement = collectAllElementsFrom(
|
||||
binding.renderViewElement!,
|
||||
skipOffstage: true,
|
||||
).cast<Element?>().lastWhere(
|
||||
(Element? element) => element!.renderObject == innerTarget,
|
||||
orElse: () => null,
|
||||
final Element? innerTargetElement = _lastWhereOrNull(
|
||||
collectAllElementsFrom(binding.renderViewElement!, skipOffstage: true),
|
||||
(Element element) => element.renderObject == innerTarget,
|
||||
);
|
||||
if (innerTargetElement == null) {
|
||||
printToConsole('No widgets found at ${binding.globalToLocal(event.position)}.');
|
||||
printToConsole('No widgets found at ${event.position}.');
|
||||
return;
|
||||
}
|
||||
final List<Element> candidates = <Element>[];
|
||||
@ -827,7 +839,7 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker
|
||||
int numberOfWithTexts = 0;
|
||||
int numberOfTypes = 0;
|
||||
int totalNumber = 0;
|
||||
printToConsole('Some possible finders for the widgets at ${binding.globalToLocal(event.position)}:');
|
||||
printToConsole('Some possible finders for the widgets at ${event.position}:');
|
||||
for (final Element element in candidates) {
|
||||
if (totalNumber > 13) // an arbitrary number of finders that feels useful without being overwhelming
|
||||
break;
|
||||
|
@ -6,6 +6,13 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// Only check the initial lines of the message, since the message walks the
|
||||
// entire widget tree back, and any changes to the widget tree break these
|
||||
// tests if we check the entire message.
|
||||
void _expectStartsWith(List<String?> actual, List<String?> matcher) {
|
||||
expect(actual.sublist(0, matcher.length), equals(matcher));
|
||||
}
|
||||
|
||||
void main() {
|
||||
final _MockLiveTestWidgetsFlutterBinding binding = _MockLiveTestWidgetsFlutterBinding();
|
||||
|
||||
@ -14,8 +21,9 @@ void main() {
|
||||
|
||||
int invocations = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Center(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
invocations++;
|
||||
@ -42,39 +50,82 @@ void main() {
|
||||
await tester.pump();
|
||||
expect(invocations, 0);
|
||||
|
||||
expect(printedMessages, equals('''
|
||||
_expectStartsWith(printedMessages, '''
|
||||
Some possible finders for the widgets at Offset(400.0, 300.0):
|
||||
find.text('Test')
|
||||
find.widgetWithText(RawGestureDetector, 'Test')
|
||||
find.byType(GestureDetector)
|
||||
find.byType(Center)
|
||||
find.widgetWithText(IgnorePointer, 'Test')
|
||||
find.byType(FadeTransition)
|
||||
find.byType(FractionalTranslation)
|
||||
find.byType(SlideTransition)
|
||||
find.widgetWithText(FocusTrap, 'Test')
|
||||
find.widgetWithText(PrimaryScrollController, 'Test')
|
||||
find.widgetWithText(PageStorage, 'Test')
|
||||
'''.trim().split('\n')));
|
||||
'''.trim().split('\n'));
|
||||
printedMessages.clear();
|
||||
|
||||
await binding.collectDebugPrints(printedMessages, () async {
|
||||
await tester.tapAt(const Offset(1, 1));
|
||||
});
|
||||
expect(printedMessages, equals('''
|
||||
Some possible finders for the widgets at Offset(1.0, 1.0):
|
||||
find.byType(MouseRegion)
|
||||
find.byType(ExcludeSemantics)
|
||||
find.byType(BlockSemantics)
|
||||
find.byType(ModalBarrier)
|
||||
find.byType(Overlay)
|
||||
No widgets found at Offset(1.0, 1.0).
|
||||
'''.trim().split('\n')));
|
||||
});
|
||||
|
||||
testWidgets('Should print message on pointer events with setSurfaceSize', (WidgetTester tester) async {
|
||||
final List<String?> printedMessages = <String?>[];
|
||||
|
||||
int invocations = 0;
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Center(
|
||||
child:GestureDetector(
|
||||
onTap: () {
|
||||
invocations++;
|
||||
},
|
||||
child: const Text('Test'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(2000, 1800));
|
||||
await tester.pump();
|
||||
|
||||
final Offset widgetCenter = tester.getRect(find.byType(Text)).center;
|
||||
expect(widgetCenter.dx, 1000);
|
||||
expect(widgetCenter.dy, 900);
|
||||
|
||||
await binding.collectDebugPrints(printedMessages, () async {
|
||||
await tester.tap(find.byType(Text));
|
||||
});
|
||||
await tester.pump();
|
||||
expect(invocations, 0);
|
||||
|
||||
_expectStartsWith(printedMessages, '''
|
||||
Some possible finders for the widgets at Offset(1000.0, 900.0):
|
||||
find.text('Test')
|
||||
'''.trim().split('\n'));
|
||||
printedMessages.clear();
|
||||
|
||||
await binding.collectDebugPrints(printedMessages, () async {
|
||||
await tester.tapAt(const Offset(1, 1));
|
||||
});
|
||||
expect(printedMessages, equals('''
|
||||
No widgets found at Offset(1.0, 1.0).
|
||||
'''.trim().split('\n')));
|
||||
});
|
||||
}
|
||||
|
||||
class _MockLiveTestWidgetsFlutterBinding extends LiveTestWidgetsFlutterBinding {
|
||||
@override
|
||||
TestBindingEventSource get pointerEventSource => TestBindingEventSource.device;
|
||||
void handlePointerEventForSource(
|
||||
PointerEvent event, {
|
||||
TestBindingEventSource source = TestBindingEventSource.device,
|
||||
}) {
|
||||
// In this test we use `WidgetTester.tap` to simulate real device touches.
|
||||
// `WidgetTester.tap` sends events in the local coordinate system, while
|
||||
// real devices touches sends event in the global coordinate system.
|
||||
// See the documentation of [handlePointerEventForSource] for details.
|
||||
if (source == TestBindingEventSource.test) {
|
||||
final PointerEvent globalEvent = event.copyWith(position: localToGlobal(event.position));
|
||||
return super.handlePointerEventForSource(globalEvent, source: TestBindingEventSource.device);
|
||||
}
|
||||
return super.handlePointerEventForSource(event, source: source);
|
||||
}
|
||||
|
||||
List<String?>? _storeDebugPrints;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user