mirror of
https://github.com/flutter/flutter
synced 2024-09-17 23:31:55 +00:00
fdb17ab069
Reland #143334
1880 lines
61 KiB
Dart
1880 lines
61 KiB
Dart
// 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(label: 'Text Field'),
|
|
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, 9);
|
|
|
|
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, 10);
|
|
|
|
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, 10);
|
|
|
|
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)
|
|
.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: '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);
|
|
});
|
|
}
|
|
}
|