| // 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/rendering.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 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 dragOffsets = <Offset>[]; | |
| await tester.pumpWidget( | |
| Listener( | |
| onPointerMove: (PointerMoveEvent event) { | |
| dragOffsets.add(event.delta); | |
| }, | |
| child: const Text('test', textDirection: TextDirection.ltr), | |
| ), | |
| ); | |
| for (var 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 ( | |
| var 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 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 b = '$kSecondaryMouseButton'; | |
| for (var 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 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 b = '$kSecondaryMouseButton'; | |
| expect(logs, equals(<String>['down $b'])); | |
| }); | |
| testWidgets('WidgetTester.longPress must respect buttons', (WidgetTester tester) async { | |
| final 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 b = '$kSecondaryMouseButton'; | |
| for (var 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 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 b = '$kSecondaryMouseButton'; | |
| for (var 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 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 (var 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 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 b = '$kSecondaryMouseButton'; | |
| for (var 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 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 (var 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 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 b = '$kSecondaryMouseButton'; | |
| for (var 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 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 Padding( | |
| padding: EdgeInsets.all(4), | |
| child: ColoredBox( | |
| color: Color(0xFF000000), | |
| child: SizedBox(height: 5, width: double.infinity), | |
| ), | |
| ), | |
| 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); | |
| }); | |
| }); | |
| // Regression test for https://github.com/flutter/flutter/issues/143921. | |
| testWidgets('WidgetTester.scrollUntilVisible should work together with onTap', ( | |
| WidgetTester tester, | |
| ) async { | |
| const itemCount = 20; | |
| Widget buildFrame(bool hasOnTap) { | |
| return MaterialApp( | |
| home: Scaffold( | |
| body: ListView.builder( | |
| key: ValueKey<bool>(hasOnTap), // Trigger a rebuild. | |
| itemCount: itemCount, | |
| itemBuilder: (BuildContext context, int index) { | |
| return ListTile(onTap: hasOnTap ? () {} : null, title: Text('$index')); | |
| }, | |
| ), | |
| ), | |
| ); | |
| } | |
| final Finder target = find.text('${itemCount - 1}'); | |
| final Finder scrollable = find.byType(Scrollable); | |
| // Scroll without onTap. | |
| await tester.pumpWidget(buildFrame(false)); | |
| await tester.pumpAndSettle(); | |
| expect(target, findsNothing); | |
| await tester.scrollUntilVisible(target, 20, scrollable: scrollable, continuous: true); | |
| expect(target, findsOneWidget); | |
| // Scroll with onTap. | |
| await tester.pumpWidget(buildFrame(true)); | |
| await tester.pumpAndSettle(); | |
| expect(target, findsNothing); | |
| await tester.scrollUntilVisible(target, 20, scrollable: scrollable, continuous: true); | |
| expect(target, 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('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 fullTraversalMatchers = <Matcher>[ | |
| isSemantics(isHeader: true, label: 'Semantics Test'), | |
| isSemantics(label: 'Text Field'), | |
| isSemantics(isTextField: true), | |
| isSemantics(label: 'Off Switch'), | |
| isSemantics(hasToggledState: true), | |
| isSemantics(label: 'On Switch'), | |
| isSemantics(hasToggledState: true, isToggled: true), | |
| isSemantics(label: "Multiline\nIt's a\nmultiline label!"), | |
| isSemantics(label: 'Slider'), | |
| isSemantics(isSlider: true, value: '50%'), | |
| isSemantics(label: 'Enabled Button'), | |
| isSemantics(isButton: true, label: 'Tap'), | |
| isSemantics(label: 'Disabled Button'), | |
| isSemantics(isButton: true, label: "Don't Tap"), | |
| isSemantics(label: 'Checked Radio'), | |
| isSemantics(hasCheckedState: true, isChecked: true), | |
| isSemantics(label: 'Unchecked Radio'), | |
| isSemantics(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 expectedMatchers = <Matcher>[...fullTraversalMatchers]..removeRange(0, 8); | |
| expect( | |
| tester.semantics.simulatedAccessibilityTraversal(start: find.byType(Slider)), | |
| orderedEquals(expectedMatchers), | |
| ); | |
| }); | |
| testWidgets('simulatedAccessibilityTraversal end Index supports empty traversal', ( | |
| WidgetTester tester, | |
| ) async { | |
| await tester.pumpWidget( | |
| const MaterialApp( | |
| home: Center( | |
| child: Column(), // No nodes! | |
| ), | |
| ), | |
| ); | |
| expect( | |
| tester.semantics.simulatedAccessibilityTraversal().map( | |
| (SemanticsNode node) => node.label, | |
| ), | |
| <String>[], | |
| ); | |
| }); | |
| testWidgets('starts traversal at semantics node for `startNode`', ( | |
| WidgetTester tester, | |
| ) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Center( | |
| child: Column( | |
| children: <Widget>[ | |
| for (int c = 0; c < 5; c++) Semantics(container: true, child: Text('Child$c')), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| expect( | |
| tester.semantics | |
| .simulatedAccessibilityTraversal(startNode: find.semantics.byLabel('Child1')) | |
| .map((SemanticsNode node) => node.label), | |
| <String>['Child1', 'Child2', 'Child3', 'Child4'], | |
| ); | |
| }); | |
| 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('throws StateError if `startNode` not found in traversal', ( | |
| WidgetTester tester, | |
| ) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Center( | |
| child: Column( | |
| children: <Widget>[ | |
| for (int c = 0; c < 5; c++) Semantics(container: true, child: Text('Child$c')), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| expect( | |
| () => tester.semantics.simulatedAccessibilityTraversal( | |
| startNode: find.semantics.byLabel('Child20'), | |
| ), | |
| 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('ends traversal at semantics node for `endNode`', (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Center( | |
| child: Column( | |
| children: <Widget>[ | |
| for (int c = 0; c < 5; c++) Semantics(container: true, child: Text('Child$c')), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| expect( | |
| tester.semantics | |
| .simulatedAccessibilityTraversal(endNode: find.semantics.byLabel('Child1')) | |
| .map((SemanticsNode node) => node.label), | |
| <String>['Child0', 'Child1'], | |
| ); | |
| }); | |
| 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('throws StateError if `endNode` not found in traversal', ( | |
| WidgetTester tester, | |
| ) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Center( | |
| child: Column( | |
| children: <Widget>[ | |
| for (int c = 0; c < 5; c++) Semantics(container: true, child: Text('Child$c')), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| expect( | |
| () => tester.semantics.simulatedAccessibilityTraversal( | |
| endNode: find.semantics.byLabel('Child20'), | |
| ), | |
| 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('returns traversal between `startNode` and `endNode` if both are provided', ( | |
| WidgetTester tester, | |
| ) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Center( | |
| child: Column( | |
| children: <Widget>[ | |
| for (int c = 0; c < 5; c++) Semantics(container: true, child: Text('Child$c')), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| expect( | |
| tester.semantics | |
| .simulatedAccessibilityTraversal( | |
| startNode: find.semantics.byLabel('Child1'), | |
| endNode: find.semantics.byLabel('Child3'), | |
| ) | |
| .map((SemanticsNode node) => node.label), | |
| <String>['Child1', 'Child2', 'Child3'], | |
| ); | |
| }); | |
| 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).nonNulls; | |
| expect( | |
| tester.semantics.simulatedAccessibilityTraversal(), | |
| containsAllInOrder(expectedMatchers), | |
| ); | |
| }); | |
| testWidgets('merging node should not be visited', (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: MergeSemantics( | |
| child: Column( | |
| children: <Widget>[ | |
| Semantics(container: true, child: const Text('1')), | |
| Semantics(container: true, child: const Text('2')), | |
| Semantics(container: true, child: const Text('3')), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| expect( | |
| tester.semantics.simulatedAccessibilityTraversal(), | |
| orderedEquals(<Matcher>[isSemantics(label: '1\n2\n3')]), | |
| ); | |
| }); | |
| testWidgets('a node with only a tooltip is important for accessibility', ( | |
| WidgetTester tester, | |
| ) async { | |
| await tester.pumpWidget( | |
| const MaterialApp( | |
| home: Tooltip(message: 'My tooltip', child: SizedBox()), | |
| ), | |
| ); | |
| expect( | |
| tester.semantics.simulatedAccessibilityTraversal(), | |
| contains(isSemantics(tooltip: 'My tooltip')), | |
| ); | |
| }); | |
| }); | |
| group('actions', () { | |
| testWidgets('performAction with unsupported action throws StateError', ( | |
| WidgetTester tester, | |
| ) async { | |
| await tester.pumpWidget(Semantics(onTap: () {})); | |
| expect( | |
| () => tester.semantics.performAction( | |
| find.semantics.byLabel('Test'), | |
| SemanticsAction.dismiss, | |
| ), | |
| throwsStateError, | |
| ); | |
| }); | |
| testWidgets('tap causes semantic tap', (WidgetTester tester) async { | |
| var invoked = false; | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: TextButton(onPressed: () => invoked = true, child: const Text('Test Button')), | |
| ), | |
| ); | |
| tester.semantics.tap(find.semantics.byAction(SemanticsAction.tap)); | |
| expect(invoked, isTrue); | |
| }); | |
| testWidgets('longPress causes semantic long press', (WidgetTester tester) async { | |
| var invoked = false; | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: TextButton( | |
| onPressed: () {}, | |
| onLongPress: () => invoked = true, | |
| child: const Text('Test Button'), | |
| ), | |
| ), | |
| ); | |
| tester.semantics.longPress(find.semantics.byAction(SemanticsAction.longPress)); | |
| expect(invoked, isTrue); | |
| }); | |
| testWidgets('scrollLeft and scrollRight scroll left and right respectively', ( | |
| WidgetTester tester, | |
| ) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: ListView( | |
| scrollDirection: Axis.horizontal, | |
| children: <Widget>[ | |
| SizedBox(height: 40, width: tester.binding.window.physicalSize.width * 1.5), | |
| ], | |
| ), | |
| ), | |
| ); | |
| expect( | |
| find.semantics.scrollable(), | |
| isSemantics(hasScrollLeftAction: true, hasScrollRightAction: false), | |
| reason: | |
| 'When not yet scrolled, a scrollview should only be able to support left scrolls.', | |
| ); | |
| tester.semantics.scrollLeft(); | |
| await tester.pump(); | |
| expect( | |
| find.semantics.scrollable(), | |
| isSemantics(hasScrollLeftAction: true, hasScrollRightAction: true), | |
| reason: 'When partially scrolled, a scrollview should be able to support both left and right scrolls.', | |
| ); | |
| // This will scroll the listview until it's completely scrolled to the right. | |
| final SemanticsFinder leftScrollable = find.semantics.byAction(SemanticsAction.scrollLeft); | |
| while (leftScrollable.tryEvaluate()) { | |
| tester.semantics.scrollLeft(scrollable: leftScrollable); | |
| await tester.pump(); | |
| } | |
| expect( | |
| find.semantics.scrollable(), | |
| isSemantics(hasScrollLeftAction: false, hasScrollRightAction: true), | |
| reason: 'When fully scrolled, a scrollview should only support right scrolls.', | |
| ); | |
| tester.semantics.scrollRight(); | |
| await tester.pump(); | |
| expect( | |
| find.semantics.scrollable(), | |
| isSemantics(hasScrollLeftAction: true, hasScrollRightAction: true), | |
| reason: 'When partially scrolled, a scrollview should be able to support both left and right scrolls.', | |
| ); | |
| }); | |
| testWidgets('scrollUp and scrollDown scrolls up and down respectively', ( | |
| WidgetTester tester, | |
| ) async { | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: ListView( | |
| children: <Widget>[ | |
| SizedBox(height: tester.binding.window.physicalSize.height * 1.5, width: 40), | |
| ], | |
| ), | |
| ), | |
| ); | |
| expect( | |
| find.semantics.scrollable(), | |
| isSemantics(hasScrollUpAction: true, hasScrollDownAction: false), | |
| reason: | |
| 'When not yet scrolled, a scrollview should only be able to support left scrolls.', | |
| ); | |
| tester.semantics.scrollUp(); | |
| await tester.pump(); | |
| expect( | |
| find.semantics.scrollable(), | |
| isSemantics(hasScrollUpAction: true, hasScrollDownAction: true), | |
| reason: 'When partially scrolled, a scrollview should be able to support both left and right scrolls.', | |
| ); | |
| // This will scroll the listview until it's completely scrolled to the right. | |
| final SemanticsFinder upScrollable = find.semantics.byAction(SemanticsAction.scrollUp); | |
| while (upScrollable.tryEvaluate()) { | |
| tester.semantics.scrollUp(scrollable: upScrollable); | |
| await tester.pump(); | |
| } | |
| expect( | |
| find.semantics.scrollable(), | |
| isSemantics(hasScrollUpAction: false, hasScrollDownAction: true), | |
| reason: 'When fully scrolled, a scrollview should only support right scrolls.', | |
| ); | |
| tester.semantics.scrollDown(); | |
| await tester.pump(); | |
| expect( | |
| find.semantics.scrollable(), | |
| isSemantics(hasScrollUpAction: true, hasScrollDownAction: true), | |
| reason: 'When partially scrolled, a scrollview should be able to support both left and right scrolls.', | |
| ); | |
| }); | |
| testWidgets('increase causes semantic increase', (WidgetTester tester) async { | |
| var invoked = false; | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Material( | |
| child: _StatefulSlider( | |
| initialValue: 0, | |
| onChanged: (double _) { | |
| invoked = true; | |
| }, | |
| ), | |
| ), | |
| ), | |
| ); | |
| final SemanticsFinder sliderFinder = find.semantics.byFlag(SemanticsFlag.isSlider); | |
| final String expected = sliderFinder.evaluate().single.increasedValue; | |
| tester.semantics.increase(sliderFinder); | |
| await tester.pumpAndSettle(); | |
| expect(invoked, isTrue); | |
| expect( | |
| find.semantics.byFlag(SemanticsFlag.isSlider).evaluate().single.value, | |
| equals(expected), | |
| ); | |
| }); | |
| testWidgets('decrease causes semantic decrease', (WidgetTester tester) async { | |
| var invoked = false; | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Material( | |
| child: _StatefulSlider( | |
| initialValue: 1, | |
| onChanged: (double _) { | |
| invoked = true; | |
| }, | |
| ), | |
| ), | |
| ), | |
| ); | |
| final SemanticsFinder sliderFinder = find.semantics.byFlag(SemanticsFlag.isSlider); | |
| final String expected = sliderFinder.evaluate().single.decreasedValue; | |
| tester.semantics.decrease(sliderFinder); | |
| await tester.pumpAndSettle(); | |
| expect(invoked, isTrue); | |
| expect( | |
| find.semantics.byFlag(SemanticsFlag.isSlider).evaluate().single.value, | |
| equals(expected), | |
| ); | |
| }); | |
| testWidgets('showOnScreen sends showOnScreen action', (WidgetTester tester) async { | |
| final controller = ScrollController(initialScrollOffset: 50); | |
| addTearDown(controller.dispose); | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: ListView( | |
| controller: controller, | |
| children: <Widget>[ | |
| const MergeSemantics(child: SizedBox(height: 40, child: Text('Test'))), | |
| SizedBox(width: 40, height: tester.binding.window.physicalSize.height * 1.5), | |
| ], | |
| ), | |
| ), | |
| ); | |
| expect(find.semantics.byLabel('Test'), isSemantics(isHidden: true)); | |
| tester.semantics.showOnScreen(find.semantics.byLabel('Test')); | |
| await tester.pump(); | |
| expect(tester.semantics.find(find.text('Test')), isSemantics(isHidden: false)); | |
| }); | |
| testWidgets( | |
| 'actions for moving the cursor without modifying selection can move the cursor forward and back by character and word', | |
| (WidgetTester tester) async { | |
| const text = 'This is some text.'; | |
| int currentIndex = text.length; | |
| final controller = TextEditingController(text: text); | |
| addTearDown(controller.dispose); | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Material(child: TextField(controller: controller)), | |
| ), | |
| ); | |
| void expectUnselectedIndex(int expectedIndex) { | |
| expect(controller.selection.start, equals(expectedIndex)); | |
| expect(controller.selection.end, equals(expectedIndex)); | |
| } | |
| final SemanticsFinder finder = find.semantics.byValue(text); | |
| // Get focus onto the text field | |
| tester.semantics.tap(finder); | |
| await tester.pump(); | |
| tester.semantics.moveCursorBackwardByCharacter(finder); | |
| await tester.pump(); | |
| expectUnselectedIndex(currentIndex - 1); | |
| currentIndex -= 1; | |
| tester.semantics.moveCursorBackwardByWord(finder); | |
| await tester.pump(); | |
| expectUnselectedIndex(currentIndex - 4); | |
| currentIndex -= 4; | |
| tester.semantics.moveCursorBackwardByWord(finder); | |
| await tester.pump(); | |
| expectUnselectedIndex(currentIndex - 5); | |
| currentIndex -= 5; | |
| tester.semantics.moveCursorForwardByCharacter(finder); | |
| await tester.pump(); | |
| expectUnselectedIndex(currentIndex + 1); | |
| currentIndex += 1; | |
| tester.semantics.moveCursorForwardByWord(finder); | |
| await tester.pump(); | |
| expectUnselectedIndex(currentIndex + 4); | |
| currentIndex += 4; | |
| }, | |
| ); | |
| testWidgets( | |
| 'actions for moving the cursor with modifying selection can update the selection forward and back by character and word', | |
| (WidgetTester tester) async { | |
| const text = 'This is some text.'; | |
| int currentIndex = text.length; | |
| final controller = TextEditingController(text: text); | |
| addTearDown(controller.dispose); | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Material(child: TextField(controller: controller)), | |
| ), | |
| ); | |
| void expectSelectedIndex(int start) { | |
| expect(controller.selection.start, equals(start)); | |
| expect(controller.selection.end, equals(text.length)); | |
| } | |
| final SemanticsFinder finder = find.semantics.byValue(text); | |
| // Get focus onto the text field | |
| tester.semantics.tap(finder); | |
| await tester.pump(); | |
| tester.semantics.moveCursorBackwardByCharacter(finder, shouldModifySelection: true); | |
| await tester.pump(); | |
| expectSelectedIndex(currentIndex - 1); | |
| currentIndex -= 1; | |
| tester.semantics.moveCursorBackwardByWord(finder, shouldModifySelection: true); | |
| await tester.pump(); | |
| expectSelectedIndex(currentIndex - 4); | |
| currentIndex -= 4; | |
| tester.semantics.moveCursorBackwardByWord(finder, shouldModifySelection: true); | |
| await tester.pump(); | |
| expectSelectedIndex(currentIndex - 5); | |
| currentIndex -= 5; | |
| tester.semantics.moveCursorForwardByCharacter(finder, shouldModifySelection: true); | |
| await tester.pump(); | |
| expectSelectedIndex(currentIndex + 1); | |
| currentIndex += 1; | |
| tester.semantics.moveCursorForwardByWord(finder, shouldModifySelection: true); | |
| await tester.pump(); | |
| expectSelectedIndex(currentIndex + 4); | |
| currentIndex += 4; | |
| }, | |
| ); | |
| testWidgets('setText causes semantics to set the text', (WidgetTester tester) async { | |
| const expectedText = 'This is some text.'; | |
| final controller = TextEditingController(); | |
| addTearDown(controller.dispose); | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Material(child: TextField(controller: controller)), | |
| ), | |
| ); | |
| final SemanticsFinder finder = find.semantics.byFlag(SemanticsFlag.isTextField); | |
| tester.semantics.tap(finder); | |
| await tester.pump(); | |
| tester.semantics.setText(finder, expectedText); | |
| await tester.pump(); | |
| expect(controller.text, equals(expectedText)); | |
| }); | |
| testWidgets('setSelection causes semantics to select text', (WidgetTester tester) async { | |
| const text = 'This is some text.'; | |
| const int expectedStart = text.length - 8; | |
| const int expectedEnd = text.length - 4; | |
| final controller = TextEditingController(text: text); | |
| addTearDown(controller.dispose); | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Material(child: TextField(controller: controller)), | |
| ), | |
| ); | |
| final SemanticsFinder finder = find.semantics.byFlag(SemanticsFlag.isTextField); | |
| tester.semantics.tap(finder); | |
| await tester.pump(); | |
| tester.semantics.setSelection(finder, base: expectedStart, extent: expectedEnd); | |
| await tester.pump(); | |
| expect(controller.selection.start, equals(expectedStart)); | |
| expect(controller.selection.end, equals(expectedEnd)); | |
| }); | |
| testWidgets('copy sends semantic copy', (WidgetTester tester) async { | |
| var invoked = false; | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Semantics(label: 'test', onCopy: () => invoked = true), | |
| ), | |
| ); | |
| tester.semantics.copy(find.semantics.byLabel('test')); | |
| expect(invoked, isTrue); | |
| }); | |
| testWidgets('cut sends semantic cut', (WidgetTester tester) async { | |
| var invoked = false; | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Semantics(label: 'test', onCut: () => invoked = true), | |
| ), | |
| ); | |
| tester.semantics.cut(find.semantics.byLabel('test')); | |
| expect(invoked, isTrue); | |
| }); | |
| testWidgets('paste sends semantic paste', (WidgetTester tester) async { | |
| var invoked = false; | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Semantics(label: 'test', onPaste: () => invoked = true), | |
| ), | |
| ); | |
| tester.semantics.paste(find.semantics.byLabel('test')); | |
| expect(invoked, isTrue); | |
| }); | |
| testWidgets('didGainAccessibilityFocus causes semantic focus on node', ( | |
| WidgetTester tester, | |
| ) async { | |
| var invoked = false; | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Semantics(label: 'test', onDidGainAccessibilityFocus: () => invoked = true), | |
| ), | |
| ); | |
| tester.semantics.didGainAccessibilityFocus(find.semantics.byLabel('test')); | |
| expect(invoked, isTrue); | |
| }); | |
| testWidgets('didLoseAccessibility causes semantic focus to be lost', ( | |
| WidgetTester tester, | |
| ) async { | |
| var invoked = false; | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Semantics(label: 'test', onDidLoseAccessibilityFocus: () => invoked = true), | |
| ), | |
| ); | |
| tester.semantics.didLoseAccessibilityFocus(find.semantics.byLabel('test')); | |
| expect(invoked, isTrue); | |
| }); | |
| testWidgets('dismiss sends semantic dismiss', (WidgetTester tester) async { | |
| final GlobalKey key = GlobalKey(); | |
| const duration = Duration(seconds: 3); | |
| final halfDuration = Duration(milliseconds: (duration.inMilliseconds / 2).floor()); | |
| late SnackBarClosedReason reason; | |
| await tester.pumpWidget(MaterialApp(home: Scaffold(key: key))); | |
| final ScaffoldMessengerState messenger = ScaffoldMessenger.of(key.currentContext!); | |
| messenger | |
| .showSnackBar( | |
| const SnackBar(content: SizedBox(height: 40, width: 300), duration: duration), | |
| ) | |
| .closed | |
| // ignore: unawaited_futures | |
| .then((SnackBarClosedReason result) => reason = result); | |
| await tester.pumpFrames(tester.widget(find.byType(MaterialApp)), halfDuration); | |
| tester.semantics.dismiss(find.semantics.byAction(SemanticsAction.dismiss)); | |
| await tester.pumpAndSettle(); | |
| expect(reason, equals(SnackBarClosedReason.dismiss)); | |
| }); | |
| testWidgets('customAction invokes appropriate custom action', (WidgetTester tester) async { | |
| const customAction = CustomSemanticsAction(label: 'test'); | |
| var invoked = false; | |
| await tester.pumpWidget( | |
| MaterialApp( | |
| home: Semantics( | |
| label: 'test', | |
| customSemanticsActions: <CustomSemanticsAction, void Function()>{ | |
| customAction: () => invoked = true, | |
| }, | |
| ), | |
| ), | |
| ); | |
| tester.semantics.customAction(find.semantics.byLabel('test'), customAction); | |
| await tester.pump(); | |
| expect(invoked, isTrue); | |
| }); | |
| }); | |
| }); | |
| group('WidgetTester.tapOnText', () { | |
| final tapLogs = <String>[]; | |
| final tapA = TapGestureRecognizer() | |
| ..onTap = () { | |
| tapLogs.add('A'); | |
| }; | |
| final tapB = TapGestureRecognizer() | |
| ..onTap = () { | |
| tapLogs.add('B'); | |
| }; | |
| final tapC = TapGestureRecognizer() | |
| ..onTap = () { | |
| tapLogs.add('C'); | |
| }; | |
| tearDown(tapLogs.clear); | |
| tearDownAll(() { | |
| tapA.dispose(); | |
| tapB.dispose(); | |
| tapC.dispose(); | |
| }); | |
| testWidgets('basic test', (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Text.rich(TextSpan(text: 'match', recognizer: tapA)), | |
| ), | |
| ); | |
| await tester.tapOnText(find.textRange.ofSubstring('match')); | |
| expect(tapLogs, <String>['A']); | |
| }); | |
| testWidgets('partially obstructed: find a hit-testable Offset', (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Stack( | |
| fit: StackFit.expand, | |
| children: <Widget>[ | |
| Positioned( | |
| left: 100.0 - 9 * 10.0, // Only the last character is visible. | |
| child: Text.rich( | |
| TextSpan( | |
| text: 'text match', | |
| style: const TextStyle(fontSize: 10), | |
| recognizer: tapA, | |
| ), | |
| ), | |
| ), | |
| const Positioned( | |
| left: 0.0, | |
| right: 100.0, | |
| child: MetaData(behavior: HitTestBehavior.opaque), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| await expectLater( | |
| () => tester.tapOnText(find.textRange.ofSubstring('text match')), | |
| returnsNormally, | |
| ); | |
| }); | |
| testWidgets('multiline text partially obstructed: find a hit-testable Offset', ( | |
| WidgetTester tester, | |
| ) async { | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Stack( | |
| fit: StackFit.expand, | |
| children: <Widget>[ | |
| Positioned( | |
| width: 100.0, | |
| top: 23.0, | |
| left: 0.0, | |
| child: Text.rich( | |
| TextSpan( | |
| style: const TextStyle(fontSize: 10), | |
| children: <InlineSpan>[ | |
| TextSpan(text: 'AAAAAAAAA ', recognizer: tapA), | |
| TextSpan(text: 'BBBBBBBBB ', recognizer: tapB), // The only visible line | |
| TextSpan(text: 'CCCCCCCCC ', recognizer: tapC), | |
| ], | |
| ), | |
| ), | |
| ), | |
| const Positioned( | |
| top: 23.0, // Some random offset to test the global to local Offset conversion | |
| left: 0.0, | |
| right: 0.0, | |
| height: 10.0, | |
| child: MetaData(behavior: HitTestBehavior.opaque, child: SizedBox.expand()), | |
| ), | |
| const Positioned( | |
| top: 43.0, | |
| left: 0.0, | |
| right: 0.0, | |
| height: 10.0, | |
| child: MetaData(behavior: HitTestBehavior.opaque, child: SizedBox.expand()), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| await tester.tapOnText(find.textRange.ofSubstring('AAAAAAAAA BBBBBBBBB CCCCCCCCC ')); | |
| expect(tapLogs, <String>['B']); | |
| }); | |
| testWidgets('error message: no matching text', (WidgetTester tester) async { | |
| await tester.pumpWidget(const SizedBox()); | |
| await expectLater( | |
| () => tester.tapOnText(find.textRange.ofSubstring('nonexistent')), | |
| throwsA( | |
| isFlutterError.having( | |
| (FlutterError error) => error.message, | |
| 'message', | |
| contains('Found 0 non-overlapping TextRanges that match the Pattern "nonexistent": []'), | |
| ), | |
| ), | |
| ); | |
| }); | |
| testWidgets('error message: too many matches', (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Text.rich( | |
| TextSpan( | |
| text: 'match', | |
| recognizer: tapA, | |
| children: <InlineSpan>[TextSpan(text: 'another match', recognizer: tapB)], | |
| ), | |
| ), | |
| ), | |
| ); | |
| await expectLater( | |
| () => tester.tapOnText(find.textRange.ofSubstring('match')), | |
| throwsA( | |
| isFlutterError.having( | |
| (FlutterError error) => error.message, | |
| 'message', | |
| stringContainsInOrder(<String>[ | |
| 'Found 2 non-overlapping TextRanges that match the Pattern "match"', | |
| 'TextRange(start: 0, end: 5)', | |
| 'TextRange(start: 13, end: 18)', | |
| 'The "tapOnText" method needs a single non-empty TextRange.', | |
| ]), | |
| ), | |
| ), | |
| ); | |
| }); | |
| testWidgets('error message: not hit-testable', (WidgetTester tester) async { | |
| await tester.pumpWidget( | |
| Directionality( | |
| textDirection: TextDirection.ltr, | |
| child: Stack( | |
| fit: StackFit.expand, | |
| children: <Widget>[ | |
| Text.rich(TextSpan(text: 'match', recognizer: tapA)), | |
| const MetaData(behavior: HitTestBehavior.opaque), | |
| ], | |
| ), | |
| ), | |
| ); | |
| await expectLater( | |
| () => tester.tapOnText(find.textRange.ofSubstring('match')), | |
| throwsA( | |
| isFlutterError.having( | |
| (FlutterError error) => error.message, | |
| 'message', | |
| stringContainsInOrder(<String>[ | |
| 'The finder used was: A finder that searches for non-overlapping TextRanges that match the Pattern "match".', | |
| 'Found a matching substring in a static text widget, within TextRange(start: 0, end: 5).', | |
| 'But the "tapOnText" method could not find a hit-testable Offset with in that text range.', | |
| ]), | |
| ), | |
| ), | |
| ); | |
| }); | |
| }); | |
| } | |
| 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: 'Text Field', 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), | |
| ), | |
| ); | |
| } | |
| } | |
| class _StatefulSlider extends StatefulWidget { | |
| const _StatefulSlider({required this.initialValue, required this.onChanged}); | |
| final double initialValue; | |
| final ValueChanged<double> onChanged; | |
| @override | |
| _StatefulSliderState createState() => _StatefulSliderState(); | |
| } | |
| class _StatefulSliderState extends State<_StatefulSlider> { | |
| double _value = 0; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _value = widget.initialValue; | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Slider( | |
| value: _value, | |
| onChanged: (double value) { | |
| setState(() { | |
| _value = value; | |
| }); | |
| widget.onChanged(value); | |
| }, | |
| ); | |
| } | |
| } |