| // 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 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)); |
| }); |
| |
| 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>[containsSemantics(label: '1\n2\n3')], |
| ), |
| ); |
| }); |
| }); |
| |
| 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 { |
| bool 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 { |
| bool 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(), |
| containsSemantics(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(), |
| containsSemantics(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(), |
| containsSemantics(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(), |
| containsSemantics(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(), |
| containsSemantics(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(), |
| containsSemantics(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(), |
| containsSemantics(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(), |
| containsSemantics(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 { |
| bool 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 { |
| bool 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( |
| tester.semantics.find(find.byType(Slider)).value, |
| equals(expected), |
| ); |
| }); |
| |
| testWidgets('showOnScreen sends showOnScreen action', (WidgetTester tester) async { |
| await tester.pumpWidget(MaterialApp( |
| home: ListView( |
| controller: ScrollController(initialScrollOffset: 50), |
| 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'), |
| containsSemantics(isHidden:true), |
| ); |
| |
| tester.semantics.showOnScreen(find.semantics.byLabel('Test')); |
| await tester.pump(); |
| |
| expect( |
| tester.semantics.find(find.text('Test')), |
| containsSemantics(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 String text = 'This is some text.'; |
| int currentIndex = text.length; |
| final TextEditingController controller = TextEditingController(text: text); |
| 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 String text = 'This is some text.'; |
| int currentIndex = text.length; |
| final TextEditingController controller = TextEditingController(text: text); |
| 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 String expectedText = 'This is some text.'; |
| final TextEditingController controller = TextEditingController(); |
| 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 String text = 'This is some text.'; |
| const int expectedStart = text.length - 8; |
| const int expectedEnd = text.length - 4; |
| final TextEditingController controller = TextEditingController(text: text); |
| 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 { |
| bool 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 { |
| bool 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 { |
| bool 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 { |
| bool 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 { |
| bool 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 = Duration(seconds: 3); |
| final Duration 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.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 CustomSemanticsAction customAction = CustomSemanticsAction(label: 'test'); |
| bool 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 List<String > tapLogs = <String>[]; |
| final TapGestureRecognizer tapA = TapGestureRecognizer()..onTap = () { tapLogs.add('A'); }; |
| final TapGestureRecognizer tapB = TapGestureRecognizer()..onTap = () { tapLogs.add('B'); }; |
| final TapGestureRecognizer 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: '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), |
| ), |
| ); |
| } |
| } |
| |
| 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); |
| }); |
| } |
| } |