Unverified Commit 52a1a318 authored by pdblasi-google's avatar pdblasi-google Committed by GitHub

Adds API for performing semantics actions in tests (#132598)

* Added `performAction` to `SemanticsController` as well as specific methods for specific actions
* Added a `scrollable` finder to `find.semantics` as a convenience method for `findAny(<all scrollable actions>)`
* Updated `containsSemantics` and `matchSemantics` matchers to also work on `FinderBase<Semantics>`

Closes #112413
parent 11a2bc0b
......@@ -76,9 +76,6 @@ class SemanticsController {
/// if no semantics are found or are not enabled.
SemanticsNode find(finders.FinderBase<Element> finder) {
TestAsyncUtils.guardSync();
if (!_controller.binding.semanticsEnabled) {
throw StateError('Semantics are not enabled.');
}
final Iterable<Element> candidates = finder.evaluate();
if (candidates.isEmpty) {
throw StateError('Finder returned no matching elements.');
......@@ -102,21 +99,21 @@ class SemanticsController {
/// Simulates a traversal of the currently visible semantics tree as if by
/// assistive technologies.
///
/// Starts at the node for `start`. If `start` is not provided, then the
/// traversal begins with the first accessible node in the tree. If `start`
/// finds zero elements or more than one element, a [StateError] will be
/// thrown.
/// Starts at the node for `startNode`. If `startNode` is not provided, then
/// the traversal begins with the first accessible node in the tree. If
/// `startNode` finds zero elements or more than one element, a [StateError]
/// will be thrown.
///
/// Ends at the node for `end`, inclusive. If `end` is not provided, then the
/// traversal ends with the last accessible node in the currently available
/// tree. If `end` finds zero elements or more than one element, a
/// [StateError] will be thrown.
/// Ends at the node for `endNode`, inclusive. If `endNode` is not provided,
/// then the traversal ends with the last accessible node in the currently
/// available tree. If `endNode` finds zero elements or more than one element,
/// a [StateError] will be thrown.
///
/// If provided, the nodes for `end` and `start` must be part of the same
/// semantics tree, i.e. they must be part of the same view.
/// If provided, the nodes for `endNode` and `startNode` must be part of the
/// same semantics tree, i.e. they must be part of the same view.
///
/// If neither `start` or `end` is provided, `view` can be provided to specify
/// the semantics tree to traverse. If `view` is left unspecified,
/// If neither `startNode` or `endNode` is provided, `view` can be provided to
/// specify the semantics tree to traverse. If `view` is left unspecified,
/// [WidgetTester.view] is traversed by default.
///
/// Since the order is simulated, edge cases that differ between platforms
......@@ -149,10 +146,30 @@ class SemanticsController {
/// parts of the traversal.
/// * [orderedEquals], which can be given an [Iterable<Matcher>] to exactly
/// match the order of the traversal.
Iterable<SemanticsNode> simulatedAccessibilityTraversal({finders.FinderBase<Element>? start, finders.FinderBase<Element>? end, FlutterView? view}) {
Iterable<SemanticsNode> simulatedAccessibilityTraversal({
@Deprecated(
'Use startNode instead. '
'This method was originally created before semantics finders were available. '
'Semantics finders avoid edge cases where some nodes are not discoverable by widget finders and should be preferred for semantics testing. '
'This feature was deprecated after v3.15.0-15.2.pre.'
)
finders.FinderBase<Element>? start,
@Deprecated(
'Use endNode instead. '
'This method was originally created before semantics finders were available. '
'Semantics finders avoid edge cases where some nodes are not discoverable by widget finders and should be preferred for semantics testing. '
'This feature was deprecated after v3.15.0-15.2.pre.'
)
finders.FinderBase<Element>? end,
finders.FinderBase<SemanticsNode>? startNode,
finders.FinderBase<SemanticsNode>? endNode,
FlutterView? view,
}) {
TestAsyncUtils.guardSync();
assert(start == null || startNode == null, 'Cannot provide both start and startNode. Prefer startNode as start is deprecated.');
assert(end == null || endNode == null, 'Cannot provide both end and endNode. Prefer endNode as end is deprecated.');
FlutterView? startView;
FlutterView? endView;
if (start != null) {
startView = _controller.viewOf(start);
if (view != null && startView != view) {
......@@ -164,6 +181,23 @@ class SemanticsController {
);
}
}
if (startNode != null) {
final SemanticsOwner owner = startNode.evaluate().single.owner!;
final RenderView renderView = _controller.binding.renderViews.firstWhere(
(RenderView render) => render.owner!.semanticsOwner == owner,
);
startView = renderView.flutterView;
if (view != null && startView != view) {
throw StateError(
'The end node is not part of the provided view.\n'
'Finder: ${startNode.toString(describeSelf: true)}\n'
'View of end node: $startView\n'
'Specified view: $view'
);
}
}
FlutterView? endView;
if (end != null) {
endView = _controller.viewOf(end);
if (view != null && endView != view) {
......@@ -175,6 +209,22 @@ class SemanticsController {
);
}
}
if (endNode != null) {
final SemanticsOwner owner = endNode.evaluate().single.owner!;
final RenderView renderView = _controller.binding.renderViews.firstWhere(
(RenderView render) => render.owner!.semanticsOwner == owner,
);
endView = renderView.flutterView;
if (view != null && endView != view) {
throw StateError(
'The end node is not part of the provided view.\n'
'Finder: ${endNode.toString(describeSelf: true)}\n'
'View of end node: $endView\n'
'Specified view: $view'
);
}
}
if (endView != null && startView != null && endView != startView) {
throw StateError(
'The start and end node are in different views.\n'
......@@ -189,7 +239,10 @@ class SemanticsController {
final RenderView renderView = _controller.binding.renderViews.firstWhere((RenderView r) => r.flutterView == actualView);
final List<SemanticsNode> traversal = <SemanticsNode>[];
_traverse(renderView.owner!.semanticsOwner!.rootSemanticsNode!, traversal);
_accessibilityTraversal(
renderView.owner!.semanticsOwner!.rootSemanticsNode!,
traversal,
);
int startIndex = 0;
int endIndex = traversal.length - 1;
......@@ -223,14 +276,14 @@ class SemanticsController {
/// Recursive depth first traversal of the specified `node`, adding nodes
/// that are important for semantics to the `traversal` list.
void _traverse(SemanticsNode node, List<SemanticsNode> traversal){
void _accessibilityTraversal(SemanticsNode node, List<SemanticsNode> traversal){
if (_isImportantForAccessibility(node)) {
traversal.add(node);
}
final List<SemanticsNode> children = node.debugListChildrenInOrder(DebugSemanticsDumpOrder.traversalOrder);
for (final SemanticsNode child in children) {
_traverse(child, traversal);
_accessibilityTraversal(child, traversal);
}
}
......@@ -271,6 +324,331 @@ class SemanticsController {
return false;
}
/// Performs the given [SemanticsAction] on the [SemanticsNode] found by `finder`.
///
/// If `args` are provided, they will be passed unmodified with the `action`.
/// The `checkForAction` argument allows for attempting to perform `action` on
/// `node` even if it doesn't report supporting that action. This is useful
/// for implicitly supported actions such as [SemanticsAction.showOnScreen].
void performAction(
finders.FinderBase<SemanticsNode> finder,
SemanticsAction action, {
Object? args,
bool checkForAction = true
}) {
final SemanticsNode node = finder.evaluate().single;
if (checkForAction && !node.getSemanticsData().hasAction(action)){
throw StateError(
'The given node does not support $action. If the action is implicitly '
'supported or an unsupported action is being tested for this node, '
'set `checkForAction` to false.\n'
'Node: $node'
);
}
node.owner!.performAction(node.id, action, args);
}
/// Performs a [SemanticsAction.tap] action on the [SemanticsNode] found
/// by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.tap].
void tap(finders.FinderBase<SemanticsNode> finder) {
performAction(finder, SemanticsAction.tap);
}
/// Performs a [SemanticsAction.longPress] action on the [SemanticsNode] found
/// by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.longPress].
void longPress(finders.FinderBase<SemanticsNode> finder) {
performAction(finder, SemanticsAction.longPress);
}
/// Performs a [SemanticsAction.scrollLeft] action on the [SemanticsNode]
/// found by `scrollable` or the first scrollable node in the default
/// semantics tree if no `scrollable` is provided.
///
/// Throws a [StateError] if:
/// * The given `scrollable` returns zero or more than one result.
/// * The [SemanticsNode] found with `scrollable` does not support
/// [SemanticsAction.scrollLeft].
void scrollLeft({finders.FinderBase<SemanticsNode>? scrollable}) {
performAction(scrollable ?? finders.find.semantics.scrollable(), SemanticsAction.scrollLeft);
}
/// Performs a [SemanticsAction.scrollRight] action on the [SemanticsNode]
/// found by `scrollable` or the first scrollable node in the default
/// semantics tree if no `scrollable` is provided.
///
/// Throws a [StateError] if:
/// * The given `scrollable` returns zero or more than one result.
/// * The [SemanticsNode] found with `scrollable` does not support
/// [SemanticsAction.scrollRight].
void scrollRight({finders.FinderBase<SemanticsNode>? scrollable}) {
performAction(scrollable ?? finders.find.semantics.scrollable(), SemanticsAction.scrollRight);
}
/// Performs a [SemanticsAction.scrollUp] action on the [SemanticsNode] found
/// by `scrollable` or the first scrollable node in the default semantics
/// tree if no `scrollable` is provided.
///
/// Throws a [StateError] if:
/// * The given `scrollable` returns zero or more than one result.
/// * The [SemanticsNode] found with `scrollable` does not support
/// [SemanticsAction.scrollUp].
void scrollUp({finders.FinderBase<SemanticsNode>? scrollable}) {
performAction(scrollable ?? finders.find.semantics.scrollable(), SemanticsAction.scrollUp);
}
/// Performs a [SemanticsAction.scrollDown] action on the [SemanticsNode]
/// found by `scrollable` or the first scrollable node in the default
/// semantics tree if no `scrollable` is provided.
///
/// Throws a [StateError] if:
/// * The given `scrollable` returns zero or more than one result.
/// * The [SemanticsNode] found with `scrollable` does not support
/// [SemanticsAction.scrollDown].
void scrollDown({finders.FinderBase<SemanticsNode>? scrollable}) {
performAction(scrollable ?? finders.find.semantics.scrollable(), SemanticsAction.scrollDown);
}
/// Performs a [SemanticsAction.increase] action on the [SemanticsNode]
/// found by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.increase].
void increase(finders.FinderBase<SemanticsNode> finder) {
performAction(finder, SemanticsAction.increase);
}
/// Performs a [SemanticsAction.decrease] action on the [SemanticsNode]
/// found by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.decrease].
void decrease(finders.FinderBase<SemanticsNode> finder) {
performAction(finder, SemanticsAction.decrease);
}
/// Performs a [SemanticsAction.showOnScreen] action on the [SemanticsNode]
/// found by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.showOnScreen].
void showOnScreen(finders.FinderBase<SemanticsNode> finder) {
performAction(
finder,
SemanticsAction.showOnScreen,
checkForAction: false,
);
}
/// Performs a [SemanticsAction.moveCursorForwardByCharacter] action on the
/// [SemanticsNode] found by `finder`.
///
/// If `shouldModifySelection` is true, then the cursor will begin or extend
/// a selection.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.moveCursorForwardByCharacter].
void moveCursorForwardByCharacter(
finders.FinderBase<SemanticsNode> finder, {
bool shouldModifySelection = false
}) {
performAction(
finder,
SemanticsAction.moveCursorForwardByCharacter,
args: shouldModifySelection
);
}
/// Performs a [SemanticsAction.moveCursorForwardByWord] action on the
/// [SemanticsNode] found by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.moveCursorForwardByWord].
void moveCursorForwardByWord(
finders.FinderBase<SemanticsNode> finder, {
bool shouldModifySelection = false
}) {
performAction(
finder,
SemanticsAction.moveCursorForwardByWord,
args: shouldModifySelection
);
}
/// Performs a [SemanticsAction.moveCursorBackwardByCharacter] action on the
/// [SemanticsNode] found by `finder`.
///
/// If `shouldModifySelection` is true, then the cursor will begin or extend
/// a selection.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.moveCursorBackwardByCharacter].
void moveCursorBackwardByCharacter(
finders.FinderBase<SemanticsNode> finder, {
bool shouldModifySelection = false
}) {
performAction(
finder,
SemanticsAction.moveCursorBackwardByCharacter,
args: shouldModifySelection
);
}
/// Performs a [SemanticsAction.moveCursorBackwardByWord] action on the
/// [SemanticsNode] found by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.moveCursorBackwardByWord].
void moveCursorBackwardByWord(
finders.FinderBase<SemanticsNode> finder, {
bool shouldModifySelection = false
}) {
performAction(
finder,
SemanticsAction.moveCursorBackwardByWord,
args: shouldModifySelection
);
}
/// Performs a [SemanticsAction.setText] action on the [SemanticsNode]
/// found by `finder` using the given `text`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.setText].
void setText(finders.FinderBase<SemanticsNode> finder, String text) {
performAction(finder, SemanticsAction.setText, args: text);
}
/// Performs a [SemanticsAction.setSelection] action on the [SemanticsNode]
/// found by `finder`.
///
/// The `base` parameter is the start index of selection, and the `extent`
/// parameter is the length of the selection. Each value should be limited
/// between 0 and the length of the found [SemanticsNode]'s `value`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.setSelection].
void setSelection(
finders.FinderBase<SemanticsNode> finder, {
required int base,
required int extent
}) {
performAction(
finder,
SemanticsAction.setSelection,
args: <String, int>{'base': base, 'extent': extent},
);
}
/// Performs a [SemanticsAction.copy] action on the [SemanticsNode]
/// found by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.copy].
void copy(finders.FinderBase<SemanticsNode> finder) {
performAction(finder, SemanticsAction.copy);
}
/// Performs a [SemanticsAction.cut] action on the [SemanticsNode]
/// found by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.cut].
void cut(finders.FinderBase<SemanticsNode> finder) {
performAction(finder, SemanticsAction.cut);
}
/// Performs a [SemanticsAction.paste] action on the [SemanticsNode]
/// found by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.paste].
void paste(finders.FinderBase<SemanticsNode> finder) {
performAction(finder, SemanticsAction.paste);
}
/// Performs a [SemanticsAction.didGainAccessibilityFocus] action on the
/// [SemanticsNode] found by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.didGainAccessibilityFocus].
void didGainAccessibilityFocus(finders.FinderBase<SemanticsNode> finder) {
performAction(finder, SemanticsAction.didGainAccessibilityFocus);
}
/// Performs a [SemanticsAction.didLoseAccessibilityFocus] action on the
/// [SemanticsNode] found by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.didLoseAccessibilityFocus].
void didLoseAccessibilityFocus(finders.FinderBase<SemanticsNode> finder) {
performAction(finder, SemanticsAction.didLoseAccessibilityFocus);
}
/// Performs a [SemanticsAction.customAction] action on the
/// [SemanticsNode] found by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.customAction].
void customAction(finders.FinderBase<SemanticsNode> finder, CustomSemanticsAction action) {
performAction(
finder,
SemanticsAction.customAction,
args: CustomSemanticsAction.getIdentifier(action)
);
}
/// Performs a [SemanticsAction.dismiss] action on the [SemanticsNode]
/// found by `finder`.
///
/// Throws a [StateError] if:
/// * The given `finder` returns zero or more than one result.
/// * The [SemanticsNode] found with `finder` does not support
/// [SemanticsAction.dismiss].
void dismiss(finders.FinderBase<SemanticsNode> finder) {
performAction(finder, SemanticsAction.dismiss);
}
}
/// Class that programmatically interacts with widgets.
......
......@@ -639,6 +639,26 @@ class CommonSemanticsFinders {
);
}
/// Finds any [SemanticsNode]s that can scroll in at least one direction.
///
/// If `axis` is provided, then the search will be limited to scrollable nodes
/// that can scroll in the given axis. If `axis` is not provided, then both
/// horizontal and vertical scrollable nodes will be found.
///
/// {@macro flutter_test.finders.CommonSemanticsFinders.viewParameter}
SemanticsFinder scrollable({Axis? axis, FlutterView? view}) {
return byAnyAction(<SemanticsAction>[
if (axis == null || axis == Axis.vertical) ...<SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
],
if (axis == null || axis == Axis.horizontal) ...<SemanticsAction>[
SemanticsAction.scrollLeft,
SemanticsAction.scrollRight,
],
]);
}
bool _matchesPattern(String target, Pattern pattern) {
if (pattern is RegExp) {
return pattern.hasMatch(target);
......
......@@ -600,7 +600,11 @@ AsyncMatcher matchesReferenceImage(ui.Image image) {
/// provided, then they are not part of the comparison. All of the boolean
/// flag and action fields must match, and default to false.
///
/// To retrieve the semantics data of a widget, use [WidgetTester.getSemantics]
/// To find a [SemanticsNode] directly, use [CommonFinders.semantics].
/// These methods will search the semantics tree directly and avoid the edge
/// cases that [SemanticsController.find] sometimes runs into.
///
/// To retrieve the semantics data of a widget, use [SemanticsController.find]
/// with a [Finder] that returns a single widget. Semantics must be enabled
/// in order to use this method.
///
......@@ -780,7 +784,11 @@ Matcher matchesSemantics({
/// There are no default expected values, so no unspecified values will be
/// validated.
///
/// To retrieve the semantics data of a widget, use [WidgetTester.getSemantics]
/// To find a [SemanticsNode] directly, use [CommonFinders.semantics].
/// These methods will search the semantics tree directly and avoid the edge
/// cases that [SemanticsController.find] sometimes runs into.
///
/// To retrieve the semantics data of a widget, use [SemanticsController.find]
/// with a [Finder] that returns a single widget. Semantics must be enabled
/// in order to use this method.
///
......@@ -2502,7 +2510,13 @@ class _MatchesSemanticsData extends Matcher {
return failWithDescription(matchState, 'No SemanticsData provided. '
'Maybe you forgot to enable semantics?');
}
final SemanticsData data = node is SemanticsNode ? node.getSemanticsData() : (node as SemanticsData);
final SemanticsData data = switch (node) {
SemanticsNode() => node.getSemanticsData(),
FinderBase<SemanticsNode>() => node.evaluate().single.getSemanticsData(),
_ => node as SemanticsData,
};
if (label != null && label != data.label) {
return failWithDescription(matchState, 'label was: ${data.label}');
}
......
......@@ -5,7 +5,7 @@
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stack_trace/stack_trace.dart';
......@@ -1015,6 +1015,472 @@ void main() {
);
});
});
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);
});
});
});
}
......@@ -1095,3 +1561,36 @@ class _SemanticsTestCard extends StatelessWidget {
);
}
}
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);
});
}
}
......@@ -988,6 +988,110 @@ void main() {
expect(failure.message, contains('Actual: _PredicateSemanticsFinder:<Found 2 SemanticsNodes with any of the following flags: [SemanticsFlag.isHeader, SemanticsFlag.isTextField]:'));
});
});
group('scrollable', () {
testWidgets('can find node that can scroll up', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SingleChildScrollView(
controller: controller,
child: const SizedBox(width: 100, height: 1000),
),
));
expect(find.semantics.scrollable(), containsSemantics(
hasScrollUpAction: true,
hasScrollDownAction: false,
));
});
testWidgets('can find node that can scroll down', (WidgetTester tester) async {
final ScrollController controller = ScrollController(initialScrollOffset: 400);
await tester.pumpWidget(MaterialApp(
home: SingleChildScrollView(
controller: controller,
child: const SizedBox(width: 100, height: 1000),
),
));
expect(find.semantics.scrollable(), containsSemantics(
hasScrollUpAction: false,
hasScrollDownAction: true,
));
});
testWidgets('can find node that can scroll left', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(
home: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: controller,
child: const SizedBox(width: 1000, height: 100),
),
));
expect(find.semantics.scrollable(), containsSemantics(
hasScrollLeftAction: true,
hasScrollRightAction: false,
));
});
testWidgets('can find node that can scroll right', (WidgetTester tester) async {
final ScrollController controller = ScrollController(initialScrollOffset: 200);
await tester.pumpWidget(MaterialApp(
home: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: controller,
child: const SizedBox(width: 1000, height: 100),
),
));
expect(find.semantics.scrollable(), containsSemantics(
hasScrollLeftAction: false,
hasScrollRightAction: true,
));
});
testWidgets('can exclusively find node that scrolls horizontally', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Column(
children: <Widget>[
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(width: 1000, height: 100),
),
Expanded(
child: SingleChildScrollView(
child: SizedBox(width: 100, height: 1000),
),
),
],
)
));
expect(find.semantics.scrollable(axis: Axis.horizontal), findsOne);
});
testWidgets('can exclusively find node that scrolls vertically', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: Column(
children: <Widget>[
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(width: 1000, height: 100),
),
Expanded(
child: SingleChildScrollView(
child: SizedBox(width: 100, height: 1000),
),
),
],
)
));
expect(find.semantics.scrollable(axis: Axis.vertical), findsOne);
});
});
});
group('FinderBase', () {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment