mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
WIP Commits separated as follows: - Update lints in analysis_options files - Run `dart fix --apply` - Clean up leftover analysis issues - Run `dart format .` in the right places. Local analysis and testing passes. Checking CI now. Part of https://github.com/flutter/flutter/issues/178827 - Adoption of flutter_lints in examples/api coming in a separate change (cc @loic-sharma) ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
1826 lines
60 KiB
Dart
1826 lines
60 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 offsetResults = <TestDragData>[
|
|
TestDragData(Offset(10.0, 10.0), Offset(-150.0, 200.0), <Offset>[
|
|
Offset(-7.5, 10.0),
|
|
Offset(-2.5, 3.333333333333333),
|
|
Offset(-140.0, 186.66666666666666),
|
|
]),
|
|
TestDragData(Offset(10.0, 10.0), Offset(150, -200), <Offset>[
|
|
Offset(7.5, -10),
|
|
Offset(2.5, -3.333333333333333),
|
|
Offset(140.0, -186.66666666666666),
|
|
]),
|
|
TestDragData(Offset(10.0, 10.0), Offset(-200, 150), <Offset>[
|
|
Offset(-10, 7.5),
|
|
Offset(-3.333333333333333, 2.5),
|
|
Offset(-186.66666666666666, 140.0),
|
|
]),
|
|
TestDragData(Offset(10.0, 10.0), Offset(200.0, -150.0), <Offset>[
|
|
Offset(10, -7.5),
|
|
Offset(3.333333333333333, -2.5),
|
|
Offset(186.66666666666666, -140.0),
|
|
]),
|
|
TestDragData(Offset(10.0, 10.0), Offset(-150.0, -200.0), <Offset>[
|
|
Offset(-7.5, -10.0),
|
|
Offset(-2.5, -3.333333333333333),
|
|
Offset(-140.0, -186.66666666666666),
|
|
]),
|
|
TestDragData(Offset(10.0, 10.0), Offset(8.0, 3.0), <Offset>[Offset(8.0, 3.0)]),
|
|
TestDragData(Offset(10.0, 10.0), Offset(3.0, 8.0), <Offset>[Offset(3.0, 8.0)]),
|
|
TestDragData(Offset(10.0, 10.0), Offset(20.0, 5.0), <Offset>[
|
|
Offset(10.0, 2.5),
|
|
Offset(10.0, 2.5),
|
|
]),
|
|
TestDragData(Offset(10.0, 10.0), Offset(5.0, 20.0), <Offset>[
|
|
Offset(2.5, 10.0),
|
|
Offset(2.5, 10.0),
|
|
]),
|
|
TestDragData(Offset(10.0, 10.0), Offset(20.0, 15.0), <Offset>[
|
|
Offset(10.0, 7.5),
|
|
Offset(3.333333333333333, 2.5),
|
|
Offset(6.666666666666668, 5.0),
|
|
]),
|
|
TestDragData(Offset(10.0, 10.0), Offset(15.0, 20.0), <Offset>[
|
|
Offset(7.5, 10.0),
|
|
Offset(2.5, 3.333333333333333),
|
|
Offset(5.0, 6.666666666666668),
|
|
]),
|
|
TestDragData(Offset(10.0, 10.0), Offset(20.0, 20.0), <Offset>[
|
|
Offset(10.0, 10.0),
|
|
Offset(10.0, 10.0),
|
|
]),
|
|
TestDragData(Offset(10.0, 10.0), Offset(0.0, 5.0), <Offset>[Offset(0.0, 5.0)]),
|
|
|
|
//// Varying touch slops
|
|
TestDragData(Offset(12.0, 5.0), Offset(0.0, 5.0), <Offset>[Offset(0.0, 5.0)]),
|
|
TestDragData(Offset(12.0, 5.0), Offset(20.0, 5.0), <Offset>[
|
|
Offset(12.0, 3.0),
|
|
Offset(8.0, 2.0),
|
|
]),
|
|
TestDragData(Offset(12.0, 5.0), Offset(5.0, 20.0), <Offset>[
|
|
Offset(1.25, 5.0),
|
|
Offset(3.75, 15.0),
|
|
]),
|
|
TestDragData(Offset(5.0, 12.0), Offset(5.0, 20.0), <Offset>[
|
|
Offset(3.0, 12.0),
|
|
Offset(2.0, 8.0),
|
|
]),
|
|
TestDragData(Offset(5.0, 12.0), Offset(20.0, 5.0), <Offset>[
|
|
Offset(5.0, 1.25),
|
|
Offset(15.0, 3.75),
|
|
]),
|
|
TestDragData(Offset(18.0, 18.0), Offset(0.0, 150.0), <Offset>[
|
|
Offset(0.0, 18.0),
|
|
Offset(0.0, 132.0),
|
|
]),
|
|
TestDragData(Offset(18.0, 18.0), Offset(0.0, -150.0), <Offset>[
|
|
Offset(0.0, -18.0),
|
|
Offset(0.0, -132.0),
|
|
]),
|
|
TestDragData(Offset(18.0, 18.0), Offset(-150.0, 0.0), <Offset>[
|
|
Offset(-18.0, 0.0),
|
|
Offset(-132.0, 0.0),
|
|
]),
|
|
TestDragData(Offset.zero, Offset(-150.0, 0.0), <Offset>[Offset(-150.0, 0.0)]),
|
|
TestDragData(Offset(18.0, 18.0), Offset(-32.0, 0.0), <Offset>[
|
|
Offset(-18.0, 0.0),
|
|
Offset(-14.0, 0.0),
|
|
]),
|
|
];
|
|
|
|
final dragOffsets = <Offset>[];
|
|
|
|
await tester.pumpWidget(
|
|
Listener(
|
|
onPointerMove: (PointerMoveEvent event) {
|
|
dragOffsets.add(event.delta);
|
|
},
|
|
child: const Text('test', textDirection: TextDirection.ltr),
|
|
),
|
|
);
|
|
|
|
for (var resultIndex = 0; resultIndex < offsetResults.length; resultIndex += 1) {
|
|
final TestDragData testResult = offsetResults[resultIndex];
|
|
await tester.drag(
|
|
find.text('test'),
|
|
testResult.dragDistance,
|
|
touchSlopX: testResult.slop.dx,
|
|
touchSlopY: testResult.slop.dy,
|
|
);
|
|
expect(
|
|
testResult.expectedOffsets.length,
|
|
dragOffsets.length,
|
|
reason:
|
|
'There is a difference in the number of expected and actual split offsets for the drag with:\n'
|
|
'Touch Slop: ${testResult.slop}\n'
|
|
'Delta: ${testResult.dragDistance}\n',
|
|
);
|
|
for (
|
|
var valueIndex = 0;
|
|
valueIndex < offsetResults[resultIndex].expectedOffsets.length;
|
|
valueIndex += 1
|
|
) {
|
|
expect(
|
|
testResult.expectedOffsets[valueIndex],
|
|
offsetMoreOrLessEquals(dragOffsets[valueIndex]),
|
|
reason:
|
|
'There is a difference in the expected and actual value of the '
|
|
'${valueIndex == 2
|
|
? 'first'
|
|
: valueIndex == 3
|
|
? 'second'
|
|
: 'third'}'
|
|
' split offset for the drag with:\n'
|
|
'Touch slop: ${testResult.slop}\n'
|
|
'Delta: ${testResult.dragDistance}\n',
|
|
);
|
|
}
|
|
dragOffsets.clear();
|
|
}
|
|
});
|
|
|
|
testWidgets('WidgetTester.tap must respect buttons', (WidgetTester tester) async {
|
|
final logs = <String>[];
|
|
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Listener(
|
|
onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'),
|
|
onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'),
|
|
onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'),
|
|
child: const Text('test'),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.text('test'), buttons: kSecondaryMouseButton);
|
|
|
|
const b = '$kSecondaryMouseButton';
|
|
for (var i = 0; i < logs.length; i++) {
|
|
if (i == 0) {
|
|
expect(logs[i], 'down $b');
|
|
} else if (i != logs.length - 1) {
|
|
expect(logs[i], 'move $b');
|
|
} else {
|
|
expect(logs[i], 'up 0');
|
|
}
|
|
}
|
|
});
|
|
|
|
testWidgets('WidgetTester.press must respect buttons', (WidgetTester tester) async {
|
|
final logs = <String>[];
|
|
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Listener(
|
|
onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'),
|
|
onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'),
|
|
onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'),
|
|
child: const Text('test'),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.press(find.text('test'), buttons: kSecondaryMouseButton);
|
|
|
|
const b = '$kSecondaryMouseButton';
|
|
expect(logs, equals(<String>['down $b']));
|
|
});
|
|
|
|
testWidgets('WidgetTester.longPress must respect buttons', (WidgetTester tester) async {
|
|
final logs = <String>[];
|
|
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Listener(
|
|
onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'),
|
|
onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'),
|
|
onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'),
|
|
child: const Text('test'),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.longPress(find.text('test'), buttons: kSecondaryMouseButton);
|
|
await tester.pumpAndSettle();
|
|
|
|
const b = '$kSecondaryMouseButton';
|
|
for (var i = 0; i < logs.length; i++) {
|
|
if (i == 0) {
|
|
expect(logs[i], 'down $b');
|
|
} else if (i != logs.length - 1) {
|
|
expect(logs[i], 'move $b');
|
|
} else {
|
|
expect(logs[i], 'up 0');
|
|
}
|
|
}
|
|
});
|
|
|
|
testWidgets('WidgetTester.drag must respect buttons', (WidgetTester tester) async {
|
|
final logs = <String>[];
|
|
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Listener(
|
|
onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'),
|
|
onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'),
|
|
onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'),
|
|
child: const Text('test'),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.drag(
|
|
find.text('test'),
|
|
const Offset(-150.0, 200.0),
|
|
buttons: kSecondaryMouseButton,
|
|
);
|
|
|
|
const b = '$kSecondaryMouseButton';
|
|
for (var i = 0; i < logs.length; i++) {
|
|
if (i == 0) {
|
|
expect(logs[i], 'down $b');
|
|
} else if (i != logs.length - 1) {
|
|
expect(logs[i], 'move $b');
|
|
} else {
|
|
expect(logs[i], 'up 0');
|
|
}
|
|
}
|
|
});
|
|
|
|
testWidgets('WidgetTester.drag works with trackpad kind', (WidgetTester tester) async {
|
|
final logs = <String>[];
|
|
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Listener(
|
|
onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'),
|
|
onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'),
|
|
onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'),
|
|
onPointerPanZoomStart: (PointerPanZoomStartEvent event) => logs.add('panZoomStart'),
|
|
onPointerPanZoomUpdate: (PointerPanZoomUpdateEvent event) =>
|
|
logs.add('panZoomUpdate ${event.pan}'),
|
|
onPointerPanZoomEnd: (PointerPanZoomEndEvent event) => logs.add('panZoomEnd'),
|
|
child: const Text('test'),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.drag(
|
|
find.text('test'),
|
|
const Offset(-150.0, 200.0),
|
|
kind: PointerDeviceKind.trackpad,
|
|
);
|
|
|
|
for (var i = 0; i < logs.length; i++) {
|
|
if (i == 0) {
|
|
expect(logs[i], 'panZoomStart');
|
|
} else if (i != logs.length - 1) {
|
|
expect(logs[i], startsWith('panZoomUpdate'));
|
|
} else {
|
|
expect(logs[i], 'panZoomEnd');
|
|
}
|
|
}
|
|
});
|
|
|
|
testWidgets('WidgetTester.fling must respect buttons', (WidgetTester tester) async {
|
|
final logs = <String>[];
|
|
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Listener(
|
|
onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'),
|
|
onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'),
|
|
onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'),
|
|
child: const Text('test'),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.fling(
|
|
find.text('test'),
|
|
const Offset(-10.0, 0.0),
|
|
1000.0,
|
|
buttons: kSecondaryMouseButton,
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
const b = '$kSecondaryMouseButton';
|
|
for (var i = 0; i < logs.length; i++) {
|
|
if (i == 0) {
|
|
expect(logs[i], 'down $b');
|
|
} else if (i != logs.length - 1) {
|
|
expect(logs[i], 'move $b');
|
|
} else {
|
|
expect(logs[i], 'up 0');
|
|
}
|
|
}
|
|
});
|
|
|
|
testWidgets('WidgetTester.fling produces strictly monotonically increasing timestamps, '
|
|
'when given a large velocity', (WidgetTester tester) async {
|
|
// Velocity trackers may misbehave if the `PointerMoveEvent`s' have the
|
|
// same timestamp. This is more likely to happen when the velocity tracker
|
|
// has a small sample size.
|
|
final logs = <Duration>[];
|
|
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Listener(
|
|
onPointerMove: (PointerMoveEvent event) => logs.add(event.timeStamp),
|
|
child: const Text('test'),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.fling(find.text('test'), const Offset(0.0, -50.0), 10000.0);
|
|
await tester.pumpAndSettle();
|
|
|
|
for (var i = 0; i + 1 < logs.length; i += 1) {
|
|
expect(logs[i + 1], greaterThan(logs[i]));
|
|
}
|
|
});
|
|
|
|
testWidgets('WidgetTester.timedDrag must respect buttons', (WidgetTester tester) async {
|
|
final logs = <String>[];
|
|
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Listener(
|
|
onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'),
|
|
onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'),
|
|
onPointerUp: (PointerUpEvent event) => logs.add('up ${event.buttons}'),
|
|
child: const Text('test'),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.timedDrag(
|
|
find.text('test'),
|
|
const Offset(-200.0, 0.0),
|
|
const Duration(seconds: 1),
|
|
buttons: kSecondaryMouseButton,
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
const b = '$kSecondaryMouseButton';
|
|
for (var i = 0; i < logs.length; i++) {
|
|
if (i == 0) {
|
|
expect(logs[i], 'down $b');
|
|
} else if (i != logs.length - 1) {
|
|
expect(logs[i], 'move $b');
|
|
} else {
|
|
expect(logs[i], 'up 0');
|
|
}
|
|
}
|
|
});
|
|
|
|
testWidgets('WidgetTester.timedDrag uses correct pointer', (WidgetTester tester) async {
|
|
final logs = <String>[];
|
|
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Listener(
|
|
onPointerDown: (PointerDownEvent event) => logs.add('down ${event.pointer}'),
|
|
child: const Text('test'),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.timedDrag(
|
|
find.text('test'),
|
|
const Offset(-200.0, 0.0),
|
|
const Duration(seconds: 1),
|
|
buttons: kSecondaryMouseButton,
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.timedDrag(
|
|
find.text('test'),
|
|
const Offset(200.0, 0.0),
|
|
const Duration(seconds: 1),
|
|
buttons: kSecondaryMouseButton,
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(logs.length, 2);
|
|
expect(logs[0], isNotNull);
|
|
expect(logs[1], isNotNull);
|
|
expect(logs[1] != logs[0], isTrue);
|
|
});
|
|
|
|
testWidgets('WidgetTester.tap appears in stack trace on error', (WidgetTester tester) async {
|
|
// Regression test from https://github.com/flutter/flutter/pull/123946
|
|
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: Text('target'))));
|
|
|
|
final TestGesture gesture = await tester.startGesture(
|
|
tester.getCenter(find.text('target')),
|
|
pointer: 1,
|
|
);
|
|
addTearDown(() => gesture.up());
|
|
|
|
Trace? stackTrace;
|
|
try {
|
|
await tester.tap(find.text('target'), pointer: 1);
|
|
} on Error catch (e) {
|
|
stackTrace = Trace.from(e.stackTrace!);
|
|
}
|
|
expect(stackTrace, isNotNull);
|
|
|
|
final int tapFrame = stackTrace!.frames.indexWhere(
|
|
(Frame frame) => frame.member == 'WidgetController.tap',
|
|
);
|
|
expect(tapFrame, greaterThanOrEqualTo(0));
|
|
expect(stackTrace.frames[tapFrame].package, 'flutter_test');
|
|
expect(stackTrace.frames[tapFrame + 1].member, 'main.<fn>');
|
|
expect(stackTrace.frames[tapFrame + 1].package, null);
|
|
});
|
|
|
|
testWidgets('ensureVisible: scrolls to make widget visible', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: ListView.builder(
|
|
itemCount: 20,
|
|
shrinkWrap: true,
|
|
itemBuilder: (BuildContext context, int i) => ListTile(title: Text('Item $i')),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Make sure widget isn't on screen
|
|
expect(find.text('Item 15'), findsNothing);
|
|
|
|
await tester.ensureVisible(find.text('Item 15', skipOffstage: false));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Item 15'), findsOneWidget);
|
|
});
|
|
|
|
group('scrollUntilVisible: scrolls to make unbuilt widget visible', () {
|
|
testWidgets('Vertical', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: ListView.builder(
|
|
itemCount: 50,
|
|
shrinkWrap: true,
|
|
itemBuilder: (BuildContext context, int i) => ListTile(title: Text('Item $i')),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Make sure widget isn't built yet.
|
|
expect(find.text('Item 45', skipOffstage: false), findsNothing);
|
|
|
|
await tester.scrollUntilVisible(find.text('Item 45', skipOffstage: false), 100);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Now the widget is on screen.
|
|
expect(find.text('Item 45'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Horizontal', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: ListView.builder(
|
|
itemCount: 50,
|
|
shrinkWrap: true,
|
|
scrollDirection: Axis.horizontal,
|
|
// ListTile does not support horizontal list
|
|
itemBuilder: (BuildContext context, int i) => Text('Item $i'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Make sure widget isn't built yet.
|
|
expect(find.text('Item 45', skipOffstage: false), findsNothing);
|
|
|
|
await tester.scrollUntilVisible(find.text('Item 45', skipOffstage: false), 100);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Now the widget is on screen.
|
|
expect(find.text('Item 45'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Fail', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: ListView.builder(
|
|
itemCount: 50,
|
|
shrinkWrap: true,
|
|
itemBuilder: (BuildContext context, int i) => ListTile(title: Text('Item $i')),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
try {
|
|
await tester.scrollUntilVisible(find.text('Item 55', skipOffstage: false), 100);
|
|
} on StateError catch (e) {
|
|
expect(e.message, 'No element');
|
|
}
|
|
});
|
|
|
|
testWidgets('Drag Until Visible', (WidgetTester tester) async {
|
|
// when there are two implicit [Scrollable], `scrollUntilVisible` is hard
|
|
// to use.
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: Column(
|
|
children: <Widget>[
|
|
SizedBox(
|
|
height: 200,
|
|
child: ListView.builder(
|
|
key: const Key('listView-a'),
|
|
itemCount: 50,
|
|
shrinkWrap: true,
|
|
itemBuilder: (BuildContext context, int i) =>
|
|
ListTile(title: Text('Item a-$i')),
|
|
),
|
|
),
|
|
const 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);
|
|
});
|
|
});
|
|
|
|
// Regression test for https://github.com/flutter/flutter/issues/143921.
|
|
testWidgets('WidgetTester.scrollUntilVisible should work together with onTap', (
|
|
WidgetTester tester,
|
|
) async {
|
|
const itemCount = 20;
|
|
|
|
Widget buildFrame(bool hasOnTap) {
|
|
return MaterialApp(
|
|
home: Scaffold(
|
|
body: ListView.builder(
|
|
key: ValueKey<bool>(hasOnTap), // Trigger a rebuild.
|
|
itemCount: itemCount,
|
|
itemBuilder: (BuildContext context, int index) {
|
|
return ListTile(onTap: hasOnTap ? () {} : null, title: Text('$index'));
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final Finder target = find.text('${itemCount - 1}');
|
|
final Finder scrollable = find.byType(Scrollable);
|
|
|
|
// Scroll without onTap.
|
|
await tester.pumpWidget(buildFrame(false));
|
|
await tester.pumpAndSettle();
|
|
expect(target, findsNothing);
|
|
await tester.scrollUntilVisible(target, 20, scrollable: scrollable, continuous: true);
|
|
expect(target, findsOneWidget);
|
|
|
|
// Scroll with onTap.
|
|
await tester.pumpWidget(buildFrame(true));
|
|
await tester.pumpAndSettle();
|
|
expect(target, findsNothing);
|
|
await tester.scrollUntilVisible(target, 20, scrollable: scrollable, continuous: true);
|
|
expect(target, findsOneWidget);
|
|
});
|
|
|
|
testWidgets('platformDispatcher exposes the platformDispatcher from binding', (
|
|
WidgetTester tester,
|
|
) async {
|
|
expect(tester.platformDispatcher, tester.binding.platformDispatcher);
|
|
});
|
|
|
|
testWidgets('view exposes the implicitView from platformDispatcher', (WidgetTester tester) async {
|
|
expect(tester.view, tester.platformDispatcher.implicitView);
|
|
});
|
|
|
|
testWidgets('viewOf finds a view when the view is implicit', (WidgetTester tester) async {
|
|
await tester.pumpWidget(const MaterialApp(home: Center(child: Text('Test'))));
|
|
|
|
expect(() => tester.viewOf(find.text('Test')), isNot(throwsA(anything)));
|
|
expect(tester.viewOf(find.text('Test')), isA<TestFlutterView>());
|
|
});
|
|
|
|
group('SemanticsController', () {
|
|
group('find', () {
|
|
testWidgets('throws when there are no semantics', (WidgetTester tester) async {
|
|
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: Text('hello'))));
|
|
|
|
expect(() => tester.semantics.find(find.text('hello')), throwsStateError);
|
|
}, semanticsEnabled: false);
|
|
|
|
testWidgets('throws when there are multiple results from the finder', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Scaffold(body: Row(children: <Widget>[Text('hello'), Text('hello')])),
|
|
),
|
|
);
|
|
|
|
expect(() => tester.semantics.find(find.text('hello')), throwsStateError);
|
|
});
|
|
|
|
testWidgets('Returns the correct SemanticsData', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: OutlinedButton(onPressed: () {}, child: const Text('hello')),
|
|
),
|
|
),
|
|
);
|
|
|
|
final SemanticsNode node = tester.semantics.find(find.text('hello'));
|
|
final SemanticsData semantics = node.getSemanticsData();
|
|
expect(semantics.label, 'hello');
|
|
expect(semantics.hasAction(SemanticsAction.tap), true);
|
|
expect(semantics.hasFlag(SemanticsFlag.isButton), true);
|
|
});
|
|
|
|
testWidgets('Can enable semantics for tests via semanticsEnabled', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: OutlinedButton(onPressed: () {}, child: const Text('hello')),
|
|
),
|
|
),
|
|
);
|
|
|
|
final SemanticsNode node = tester.semantics.find(find.text('hello'));
|
|
final SemanticsData semantics = node.getSemanticsData();
|
|
expect(semantics.label, 'hello');
|
|
expect(semantics.hasAction(SemanticsAction.tap), true);
|
|
expect(semantics.hasFlag(SemanticsFlag.isButton), true);
|
|
});
|
|
|
|
testWidgets('Returns merged SemanticsData', (WidgetTester tester) async {
|
|
const key = Key('test');
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: Semantics(
|
|
label: 'A',
|
|
child: Semantics(
|
|
label: 'B',
|
|
child: Semantics(key: key, label: 'C', child: Container()),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final SemanticsNode node = tester.semantics.find(find.byKey(key));
|
|
final SemanticsData semantics = node.getSemanticsData();
|
|
expect(semantics.label, 'A\nB\nC');
|
|
});
|
|
|
|
testWidgets('Does not return partial semantics', (WidgetTester tester) async {
|
|
final Key key = UniqueKey();
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: MergeSemantics(
|
|
child: Semantics(
|
|
container: true,
|
|
label: 'A',
|
|
child: Semantics(container: true, key: key, label: 'B', child: Container()),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final SemanticsNode node = tester.semantics.find(find.byKey(key));
|
|
final SemanticsData semantics = node.getSemanticsData();
|
|
expect(semantics.label, 'A\nB');
|
|
});
|
|
});
|
|
|
|
group('simulatedTraversal', () {
|
|
final fullTraversalMatchers = <Matcher>[
|
|
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 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).nonNulls;
|
|
|
|
expect(
|
|
tester.semantics.simulatedAccessibilityTraversal(),
|
|
containsAllInOrder(expectedMatchers),
|
|
);
|
|
});
|
|
|
|
testWidgets('merging node should not be visited', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: MergeSemantics(
|
|
child: Column(
|
|
children: <Widget>[
|
|
Semantics(container: true, child: const Text('1')),
|
|
Semantics(container: true, child: const Text('2')),
|
|
Semantics(container: true, child: const Text('3')),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
tester.semantics.simulatedAccessibilityTraversal(),
|
|
orderedEquals(<Matcher>[containsSemantics(label: '1\n2\n3')]),
|
|
);
|
|
});
|
|
testWidgets('a node with only a tooltip is important for accessibility', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Tooltip(message: 'My tooltip', child: SizedBox()),
|
|
),
|
|
);
|
|
expect(
|
|
tester.semantics.simulatedAccessibilityTraversal(),
|
|
contains(containsSemantics(tooltip: 'My tooltip')),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('actions', () {
|
|
testWidgets('performAction with unsupported action throws StateError', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(Semantics(onTap: () {}));
|
|
|
|
expect(
|
|
() => tester.semantics.performAction(
|
|
find.semantics.byLabel('Test'),
|
|
SemanticsAction.dismiss,
|
|
),
|
|
throwsStateError,
|
|
);
|
|
});
|
|
|
|
testWidgets('tap causes semantic tap', (WidgetTester tester) async {
|
|
var invoked = false;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: TextButton(onPressed: () => invoked = true, child: const Text('Test Button')),
|
|
),
|
|
);
|
|
|
|
tester.semantics.tap(find.semantics.byAction(SemanticsAction.tap));
|
|
expect(invoked, isTrue);
|
|
});
|
|
|
|
testWidgets('longPress causes semantic long press', (WidgetTester tester) async {
|
|
var invoked = false;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: TextButton(
|
|
onPressed: () {},
|
|
onLongPress: () => invoked = true,
|
|
child: const Text('Test Button'),
|
|
),
|
|
),
|
|
);
|
|
|
|
tester.semantics.longPress(find.semantics.byAction(SemanticsAction.longPress));
|
|
expect(invoked, isTrue);
|
|
});
|
|
|
|
testWidgets('scrollLeft and scrollRight scroll left and right respectively', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: ListView(
|
|
scrollDirection: Axis.horizontal,
|
|
children: <Widget>[
|
|
SizedBox(height: 40, width: tester.binding.window.physicalSize.width * 1.5),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
find.semantics.scrollable(),
|
|
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 {
|
|
var invoked = false;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Material(
|
|
child: _StatefulSlider(
|
|
initialValue: 0,
|
|
onChanged: (double _) {
|
|
invoked = true;
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final SemanticsFinder sliderFinder = find.semantics.byFlag(SemanticsFlag.isSlider);
|
|
final String expected = sliderFinder.evaluate().single.increasedValue;
|
|
tester.semantics.increase(sliderFinder);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(invoked, isTrue);
|
|
expect(
|
|
find.semantics.byFlag(SemanticsFlag.isSlider).evaluate().single.value,
|
|
equals(expected),
|
|
);
|
|
});
|
|
|
|
testWidgets('decrease causes semantic decrease', (WidgetTester tester) async {
|
|
var invoked = false;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Material(
|
|
child: _StatefulSlider(
|
|
initialValue: 1,
|
|
onChanged: (double _) {
|
|
invoked = true;
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final SemanticsFinder sliderFinder = find.semantics.byFlag(SemanticsFlag.isSlider);
|
|
final String expected = sliderFinder.evaluate().single.decreasedValue;
|
|
tester.semantics.decrease(sliderFinder);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(invoked, isTrue);
|
|
expect(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 text = 'This is some text.';
|
|
int currentIndex = text.length;
|
|
final 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 text = 'This is some text.';
|
|
int currentIndex = text.length;
|
|
final 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 expectedText = 'This is some text.';
|
|
final 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 text = 'This is some text.';
|
|
const int expectedStart = text.length - 8;
|
|
const int expectedEnd = text.length - 4;
|
|
final 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 {
|
|
var invoked = false;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Semantics(label: 'test', onCopy: () => invoked = true),
|
|
),
|
|
);
|
|
|
|
tester.semantics.copy(find.semantics.byLabel('test'));
|
|
expect(invoked, isTrue);
|
|
});
|
|
|
|
testWidgets('cut sends semantic cut', (WidgetTester tester) async {
|
|
var invoked = false;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Semantics(label: 'test', onCut: () => invoked = true),
|
|
),
|
|
);
|
|
|
|
tester.semantics.cut(find.semantics.byLabel('test'));
|
|
expect(invoked, isTrue);
|
|
});
|
|
|
|
testWidgets('paste sends semantic paste', (WidgetTester tester) async {
|
|
var invoked = false;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Semantics(label: 'test', onPaste: () => invoked = true),
|
|
),
|
|
);
|
|
|
|
tester.semantics.paste(find.semantics.byLabel('test'));
|
|
expect(invoked, isTrue);
|
|
});
|
|
|
|
testWidgets('didGainAccessibilityFocus causes semantic focus on node', (
|
|
WidgetTester tester,
|
|
) async {
|
|
var invoked = false;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Semantics(label: 'test', onDidGainAccessibilityFocus: () => invoked = true),
|
|
),
|
|
);
|
|
|
|
tester.semantics.didGainAccessibilityFocus(find.semantics.byLabel('test'));
|
|
expect(invoked, isTrue);
|
|
});
|
|
|
|
testWidgets('didLoseAccessibility causes semantic focus to be lost', (
|
|
WidgetTester tester,
|
|
) async {
|
|
var invoked = false;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Semantics(label: 'test', onDidLoseAccessibilityFocus: () => invoked = true),
|
|
),
|
|
);
|
|
|
|
tester.semantics.didLoseAccessibilityFocus(find.semantics.byLabel('test'));
|
|
expect(invoked, isTrue);
|
|
});
|
|
|
|
testWidgets('dismiss sends semantic dismiss', (WidgetTester tester) async {
|
|
final GlobalKey key = GlobalKey();
|
|
const duration = Duration(seconds: 3);
|
|
final halfDuration = Duration(milliseconds: (duration.inMilliseconds / 2).floor());
|
|
late SnackBarClosedReason reason;
|
|
|
|
await tester.pumpWidget(MaterialApp(home: Scaffold(key: key)));
|
|
|
|
final ScaffoldMessengerState messenger = ScaffoldMessenger.of(key.currentContext!);
|
|
messenger
|
|
.showSnackBar(
|
|
const SnackBar(content: SizedBox(height: 40, width: 300), duration: duration),
|
|
)
|
|
.closed
|
|
.then((SnackBarClosedReason result) => reason = result);
|
|
await tester.pumpFrames(tester.widget(find.byType(MaterialApp)), halfDuration);
|
|
|
|
tester.semantics.dismiss(find.semantics.byAction(SemanticsAction.dismiss));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(reason, equals(SnackBarClosedReason.dismiss));
|
|
});
|
|
|
|
testWidgets('customAction invokes appropriate custom action', (WidgetTester tester) async {
|
|
const customAction = CustomSemanticsAction(label: 'test');
|
|
var invoked = false;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Semantics(
|
|
label: 'test',
|
|
customSemanticsActions: <CustomSemanticsAction, void Function()>{
|
|
customAction: () => invoked = true,
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
tester.semantics.customAction(find.semantics.byLabel('test'), customAction);
|
|
await tester.pump();
|
|
|
|
expect(invoked, isTrue);
|
|
});
|
|
});
|
|
});
|
|
|
|
group('WidgetTester.tapOnText', () {
|
|
final tapLogs = <String>[];
|
|
final tapA = TapGestureRecognizer()
|
|
..onTap = () {
|
|
tapLogs.add('A');
|
|
};
|
|
final tapB = TapGestureRecognizer()
|
|
..onTap = () {
|
|
tapLogs.add('B');
|
|
};
|
|
final tapC = TapGestureRecognizer()
|
|
..onTap = () {
|
|
tapLogs.add('C');
|
|
};
|
|
tearDown(tapLogs.clear);
|
|
tearDownAll(() {
|
|
tapA.dispose();
|
|
tapB.dispose();
|
|
tapC.dispose();
|
|
});
|
|
|
|
testWidgets('basic test', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Text.rich(TextSpan(text: 'match', recognizer: tapA)),
|
|
),
|
|
);
|
|
|
|
await tester.tapOnText(find.textRange.ofSubstring('match'));
|
|
expect(tapLogs, <String>['A']);
|
|
});
|
|
|
|
testWidgets('partially obstructed: find a hit-testable Offset', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: <Widget>[
|
|
Positioned(
|
|
left: 100.0 - 9 * 10.0, // Only the last character is visible.
|
|
child: Text.rich(
|
|
TextSpan(
|
|
text: 'text match',
|
|
style: const TextStyle(fontSize: 10),
|
|
recognizer: tapA,
|
|
),
|
|
),
|
|
),
|
|
const Positioned(
|
|
left: 0.0,
|
|
right: 100.0,
|
|
child: MetaData(behavior: HitTestBehavior.opaque),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
await expectLater(
|
|
() => tester.tapOnText(find.textRange.ofSubstring('text match')),
|
|
returnsNormally,
|
|
);
|
|
});
|
|
|
|
testWidgets('multiline text partially obstructed: find a hit-testable Offset', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: <Widget>[
|
|
Positioned(
|
|
width: 100.0,
|
|
top: 23.0,
|
|
left: 0.0,
|
|
child: Text.rich(
|
|
TextSpan(
|
|
style: const TextStyle(fontSize: 10),
|
|
children: <InlineSpan>[
|
|
TextSpan(text: 'AAAAAAAAA ', recognizer: tapA),
|
|
TextSpan(text: 'BBBBBBBBB ', recognizer: tapB), // The only visible line
|
|
TextSpan(text: 'CCCCCCCCC ', recognizer: tapC),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const Positioned(
|
|
top: 23.0, // Some random offset to test the global to local Offset conversion
|
|
left: 0.0,
|
|
right: 0.0,
|
|
height: 10.0,
|
|
child: MetaData(behavior: HitTestBehavior.opaque, child: SizedBox.expand()),
|
|
),
|
|
const Positioned(
|
|
top: 43.0,
|
|
left: 0.0,
|
|
right: 0.0,
|
|
height: 10.0,
|
|
child: MetaData(behavior: HitTestBehavior.opaque, child: SizedBox.expand()),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.tapOnText(find.textRange.ofSubstring('AAAAAAAAA BBBBBBBBB CCCCCCCCC '));
|
|
expect(tapLogs, <String>['B']);
|
|
});
|
|
|
|
testWidgets('error message: no matching text', (WidgetTester tester) async {
|
|
await tester.pumpWidget(const SizedBox());
|
|
await expectLater(
|
|
() => tester.tapOnText(find.textRange.ofSubstring('nonexistent')),
|
|
throwsA(
|
|
isFlutterError.having(
|
|
(FlutterError error) => error.message,
|
|
'message',
|
|
contains('Found 0 non-overlapping TextRanges that match the Pattern "nonexistent": []'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
testWidgets('error message: too many matches', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Text.rich(
|
|
TextSpan(
|
|
text: 'match',
|
|
recognizer: tapA,
|
|
children: <InlineSpan>[TextSpan(text: 'another match', recognizer: tapB)],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
await expectLater(
|
|
() => tester.tapOnText(find.textRange.ofSubstring('match')),
|
|
throwsA(
|
|
isFlutterError.having(
|
|
(FlutterError error) => error.message,
|
|
'message',
|
|
stringContainsInOrder(<String>[
|
|
'Found 2 non-overlapping TextRanges that match the Pattern "match"',
|
|
'TextRange(start: 0, end: 5)',
|
|
'TextRange(start: 13, end: 18)',
|
|
'The "tapOnText" method needs a single non-empty TextRange.',
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
testWidgets('error message: not hit-testable', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: <Widget>[
|
|
Text.rich(TextSpan(text: 'match', recognizer: tapA)),
|
|
const MetaData(behavior: HitTestBehavior.opaque),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
await expectLater(
|
|
() => tester.tapOnText(find.textRange.ofSubstring('match')),
|
|
throwsA(
|
|
isFlutterError.having(
|
|
(FlutterError error) => error.message,
|
|
'message',
|
|
stringContainsInOrder(<String>[
|
|
'The finder used was: A finder that searches for non-overlapping TextRanges that match the Pattern "match".',
|
|
'Found a matching substring in a static text widget, within TextRange(start: 0, end: 5).',
|
|
'But the "tapOnText" method could not find a hit-testable Offset with in that text range.',
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
class _SemanticsTestWidget extends StatelessWidget {
|
|
const _SemanticsTestWidget();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Semantics Test')),
|
|
body: SingleChildScrollView(
|
|
child: Column(
|
|
children: <Widget>[
|
|
const _SemanticsTestCard(label: 'Text Field', widget: TextField()),
|
|
_SemanticsTestCard(
|
|
label: 'Off Switch',
|
|
widget: Switch(value: false, onChanged: (bool value) {}),
|
|
),
|
|
_SemanticsTestCard(
|
|
label: 'On Switch',
|
|
widget: Switch(value: true, onChanged: (bool value) {}),
|
|
),
|
|
const _SemanticsTestCard(
|
|
label: 'Multiline',
|
|
widget: Text("It's a\nmultiline label!", maxLines: 2),
|
|
),
|
|
_SemanticsTestCard(
|
|
label: 'Slider',
|
|
widget: Slider(value: .5, onChanged: (double value) {}),
|
|
),
|
|
_SemanticsTestCard(
|
|
label: 'Enabled Button',
|
|
widget: TextButton(onPressed: () {}, child: const Text('Tap')),
|
|
),
|
|
const _SemanticsTestCard(
|
|
label: 'Disabled Button',
|
|
widget: TextButton(onPressed: null, child: Text("Don't Tap")),
|
|
),
|
|
_SemanticsTestCard(
|
|
label: 'Checked Radio',
|
|
widget: Radio<String>(
|
|
value: 'checked',
|
|
groupValue: 'checked',
|
|
onChanged: (String? value) {},
|
|
),
|
|
),
|
|
_SemanticsTestCard(
|
|
label: 'Unchecked Radio',
|
|
widget: Radio<String>(
|
|
value: 'unchecked',
|
|
groupValue: 'checked',
|
|
onChanged: (String? value) {},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SemanticsTestCard extends StatelessWidget {
|
|
const _SemanticsTestCard({required this.label, required this.widget});
|
|
|
|
final String label;
|
|
final Widget widget;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
child: ListTile(
|
|
title: Text(label),
|
|
trailing: SizedBox(width: 200, child: widget),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatefulSlider extends StatefulWidget {
|
|
const _StatefulSlider({required this.initialValue, required this.onChanged});
|
|
|
|
final double initialValue;
|
|
final ValueChanged<double> onChanged;
|
|
|
|
@override
|
|
_StatefulSliderState createState() => _StatefulSliderState();
|
|
}
|
|
|
|
class _StatefulSliderState extends State<_StatefulSlider> {
|
|
double _value = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_value = widget.initialValue;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Slider(
|
|
value: _value,
|
|
onChanged: (double value) {
|
|
setState(() {
|
|
_value = value;
|
|
});
|
|
widget.onChanged(value);
|
|
},
|
|
);
|
|
}
|
|
}
|