| // 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:collection/collection.dart'; | |
| import 'package:flutter/gestures.dart'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/semantics.dart'; | |
| import 'package:flutter_test/flutter_test.dart'; | |
| import 'package:stack_trace/stack_trace.dart'; | |
| class TestDragData { | |
| const TestDragData( | |
| this.slop, | |
| this.dragDistance, | |
| this.expectedOffsets, | |
| ); | |
| final Offset slop; | |
| final Offset dragDistance; | |
| final List<Offset> expectedOffsets; | |
| } | |
| void main() { | |
| testWidgets( | |
| 'WidgetTester.drag must break the offset into multiple parallel components if ' | |
| 'the drag goes outside the touch slop values', | |
| (WidgetTester tester) async { | |
| // This test checks to make sure that the total drag will be correctly split into | |
| // pieces such that the first (and potentially second) moveBy function call(s) in | |
| // controller.drag() will never have a component greater than the touch | |
| // slop in that component's respective axis. | |
| const List<TestDragData> offsetResults = <TestDragData>[ | |
| TestDragData( | |
| Offset(10.0, 10.0), | |
| Offset(-150.0, 200.0), | |
| <Offset>[ | |
| Offset(-7.5, 10.0), | |
| Offset(-2.5, 3.333333333333333), | |
| Offset(-140.0, 186.66666666666666), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(10.0, 10.0), | |
| Offset(150, -200), | |
| <Offset>[ | |
| Offset(7.5, -10), | |
| Offset(2.5, -3.333333333333333), | |
| Offset(140.0, -186.66666666666666), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(10.0, 10.0), | |
| Offset(-200, 150), | |
| <Offset>[ | |
| Offset(-10, 7.5), | |
| Offset(-3.333333333333333, 2.5), | |
| Offset(-186.66666666666666, 140.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(10.0, 10.0), | |
| Offset(200.0, -150.0), | |
| <Offset>[ | |
| Offset(10, -7.5), | |
| Offset(3.333333333333333, -2.5), | |
| Offset(186.66666666666666, -140.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(10.0, 10.0), | |
| Offset(-150.0, -200.0), | |
| <Offset>[ | |
| Offset(-7.5, -10.0), | |
| Offset(-2.5, -3.333333333333333), | |
| Offset(-140.0, -186.66666666666666), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(10.0, 10.0), | |
| Offset(8.0, 3.0), | |
| <Offset>[ | |
| Offset(8.0, 3.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(10.0, 10.0), | |
| Offset(3.0, 8.0), | |
| <Offset>[ | |
| Offset(3.0, 8.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(10.0, 10.0), | |
| Offset(20.0, 5.0), | |
| <Offset>[ | |
| Offset(10.0, 2.5), | |
| Offset(10.0, 2.5), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(10.0, 10.0), | |
| Offset(5.0, 20.0), | |
| <Offset>[ | |
| Offset(2.5, 10.0), | |
| Offset(2.5, 10.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(10.0, 10.0), | |
| Offset(20.0, 15.0), | |
| <Offset>[ | |
| Offset(10.0, 7.5), | |
| Offset(3.333333333333333, 2.5), | |
| Offset(6.666666666666668, 5.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(10.0, 10.0), | |
| Offset(15.0, 20.0), | |
| <Offset>[ | |
| Offset(7.5, 10.0), | |
| Offset(2.5, 3.333333333333333), | |
| Offset(5.0, 6.666666666666668), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(10.0, 10.0), | |
| Offset(20.0, 20.0), | |
| <Offset>[ | |
| Offset(10.0, 10.0), | |
| Offset(10.0, 10.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(10.0, 10.0), | |
| Offset(0.0, 5.0), | |
| <Offset>[ | |
| Offset(0.0, 5.0), | |
| ], | |
| ), | |
| //// Varying touch slops | |
| TestDragData( | |
| Offset(12.0, 5.0), | |
| Offset(0.0, 5.0), | |
| <Offset>[ | |
| Offset(0.0, 5.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(12.0, 5.0), | |
| Offset(20.0, 5.0), | |
| <Offset>[ | |
| Offset(12.0, 3.0), | |
| Offset(8.0, 2.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(12.0, 5.0), | |
| Offset(5.0, 20.0), | |
| <Offset>[ | |
| Offset(1.25, 5.0), | |
| Offset(3.75, 15.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(5.0, 12.0), | |
| Offset(5.0, 20.0), | |
| <Offset>[ | |
| Offset(3.0, 12.0), | |
| Offset(2.0, 8.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(5.0, 12.0), | |
| Offset(20.0, 5.0), | |
| <Offset>[ | |
| Offset(5.0, 1.25), | |
| Offset(15.0, 3.75), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(18.0, 18.0), | |
| Offset(0.0, 150.0), | |
| <Offset>[ | |
| Offset(0.0, 18.0), | |
| Offset(0.0, 132.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(18.0, 18.0), | |
| Offset(0.0, -150.0), | |
| <Offset>[ | |
| Offset(0.0, -18.0), | |
| Offset(0.0, -132.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(18.0, 18.0), | |
| Offset(-150.0, 0.0), | |
| <Offset>[ | |
| Offset(-18.0, 0.0), | |
| Offset(-132.0, 0.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset.zero, | |
| Offset(-150.0, 0.0), | |
| <Offset>[ | |
| Offset(-150.0, 0.0), | |
| ], | |
| ), | |
| TestDragData( | |
| Offset(18.0, 18.0), | |
| Offset(-32.0, 0.0), | |
| <Offset>[ | |
| Offset(-18.0, 0.0), | |
| Offset(-14.0, 0.0), | |
| ], | |
| ), | |
| ]; | |
| final List<Offset> dragOffsets = <Offset>[]; | |
| await tester.pumpWidget( | |
| Listener( | |
| onPointerMove: (PointerMoveEvent event) { | |
| dragOffsets.add(event.delta); | |
| }, | |
| child: const Text('test', textDirection: TextDirection.ltr), | |
| ), | |
| ); | |
| for (int resultIndex = 0; resultIndex < offsetResults.length; resultIndex += 1) { | |
| final TestDragData testResult = offsetResults[resultIndex]; | |
| await tester.drag( | |
| find.text('test'), | |
| testResult.dragDistance, | |
| touchSlopX: testResult.slop.dx, | |
| touchSlopY: testResult.slop.dy, | |
| ); | |
| expect( | |
| testResult.expectedOffsets.length, | |
| dragOffsets.length, | |
| reason: | |
| 'There is a difference in the number of expected and actual split offsets for the drag with:\n' | |
| 'Touch Slop: ${testResult.slop}\n' | |
| 'Delta: ${testResult.dragDistance}\n', | |
| ); | |
| for (int valueIndex = 0; valueIndex < offsetResults[resultIndex].expectedOffsets.length; valueIndex += 1) { | |
| expect( | |
| testResult.expectedOffsets[valueIndex], | |
| offsetMoreOrLessEquals(dragOffsets[valueIndex]), | |
| reason: | |
| 'There is a difference in the expected and actual value of the ' | |
| '${valueIndex == 2 ? 'first' : valueIndex == 3 ? 'second' : 'third'}' | |
| ' split offset for the drag with:\n' | |
| 'Touch slop: ${testResult.slop}\n' | |
| 'Delta: ${testResult.dragDistance}\n' | |
| ); | |
| } | |
| dragOffsets.clear(); | |
| } | |
| }, | |
| ); | |
| testWidgets( | |
| 'WidgetTester.tap must respect buttons', | |
| (WidgetTester tester) async { | |
| final List<String> logs = <String>[]; | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Listener( | |
| onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'), | |
| onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'), | |
| onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'), | |
| child: const Text('test'), | |
| ), | |
| ), | |
| ); | |
| await tester.tap(find.text('test'), buttons: kSecondaryMouseButton); | |
| const String b = '$kSecondaryMouseButton'; | |
| for (int i = 0; i < logs.length; i++) { | |
| if (i == 0) { | |
| expect(logs[i], 'down $b'); | |
| } else if (i != logs.length - 1) { | |
| expect(logs[i], 'move $b'); | |
| } else { | |
| expect(logs[i], 'up 0'); | |
| } | |
| } | |
| }, | |
| ); | |
| testWidgets( | |
| 'WidgetTester.press must respect buttons', | |
| (WidgetTester tester) async { | |
| final List<String> logs = <String>[]; | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Listener( | |
| onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'), | |
| onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'), | |
| onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'), | |
| child: const Text('test'), | |
| ), | |
| ), | |
| ); | |
| await tester.press(find.text('test'), buttons: kSecondaryMouseButton); | |
| const String b = '$kSecondaryMouseButton'; | |
| expect(logs, equals(<String>['down $b'])); | |
| }, | |
| ); | |
| testWidgets( | |
| 'WidgetTester.longPress must respect buttons', | |
| (WidgetTester tester) async { | |
| final List<String> logs = <String>[]; | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Listener( | |
| onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'), | |
| onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'), | |
| onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'), | |
| child: const Text('test'), | |
| ), | |
| ), | |
| ); | |
| await tester.longPress(find.text('test'), buttons: kSecondaryMouseButton); | |
| await tester.pumpAndSettle(); | |
| const String b = '$kSecondaryMouseButton'; | |
| for (int i = 0; i < logs.length; i++) { | |
| if (i == 0) { | |
| expect(logs[i], 'down $b'); | |
| } else if (i != logs.length - 1) { | |
| expect(logs[i], 'move $b'); | |
| } else { | |
| expect(logs[i], 'up 0'); | |
| } | |
| } | |
| }, | |
| ); | |
| testWidgets( | |
| 'WidgetTester.drag must respect buttons', | |
| (WidgetTester tester) async { | |
| final List<String> logs = <String>[]; | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Listener( | |
| onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'), | |
| onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'), | |
| onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'), | |
| child: const Text('test'), | |
| ), | |
| ), | |
| ); | |
| await tester.drag(find.text('test'), const Offset(-150.0, 200.0), buttons: kSecondaryMouseButton); | |
| const String b = '$kSecondaryMouseButton'; | |
| for (int i = 0; i < logs.length; i++) { | |
| if (i == 0) { | |
| expect(logs[i], 'down $b'); | |
| } else if (i != logs.length - 1) { | |
| expect(logs[i], 'move $b'); | |
| } else { | |
| expect(logs[i], 'up 0'); | |
| } | |
| } | |
| }, | |
| ); | |
| testWidgets( | |
| 'WidgetTester.drag works with trackpad kind', | |
| (WidgetTester tester) async { | |
| final List<String> logs = <String>[]; | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Listener( | |
| onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'), | |
| onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'), | |
| onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'), | |
| onPointerPanZoomStart: (PointerPanZoomStartEvent event) => logs.add('panZoomStart'), | |
| onPointerPanZoomUpdate: (PointerPanZoomUpdateEvent event) => logs.add('panZoomUpdate ${event.pan}'), | |
| onPointerPanZoomEnd: (PointerPanZoomEndEvent event) => logs.add('panZoomEnd'), | |
| child: const Text('test'), | |
| ), | |
| ), | |
| ); | |
| await tester.drag(find.text('test'), const Offset(-150.0, 200.0), kind: PointerDeviceKind.trackpad); | |
| for (int i = 0; i < logs.length; i++) { | |
| if (i == 0) { | |
| expect(logs[i], 'panZoomStart'); | |
| } else if (i != logs.length - 1) { | |
| expect(logs[i], startsWith('panZoomUpdate')); | |
| } else { | |
| expect(logs[i], 'panZoomEnd'); | |
| } | |
| } | |
| }, | |
| ); | |
| testWidgets( | |
| 'WidgetTester.fling must respect buttons', | |
| (WidgetTester tester) async { | |
| final List<String> logs = <String>[]; | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Listener( | |
| onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'), | |
| onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'), | |
| onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'), | |
| child: const Text('test'), | |
| ), | |
| ), | |
| ); | |
| await tester.fling(find.text('test'), const Offset(-10.0, 0.0), 1000.0, buttons: kSecondaryMouseButton); | |
| await tester.pumpAndSettle(); | |
| const String b = '$kSecondaryMouseButton'; | |
| for (int i = 0; i < logs.length; i++) { | |
| if (i == 0) { | |
| expect(logs[i], 'down $b'); | |
| } else if (i != logs.length - 1) { | |
| expect(logs[i], 'move $b'); | |
| } else { | |
| expect(logs[i], 'up 0'); | |
| } | |
| } | |
| }, | |
| ); | |
| testWidgets( | |
| 'WidgetTester.fling produces strictly monotonically increasing timestamps, ' | |
| 'when given a large velocity', | |
| (WidgetTester tester) async { | |
| // Velocity trackers may misbehave if the `PointerMoveEvent`s' have the | |
| // same timestamp. This is more likely to happen when the velocity tracker | |
| // has a small sample size. | |
| final List<Duration> logs = <Duration>[]; | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Listener( | |
| onPointerMove: (PointerMoveEvent event) => logs.add(event.timeStamp), | |
| child: const Text('test'), | |
| ), | |
| ), | |
| ); | |
| await tester.fling(find.text('test'), const Offset(0.0, -50.0), 10000.0); | |
| await tester.pumpAndSettle(); | |
| for (int i = 0; i + 1 < logs.length; i += 1) { | |
| expect(logs[i + 1], greaterThan(logs[i])); | |
| } | |
| }); | |
| testWidgets( | |
| 'WidgetTester.timedDrag must respect buttons', | |
| (WidgetTester tester) async { | |
| final List<String> logs = <String>[]; | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Listener( | |
| onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'), | |
| onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'), | |
| onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'), | |
| child: const Text('test'), | |
| ), | |
| ), | |
| ); | |
| await tester.timedDrag( | |
| find.text('test'), | |
| const Offset(-200.0, 0.0), | |
| const Duration(seconds: 1), | |
| buttons: kSecondaryMouseButton, | |
| ); | |
| await tester.pumpAndSettle(); | |
| const String b = '$kSecondaryMouseButton'; | |
| for (int i = 0; i < logs.length; i++) { | |
| if (i == 0) { | |
| expect(logs[i], 'down $b'); | |
| } else if (i != logs.length - 1) { | |
| expect(logs[i], 'move $b'); | |
| } else { | |
| expect(logs[i], 'up 0'); | |
| } | |
| } | |
| }, | |
| ); | |
| testWidgets( | |
| 'WidgetTester.timedDrag uses correct pointer', | |
| (WidgetTester tester) async { | |
| final List<String> logs = <String>[]; | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Listener( | |
| onPointerDown: (PointerDownEvent event) => logs.add('down ${event.pointer}'), | |
| child: const Text('test'), | |
| ), | |
| ), | |
| ); | |
| await tester.timedDrag( | |
| find.text('test'), | |
| const Offset(-200.0, 0.0), | |
| const Duration(seconds: 1), | |
| buttons: kSecondaryMouseButton, | |
| ); | |
| await tester.pumpAndSettle(); | |
| await tester.timedDrag( | |
| find.text('test'), | |
| const Offset(200.0, 0.0), | |
| const Duration(seconds: 1), | |
| buttons: kSecondaryMouseButton, | |
| ); | |
| await tester.pumpAndSettle(); | |
| expect(logs.length, 2); | |
| expect(logs[0], isNotNull); | |
| expect(logs[1], isNotNull); | |
| expect(logs[1] != logs[0], isTrue); | |
| }, | |
| ); | |
| testWidgets( | |
| 'WidgetTester.tap appears in stack trace on error', | |
| (WidgetTester tester) async { | |
| // Regression test from https://github.com/flutter/flutter/pull/123946 | |
| await tester.pumpWidget( | |
| const MaterialApp(home: Scaffold(body: Text('target')))); | |
| final TestGesture gesture = await tester.startGesture( | |
| tester.getCenter(find.text('target')), pointer: 1); | |
| addTearDown(() => gesture.up()); | |
| Trace? stackTrace; | |
| try { | |
| await tester.tap(find.text('target'), pointer: 1); | |
| } on Error catch (e) { | |
| stackTrace = Trace.from(e.stackTrace!); | |
| } | |
| expect(stackTrace, isNotNull); | |
| final int tapFrame = stackTrace!.frames.indexWhere( | |
| (Frame frame) => frame.member == 'WidgetController.tap'); | |
| expect(tapFrame, greaterThanOrEqualTo(0)); | |
| expect(stackTrace.frames[tapFrame].package, 'flutter_test'); | |
| expect(stackTrace.frames[tapFrame+1].member, 'main.<fn>'); | |
| expect(stackTrace.frames[tapFrame+1].package, null); | |
| }, | |
| ); | |
| testWidgets( | |
| 'ensureVisible: scrolls to make widget visible', | |
| (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Scaffold( | |
| body: ListView.builder( | |
| itemCount: 20, | |
| shrinkWrap: true, | |
| itemBuilder: (BuildContext context, int i) => ListTile(title: Text('Item $i')), | |
| ), | |
| ), | |
| ), | |
| ); | |
| // Make sure widget isn't on screen | |
| expect(find.text('Item 15'), findsNothing); | |
| await tester.ensureVisible(find.text('Item 15', skipOffstage: false)); | |
| await tester.pumpAndSettle(); | |
| expect(find.text('Item 15'), findsOneWidget); | |
| }, | |
| ); | |
| group('scrollUntilVisible: scrolls to make unbuilt widget visible', () { | |
| testWidgets( | |
| 'Vertical', | |
| (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Scaffold( | |
| body: ListView.builder( | |
| itemCount: 50, | |
| shrinkWrap: true, | |
| itemBuilder: (BuildContext context, int i) => ListTile(title: Text('Item $i')), | |
| ), | |
| ), | |
| ), | |
| ); | |
| // Make sure widget isn't built yet. | |
| expect(find.text('Item 45', skipOffstage: false), findsNothing); | |
| await tester.scrollUntilVisible( | |
| find.text('Item 45', skipOffstage: false), | |
| 100, | |
| ); | |
| await tester.pumpAndSettle(); | |
| // Now the widget is on screen. | |
| expect(find.text('Item 45'), findsOneWidget); | |
| }, | |
| ); | |
| testWidgets( | |
| 'Horizontal', | |
| (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Scaffold( | |
| body: ListView.builder( | |
| itemCount: 50, | |
| shrinkWrap: true, | |
| scrollDirection: Axis.horizontal, | |
| // ListTile does not support horizontal list | |
| itemBuilder: (BuildContext context, int i) => Text('Item $i'), | |
| ), | |
| ), | |
| ), | |
| ); | |
| // Make sure widget isn't built yet. | |
| expect(find.text('Item 45', skipOffstage: false), findsNothing); | |
| await tester.scrollUntilVisible( | |
| find.text('Item 45', skipOffstage: false), | |
| 100, | |
| ); | |
| await tester.pumpAndSettle(); | |
| // Now the widget is on screen. | |
| expect(find.text('Item 45'), findsOneWidget); | |
| }, | |
| ); | |
| testWidgets( | |
| 'Fail', | |
| (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Scaffold( | |
| body: ListView.builder( | |
| itemCount: 50, | |
| shrinkWrap: true, | |
| itemBuilder: (BuildContext context, int i) => ListTile(title: Text('Item $i')), | |
| ), | |
| ), | |
| ), | |
| ); | |
| try { | |
| await tester.scrollUntilVisible( | |
| find.text('Item 55', skipOffstage: false), | |
| 100, | |
| ); | |
| } on StateError catch (e) { | |
| expect(e.message, 'No element'); | |
| } | |
| }, | |
| ); | |
| testWidgets('Drag Until Visible', (WidgetTester tester) async { | |
| // when there are two implicit [Scrollable], `scrollUntilVisible` is hard | |
| // to use. | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Scaffold( | |
| body: Column( | |
| children: <Widget>[ | |
| SizedBox(height: 200, child: ListView.builder( | |
| key: const Key('listView-a'), | |
| itemCount: 50, | |
| shrinkWrap: true, | |
| itemBuilder: (BuildContext context, int i) => ListTile(title: Text('Item a-$i')), | |
| )), | |
| const Divider(thickness: 5), | |
| Expanded(child: ListView.builder( | |
| key: const Key('listView-b'), | |
| itemCount: 50, | |
| shrinkWrap: true, | |
| itemBuilder: (BuildContext context, int i) => ListTile(title: Text('Item b-$i')), | |
| )), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| await tester.pumpAndSettle(); | |
| expect(find.byType(Scrollable), findsNWidgets(2)); | |
| // Make sure widget isn't built yet. | |
| expect(find.text('Item b-45', skipOffstage: false), findsNothing); | |
| await tester.dragUntilVisible( | |
| find.text('Item b-45', skipOffstage: false), | |
| find.byKey(const ValueKey<String>('listView-b')), | |
| const Offset(0, -100), | |
| ); | |
| await tester.pumpAndSettle(); | |
| // Now the widget is on screen. | |
| expect(find.text('Item b-45'), findsOneWidget); | |
| }); | |
| }); | |
| testWidgets('platformDispatcher exposes the platformDispatcher from binding', (WidgetTester tester) async { | |
| expect(tester.platformDispatcher, tester.binding.platformDispatcher); | |
| }); | |
| testWidgets('view exposes the implicitView from platformDispatcher', (WidgetTester tester) async { | |
| expect(tester.view, tester.platformDispatcher.implicitView); | |
| }); | |
| testWidgets('viewOf finds a view when the view is implicit', (WidgetTester tester) async { | |
| await tester.pumpWidget(const MaterialApp( | |
| home: Center( | |
| child: Text('Test'), | |
| ) | |
| )); | |
| expect(() => tester.viewOf(find.text('Test')), isNot(throwsA(anything))); | |
| expect(tester.viewOf(find.text('Test')), isA<TestFlutterView>()); | |
| }); | |
| group('SemanticsController', () { | |
| group('find', () { | |
| testWidgets('throws when there are no semantics', (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| const MaterialApp( | |
| home: Scaffold( | |
| body: Text('hello'), | |
| ), | |
| ), | |
| ); | |
| expect(() => tester.semantics.find(find.text('hello')), throwsStateError); | |
| }, semanticsEnabled: false); | |
| testWidgets('throws when there are multiple results from the finder', (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| const MaterialApp( | |
| home: Scaffold( | |
| body: Row( | |
| children: <Widget>[ | |
| Text('hello'), | |
| Text('hello'), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| expect(() => tester.semantics.find(find.text('hello')), throwsStateError); | |
| }); | |
| testWidgets('Returns the correct SemanticsData', (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Scaffold( | |
| body: OutlinedButton( | |
| onPressed: () { }, | |
| child: const Text('hello'), | |
| ), | |
| ), | |
| ), | |
| ); | |
| final SemanticsNode node = tester.semantics.find(find.text('hello')); | |
| final SemanticsData semantics = node.getSemanticsData(); | |
| expect(semantics.label, 'hello'); | |
| expect(semantics.hasAction(SemanticsAction.tap), true); | |
| expect(semantics.hasFlag(SemanticsFlag.isButton), true); | |
| }); | |
| testWidgets('Can enable semantics for tests via semanticsEnabled', (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Scaffold( | |
| body: OutlinedButton( | |
| onPressed: () { }, | |
| child: const Text('hello'), | |
| ), | |
| ), | |
| ), | |
| ); | |
| final SemanticsNode node = tester.semantics.find(find.text('hello')); | |
| final SemanticsData semantics = node.getSemanticsData(); | |
| expect(semantics.label, 'hello'); | |
| expect(semantics.hasAction(SemanticsAction.tap), true); | |
| expect(semantics.hasFlag(SemanticsFlag.isButton), true); | |
| }); | |
| testWidgets('Returns merged SemanticsData', (WidgetTester tester) async { | |
| const Key key = Key('test'); | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Scaffold( | |
| body: Semantics( | |
| label: 'A', | |
| child: Semantics( | |
| label: 'B', | |
| child: Semantics( | |
| key: key, | |
| label: 'C', | |
| child: Container(), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| final SemanticsNode node = tester.semantics.find(find.byKey(key)); | |
| final SemanticsData semantics = node.getSemanticsData(); | |
| expect(semantics.label, 'A\nB\nC'); | |
| }); | |
| testWidgets('Does not return partial semantics', (WidgetTester tester) async { | |
| final Key key = UniqueKey(); | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Scaffold( | |
| body: MergeSemantics( | |
| child: Semantics( | |
| container: true, | |
| label: 'A', | |
| child: Semantics( | |
| container: true, | |
| key: key, | |
| label: 'B', | |
| child: Container(), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| final SemanticsNode node = tester.semantics.find(find.byKey(key)); | |
| final SemanticsData semantics = node.getSemanticsData(); | |
| expect(semantics.label, 'A\nB'); | |
| }); | |
| }); | |
| group('simulatedTraversal', () { | |
| final List<Matcher> fullTraversalMatchers = <Matcher>[ | |
| containsSemantics(isHeader: true, label: 'Semantics Test'), | |
| containsSemantics(isTextField: true), | |
| containsSemantics(label: 'Off Switch'), | |
| containsSemantics(hasToggledState: true), | |
| containsSemantics(label: 'On Switch'), | |
| containsSemantics(hasToggledState: true, isToggled: true), | |
| containsSemantics(label: "Multiline\nIt's a\nmultiline label!"), | |
| containsSemantics(label: 'Slider'), | |
| containsSemantics(isSlider: true, value: '50%'), | |
| containsSemantics(label: 'Enabled Button'), | |
| containsSemantics(isButton: true, label: 'Tap'), | |
| containsSemantics(label: 'Disabled Button'), | |
| containsSemantics(isButton: true, label: "Don't Tap"), | |
| containsSemantics(label: 'Checked Radio'), | |
| containsSemantics(hasCheckedState: true, isChecked: true), | |
| containsSemantics(label: 'Unchecked Radio'), | |
| containsSemantics(hasCheckedState: true, isChecked: false), | |
| ]; | |
| testWidgets('produces expected traversal', (WidgetTester tester) async { | |
| await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); | |
| expect( | |
| tester.semantics.simulatedAccessibilityTraversal(), | |
| orderedEquals(fullTraversalMatchers)); | |
| }); | |
| testWidgets('starts traversal at semantics node for `start`', (WidgetTester tester) async { | |
| await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); | |
| // We're expecting the traversal to start where the slider is. | |
| final List<Matcher> expectedMatchers = <Matcher>[...fullTraversalMatchers]..removeRange(0, 8); | |
| expect( | |
| tester.semantics.simulatedAccessibilityTraversal(start: find.byType(Slider)), | |
| orderedEquals(expectedMatchers)); | |
| }); | |
| testWidgets('throws StateError if `start` not found in traversal', (WidgetTester tester) async { | |
| await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); | |
| // We look for a SingleChildScrollView since the view itself isn't | |
| // important for accessibility, so it won't show up in the traversal | |
| expect( | |
| () => tester.semantics.simulatedAccessibilityTraversal(start: find.byType(SingleChildScrollView)), | |
| throwsA(isA<StateError>()), | |
| ); | |
| }); | |
| testWidgets('ends traversal at semantics node for `end`', (WidgetTester tester) async { | |
| await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); | |
| // We're expecting the traversal to end where the slider is, inclusive. | |
| final Iterable<Matcher> expectedMatchers = <Matcher>[...fullTraversalMatchers].getRange(0, 9); | |
| expect( | |
| tester.semantics.simulatedAccessibilityTraversal(end: find.byType(Slider)), | |
| orderedEquals(expectedMatchers)); | |
| }); | |
| testWidgets('throws StateError if `end` not found in traversal', (WidgetTester tester) async { | |
| await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); | |
| // We look for a SingleChildScrollView since the view itself isn't | |
| // important for semantics, so it won't show up in the traversal | |
| expect( | |
| () => tester.semantics.simulatedAccessibilityTraversal(end: find.byType(SingleChildScrollView)), | |
| throwsA(isA<StateError>()), | |
| ); | |
| }); | |
| testWidgets('returns traversal between `start` and `end` if both are provided', (WidgetTester tester) async { | |
| await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); | |
| // We're expecting the traversal to start at the text field and end at the slider. | |
| final Iterable<Matcher> expectedMatchers = <Matcher>[...fullTraversalMatchers].getRange(1, 9); | |
| expect( | |
| tester.semantics.simulatedAccessibilityTraversal( | |
| start: find.byType(TextField), | |
| end: find.byType(Slider), | |
| ), | |
| orderedEquals(expectedMatchers)); | |
| }); | |
| testWidgets('can do fuzzy traversal match with `containsAllInOrder`', (WidgetTester tester) async { | |
| await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget())); | |
| // Grab a sample of the matchers to validate that not every matcher is | |
| // needed to validate a traversal when using `containsAllInOrder`. | |
| final Iterable<Matcher> expectedMatchers = <Matcher>[...fullTraversalMatchers] | |
| ..removeAt(0) | |
| ..removeLast() | |
| ..mapIndexed<Matcher?>((int i, Matcher element) => i.isEven ? element : null) | |
| .whereNotNull(); | |
| expect( | |
| tester.semantics.simulatedAccessibilityTraversal(), | |
| containsAllInOrder(expectedMatchers)); | |
| }); | |
| }); | |
| }); | |
| } | |
| class _SemanticsTestWidget extends StatelessWidget { | |
| const _SemanticsTestWidget(); | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| appBar: AppBar(title: const Text('Semantics Test')), | |
| body: SingleChildScrollView( | |
| child: Column( | |
| children: <Widget>[ | |
| const _SemanticsTestCard( | |
| label: 'TextField', | |
| widget: TextField(), | |
| ), | |
| _SemanticsTestCard( | |
| label: 'Off Switch', | |
| widget: Switch(value: false, onChanged: (bool value) {}), | |
| ), | |
| _SemanticsTestCard( | |
| label: 'On Switch', | |
| widget: Switch(value: true, onChanged: (bool value) {}), | |
| ), | |
| const _SemanticsTestCard( | |
| label: 'Multiline', | |
| widget: Text("It's a\nmultiline label!", maxLines: 2), | |
| ), | |
| _SemanticsTestCard( | |
| label: 'Slider', | |
| widget: Slider(value: .5, onChanged: (double value) {}), | |
| ), | |
| _SemanticsTestCard( | |
| label: 'Enabled Button', | |
| widget: TextButton(onPressed: () {}, child: const Text('Tap')), | |
| ), | |
| const _SemanticsTestCard( | |
| label: 'Disabled Button', | |
| widget: TextButton(onPressed: null, child: Text("Don't Tap")), | |
| ), | |
| _SemanticsTestCard( | |
| label: 'Checked Radio', | |
| widget: Radio<String>( | |
| value: 'checked', | |
| groupValue: 'checked', | |
| onChanged: (String? value) {}, | |
| ), | |
| ), | |
| _SemanticsTestCard( | |
| label: 'Unchecked Radio', | |
| widget: Radio<String>( | |
| value: 'unchecked', | |
| groupValue: 'checked', | |
| onChanged: (String? value) {}, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class _SemanticsTestCard extends StatelessWidget { | |
| const _SemanticsTestCard({required this.label, required this.widget}); | |
| final String label; | |
| final Widget widget; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Card( | |
| child: ListTile( | |
| title: Text(label), | |
| trailing: SizedBox(width: 200, child: widget), | |
| ), | |
| ); | |
| } | |
| } |