Unverified Commit 5e27ebbe authored by Dan Field's avatar Dan Field Committed by GitHub

Add semantic label finders (#29342)

* Add semantic label finders
parent 79e3bf4a
......@@ -135,6 +135,7 @@ abstract class SerializableFinder {
case 'ByType': return ByType.deserialize(json);
case 'ByValueKey': return ByValueKey.deserialize(json);
case 'ByTooltipMessage': return ByTooltipMessage.deserialize(json);
case 'BySemanticsLabel': return BySemanticsLabel.deserialize(json);
case 'ByText': return ByText.deserialize(json);
case 'PageBack': return PageBack();
}
......@@ -164,6 +165,44 @@ class ByTooltipMessage extends SerializableFinder {
}
}
/// A Flutter Driver finder that finds widgets by semantic label.
///
/// If the [label] property is a [String], the finder will try to find an exact
/// match. If it is a [RegExp], it will return true for [RegExp.hasMatch].
class BySemanticsLabel extends SerializableFinder {
/// Creates a semantic label finder given the [label].
BySemanticsLabel(this.label);
/// A [Pattern] matching the [Semantics.properties.label].
///
/// If this is a [String], it will be treated as an exact match.
final Pattern label;
@override
final String finderType = 'BySemanticsLabel';
@override
Map<String, String> serialize() {
if (label is RegExp) {
final RegExp regExp = label;
return super.serialize()..addAll(<String, String>{
'label': regExp.pattern,
'isRegExp': 'true',
});
} else {
return super.serialize()..addAll(<String, String>{
'label': label,
});
}
}
/// Deserializes the finder from JSON generated by [serialize].
static BySemanticsLabel deserialize(Map<String, String> json) {
final bool isRegExp = json['isRegExp'] == 'true';
return BySemanticsLabel(isRegExp ? RegExp(json['label']) : json['label']);
}
}
/// A Flutter Driver finder that finds widgets by [text] inside a [Text] or
/// [EditableText] widget.
class ByText extends SerializableFinder {
......
......@@ -939,6 +939,9 @@ class CommonFinders {
/// Finds widgets with a tooltip with the given [message].
SerializableFinder byTooltip(String message) => ByTooltipMessage(message);
/// Finds widgets with the given semantics [label].
SerializableFinder bySemanticsLabel(Pattern label) => BySemanticsLabel(label);
/// Finds widgets whose class name matches the given string.
SerializableFinder byType(String type) => ByType(type);
......
......@@ -135,6 +135,7 @@ class FlutterDriverExtension {
_finders.addAll(<String, FinderConstructor>{
'ByText': (SerializableFinder finder) => _createByTextFinder(finder),
'ByTooltipMessage': (SerializableFinder finder) => _createByTooltipMessageFinder(finder),
'BySemanticsLabel': (SerializableFinder finder) => _createBySemanticsLabelFinder(finder),
'ByValueKey': (SerializableFinder finder) => _createByValueKeyFinder(finder),
'ByType': (SerializableFinder finder) => _createByTypeFinder(finder),
'PageBack': (SerializableFinder finder) => _createPageBackFinder(),
......@@ -262,6 +263,22 @@ class FlutterDriverExtension {
}, description: 'widget with text tooltip "${arguments.text}"');
}
Finder _createBySemanticsLabelFinder(BySemanticsLabel arguments) {
return find.byElementPredicate((Element element) {
if (element is! RenderObjectElement) {
return false;
}
final String semanticsLabel = element.renderObject?.debugSemantics?.label;
if (semanticsLabel == null) {
return false;
}
final Pattern label = arguments.label;
return label is RegExp
? label.hasMatch(semanticsLabel)
: label == semanticsLabel;
}, description: 'widget with semantic label "${arguments.label}"');
}
Finder _createByValueKeyFinder(ByValueKey arguments) {
switch (arguments.keyValueType) {
case 'int':
......
......@@ -158,6 +158,35 @@ void main() {
});
});
group('BySemanticsLabel', () {
test('finds by Semantic label using String', () async {
when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) {
expect(i.positionalArguments[1], <String, String>{
'command': 'tap',
'timeout': _kSerializedTestTimeout,
'finderType': 'BySemanticsLabel',
'label': 'foo',
});
return makeMockResponse(<String, dynamic>{});
});
await driver.tap(find.bySemanticsLabel('foo'), timeout: _kTestTimeout);
});
test('finds by Semantic label using RegExp', () async {
when(mockIsolate.invokeExtension(any, any)).thenAnswer((Invocation i) {
expect(i.positionalArguments[1], <String, String>{
'command': 'tap',
'timeout': _kSerializedTestTimeout,
'finderType': 'BySemanticsLabel',
'label': '^foo',
'isRegExp': 'true',
});
return makeMockResponse(<String, dynamic>{});
});
await driver.tap(find.bySemanticsLabel(RegExp('^foo')), timeout: _kTestTimeout);
});
});
group('tap', () {
test('requires a target reference', () async {
expect(driver.tap(null), throwsA(isInstanceOf<DriverError>()));
......
......@@ -272,6 +272,51 @@ class CommonFinders {
Finder ancestor({ Finder of, Finder matching, bool matchRoot = false }) {
return _AncestorFinder(of, matching, matchRoot: matchRoot);
}
/// Finds [Semantics] widgets matching the given `label`, either by
/// [RegExp.hasMatch] or string equality.
///
/// The framework may combine semantics labels in certain scenarios, such as
/// when multiple [Text] widgets are in a [MaterialButton] widget. In such a
/// case, it may be preferable to match by regular expression. Consumers of
/// this API __must not__ introduce unsuitable content into the semantics tree
/// for the purposes of testing; in particular, you should prefer matching by
/// regular expression rather than by string if the framework has combined
/// your semantics, and not try to force the framework to break up the
/// semantics nodes. Breaking up the nodes would have an undesirable effect on
/// screen readers and other accessibility services.
///
/// ## Sample code
///
/// ```dart
/// expect(find.BySemanticsLabel('Back'), findsOneWidget);
/// ```
///
/// If the `skipOffstage` argument is true (the default), then this skips
/// nodes that are [Offstage] or that are from inactive [Route]s.
Finder bySemanticsLabel(Pattern label, { bool skipOffstage = true }) {
if (WidgetsBinding.instance.pipelineOwner.semanticsOwner == null)
throw StateError('Semantics are not enabled. '
'Make sure to call tester.enableSemantics() before using '
'this finder, and call dispose on its return value after.');
return byElementPredicate(
(Element element) {
// Multiple elements can have the same renderObject - we want the "owner"
// of the renderObject, i.e. the RenderObjectElement.
if (element is! RenderObjectElement) {
return false;
}
final String semanticsLabel = element.renderObject?.debugSemantics?.label;
if (semanticsLabel == null) {
return false;
}
return label is RegExp
? label.hasMatch(semanticsLabel)
: label == semanticsLabel;
},
skipOffstage: skipOffstage,
);
}
}
/// Searches a widget tree and returns nodes that match a particular
......
......@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/rendering.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
......@@ -29,6 +30,53 @@ void main() {
});
});
group('semantics', () {
testWidgets('Throws StateError if semantics are not enabled', (WidgetTester tester) async {
expect(() => find.bySemanticsLabel('Add'), throwsStateError);
});
testWidgets('finds Semantically labeled widgets', (WidgetTester tester) async {
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate(
Semantics(
label: 'Add',
button: true,
child: const FlatButton(
child: Text('+'),
onPressed: null,
),
),
));
expect(find.bySemanticsLabel('Add'), findsOneWidget);
semanticsHandle.dispose();
});
testWidgets('finds Semantically labeled widgets by RegExp', (WidgetTester tester) async {
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate(
Semantics(
container: true,
child: Row(children: const <Widget>[
Text('Hello'),
Text('World'),
]),
),
));
expect(find.bySemanticsLabel('Hello'), findsNothing);
expect(find.bySemanticsLabel(RegExp(r'^Hello')), findsOneWidget);
semanticsHandle.dispose();
});
testWidgets('finds Semantically labeled widgets without explicit Semantics', (WidgetTester tester) async {
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
await tester.pumpWidget(_boilerplate(
const SimpleCustomSemanticsWidget('Foo')
));
expect(find.bySemanticsLabel('Foo'), findsOneWidget);
semanticsHandle.dispose();
});
});
group('hitTestable', () {
testWidgets('excludes non-hit-testable widgets', (WidgetTester tester) async {
await tester.pumpWidget(
......@@ -92,3 +140,28 @@ Widget _boilerplate(Widget child) {
child: child,
);
}
class SimpleCustomSemanticsWidget extends LeafRenderObjectWidget {
const SimpleCustomSemanticsWidget(this.label);
final String label;
@override
RenderObject createRenderObject(BuildContext context) => SimpleCustomSemanticsRenderObject(label);
}
class SimpleCustomSemanticsRenderObject extends RenderBox {
SimpleCustomSemanticsRenderObject(this.label);
final String label;
@override
bool get sizedByParent => true;
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config..label = label..textDirection = TextDirection.ltr;
}
}
\ No newline at end of file
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