Unverified Commit 41646c95 authored by Jonah Williams's avatar Jonah Williams Committed by GitHub

Add new matcher and utility methods for testing semanics (#19046)

parent 32941a8c
......@@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
......@@ -59,4 +60,35 @@ void main() {
final Icon androidIcon = tester.widget(find.descendant(of: find.byKey(androidKey), matching: find.byType(Icon)));
expect(iOSIcon == androidIcon, false);
});
testWidgets('BackButton semantics', (WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(
new MaterialApp(
home: const Material(child: const Text('Home')),
routes: <String, WidgetBuilder>{
'/next': (BuildContext context) {
return const Material(
child: const Center(
child: const BackButton(),
),
);
},
},
),
);
tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next');
await tester.pumpAndSettle();
expect(tester.getSemanticsData(find.byType(BackButton)), matchesSemanticsData(
label: 'Back',
isButton: true,
hasEnabledState: true,
isEnabled: true,
hasTapAction: true,
));
handle.dispose();
});
}
......@@ -57,7 +57,7 @@ void main() {
});
testWidgets('CheckBox semantics', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(new Material(
child: new Checkbox(
......@@ -66,21 +66,12 @@ void main() {
),
));
expect(semantics, hasSemantics(new TestSemantics.root(
children: <TestSemantics>[
new TestSemantics.rootChild(
id: 1,
flags: <SemanticsFlag>[
SemanticsFlag.hasCheckedState,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
],
),
],
), ignoreRect: true, ignoreTransform: true));
expect(tester.getSemanticsData(find.byType(Checkbox)), matchesSemanticsData(
hasCheckedState: true,
hasEnabledState: true,
isEnabled: true,
hasTapAction: true,
));
await tester.pumpWidget(new Material(
child: new Checkbox(
......@@ -89,22 +80,13 @@ void main() {
),
));
expect(semantics, hasSemantics(new TestSemantics.root(
children: <TestSemantics>[
new TestSemantics.rootChild(
id: 1,
flags: <SemanticsFlag>[
SemanticsFlag.hasCheckedState,
SemanticsFlag.isChecked,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
],
),
],
), ignoreRect: true, ignoreTransform: true));
expect(tester.getSemanticsData(find.byType(Checkbox)), matchesSemanticsData(
hasCheckedState: true,
hasEnabledState: true,
isChecked: true,
isEnabled: true,
hasTapAction: true,
));
await tester.pumpWidget(const Material(
child: const Checkbox(
......@@ -113,17 +95,10 @@ void main() {
),
));
expect(semantics, hasSemantics(new TestSemantics.root(
children: <TestSemantics>[
new TestSemantics.rootChild(
id: 1,
flags: <SemanticsFlag>[
SemanticsFlag.hasCheckedState,
SemanticsFlag.hasEnabledState,
],
),
],
), ignoreRect: true, ignoreTransform: true));
expect(tester.getSemanticsData(find.byType(Checkbox)), matchesSemanticsData(
hasCheckedState: true,
hasEnabledState: true,
));
await tester.pumpWidget(const Material(
child: const Checkbox(
......@@ -132,24 +107,16 @@ void main() {
),
));
expect(semantics, hasSemantics(new TestSemantics.root(
children: <TestSemantics>[
new TestSemantics.rootChild(
id: 1,
flags: <SemanticsFlag>[
SemanticsFlag.hasCheckedState,
SemanticsFlag.isChecked,
SemanticsFlag.hasEnabledState,
],
),
],
), ignoreRect: true, ignoreTransform: true));
semantics.dispose();
expect(tester.getSemanticsData(find.byType(Checkbox)), matchesSemanticsData(
hasCheckedState: true,
hasEnabledState: true,
isChecked: true,
));
handle.dispose();
});
testWidgets('Can wrap CheckBox with Semantics', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(new Material(
child: new Semantics(
......@@ -162,25 +129,15 @@ void main() {
),
));
expect(semantics, hasSemantics(new TestSemantics.root(
children: <TestSemantics>[
new TestSemantics.rootChild(
id: 1,
label: 'foo',
textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[
SemanticsFlag.hasCheckedState,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
],
),
],
), ignoreRect: true, ignoreTransform: true));
semantics.dispose();
expect(tester.getSemanticsData(find.byType(Checkbox)), matchesSemanticsData(
label: 'foo',
textDirection: TextDirection.ltr,
hasCheckedState: true,
hasEnabledState: true,
isEnabled: true,
hasTapAction: true,
));
handle.dispose();
});
testWidgets('CheckBox tristate: true', (WidgetTester tester) async {
......
......@@ -5,6 +5,7 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'dart:ui';
import 'package:meta/meta.dart';
import 'package:test/test.dart' hide TypeMatcher, isInstanceOf;
......@@ -277,6 +278,145 @@ Matcher matchesGoldenFile(dynamic key) {
throw new ArgumentError('Unexpected type for golden file: ${key.runtimeType}');
}
/// Asserts that a [SemanticsData] contains the specified information.
///
/// If either the label, hint, value, textDirection, or rect fields are not
/// 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 [tester.getSemanticsData]
/// with a [Finder] that returns a single widget. Semantics must be enabled
/// in order to use this method.
///
/// ## Sample code
///
/// ```dart
/// final SemanticsHandle handle = tester.ensureSemantics();
/// final SemanticsData data = tester.getSemanticsData(find.text('hello'));
/// expect(data, matchesSemanticsData(label: 'hello'));
/// handle.dispose();
/// ```
///
/// See also:
///
/// * [WidgetTester.getSemanticsData], the tester method which retrieves data.
Matcher matchesSemanticsData({
String label,
String hint,
String value,
TextDirection textDirection,
Rect rect,
// Flags //
bool hasCheckedState = false,
bool isChecked = false,
bool isSelected = false,
bool isButton = false,
bool isFocused = false,
bool isTextField = false,
bool hasEnabledState = false,
bool isEnabled = false,
bool isInMutuallyExclusiveGroup = false,
bool isHeader = false,
bool isObscured = false,
bool namesRoute = false,
bool scopesRoute = false,
bool isHidden = false,
// Actions //
bool hasTapAction = false,
bool hasLongPressAction = false,
bool hasScrollLeftAction = false,
bool hasScrollRightAction = false,
bool hasScrollUpAction = false,
bool hasScrollDownAction = false,
bool hasIncreaseAction = false,
bool hasDecreaseAction = false,
bool hasShowOnScreenAction = false,
bool hasMoveCursorForwardByCharacterAction = false,
bool hasMoveCursorBackwardByCharacterAction = false,
bool hasSetSelectionAction = false,
bool hasCopyAction = false,
bool hasCutAction = false,
bool hasPasteAction = false,
bool hasDidGainAccessibilityFocusAction = false,
bool hasDidLoseAccessibilityFocusAction = false,
}) {
final List<SemanticsFlag> flags = <SemanticsFlag>[];
if (hasCheckedState)
flags.add(SemanticsFlag.hasCheckedState);
if (isChecked)
flags.add(SemanticsFlag.isChecked);
if (isSelected)
flags.add(SemanticsFlag.isSelected);
if (isButton)
flags.add(SemanticsFlag.isButton);
if (isTextField)
flags.add(SemanticsFlag.isTextField);
if (isFocused)
flags.add(SemanticsFlag.isFocused);
if (hasEnabledState)
flags.add(SemanticsFlag.hasEnabledState);
if (isEnabled)
flags.add(SemanticsFlag.isEnabled);
if (isInMutuallyExclusiveGroup)
flags.add(SemanticsFlag.isInMutuallyExclusiveGroup);
if (isHeader)
flags.add(SemanticsFlag.isHeader);
if (isObscured)
flags.add(SemanticsFlag.isObscured);
if (namesRoute)
flags.add(SemanticsFlag.namesRoute);
if (scopesRoute)
flags.add(SemanticsFlag.scopesRoute);
if (isHidden)
flags.add(SemanticsFlag.isHidden);
final List<SemanticsAction> actions = <SemanticsAction>[];
if (hasTapAction)
actions.add(SemanticsAction.tap);
if (hasLongPressAction)
actions.add(SemanticsAction.longPress);
if (hasScrollLeftAction)
actions.add(SemanticsAction.scrollLeft);
if (hasScrollRightAction)
actions.add(SemanticsAction.scrollRight);
if (hasScrollUpAction)
actions.add(SemanticsAction.scrollUp);
if (hasScrollDownAction)
actions.add(SemanticsAction.scrollDown);
if (hasIncreaseAction)
actions.add(SemanticsAction.increase);
if (hasDecreaseAction)
actions.add(SemanticsAction.decrease);
if (hasShowOnScreenAction)
actions.add(SemanticsAction.showOnScreen);
if (hasMoveCursorForwardByCharacterAction)
actions.add(SemanticsAction.moveCursorForwardByCharacter);
if (hasMoveCursorBackwardByCharacterAction)
actions.add(SemanticsAction.moveCursorBackwardByCharacter);
if (hasSetSelectionAction)
actions.add(SemanticsAction.setSelection);
if (hasCopyAction)
actions.add(SemanticsAction.copy);
if (hasCutAction)
actions.add(SemanticsAction.cut);
if (hasPasteAction)
actions.add(SemanticsAction.paste);
if (hasDidGainAccessibilityFocusAction)
actions.add(SemanticsAction.didGainAccessibilityFocus);
if (hasDidLoseAccessibilityFocusAction)
actions.add(SemanticsAction.didLoseAccessibilityFocus);
return new _MatchesSemanticsData(
label: label,
hint: hint,
value: value,
actions: actions,
flags: flags,
textDirection: textDirection,
rect: rect,
);
}
class _FindsWidgetMatcher extends Matcher {
const _FindsWidgetMatcher(this.min, this.max);
......@@ -1293,3 +1433,104 @@ class _MatchesGoldenFile extends AsyncMatcher {
Description describe(Description description) =>
description.add('one widget whose rasterized image matches golden image "$key"');
}
class _MatchesSemanticsData extends Matcher {
_MatchesSemanticsData({
this.label,
this.value,
this.hint,
this.flags,
this.actions,
this.textDirection,
this.rect,
});
final String label;
final String value;
final String hint;
final List<SemanticsAction> actions;
final List<SemanticsFlag> flags;
final TextDirection textDirection;
final Rect rect;
@override
Description describe(Description description) {
description.add('has semantics');
if (label != null)
description.add('with label: $label ');
if (value != null)
description.add('with value: $value ');
if (hint != null)
description.add('with hint: $hint ');
if (actions != null)
description.add('with actions:').addDescriptionOf(actions);
if (flags != null)
description.add('with flags:').addDescriptionOf(flags);
if (textDirection != null)
description.add('with textDirection: $textDirection ');
if (rect != null)
description.add('with rect: $rect');
return description;
}
@override
bool matches(covariant SemanticsData data, Map<dynamic, dynamic> matchState) {
if (data == null)
return failWithDescription(matchState, 'No SemanticsData provided. '
'Maybe you forgot to enabled semantics?');
if (label != null && label != data.label)
return failWithDescription(matchState, 'label was: ${data.label}');
if (hint != null && hint != data.hint)
return failWithDescription(matchState, 'hint was: ${data.hint}');
if (value != null && value != data.value)
return failWithDescription(matchState, 'value was: ${data.value}');
if (textDirection != null && textDirection != data.textDirection)
return failWithDescription(matchState, 'textDirection was: $textDirection');
if (rect != null && rect == data.rect) {
return failWithDescription(matchState, 'rect was: $rect');
}
if (actions != null) {
int actionBits = 0;
for (SemanticsAction action in actions)
actionBits |= action.index;
if (actionBits != data.actions) {
final List<String> actionSummary = <String>[];
for (SemanticsAction action in SemanticsAction.values.values) {
if ((data.actions & action.index) != 0)
actionSummary.add(describeEnum(action));
}
return failWithDescription(matchState, 'actions were: $actionSummary');
}
}
if (flags != null) {
int flagBits = 0;
for (SemanticsFlag flag in flags)
flagBits |= flag.index;
if (flagBits != data.flags) {
final List<String> flagSummary = <String>[];
for (SemanticsFlag flag in SemanticsFlag.values.values) {
if ((data.flags & flag.index) != 0)
flagSummary.add(describeEnum(flag));
}
return failWithDescription(matchState, 'flags were: $flagSummary');
}
}
return true;
}
bool failWithDescription(Map<dynamic, dynamic> matchState, String description) {
matchState['failure'] = description;
return false;
}
@override
Description describeMismatch(
dynamic item,
Description mismatchDescription,
Map<dynamic, dynamic> matchState,
bool verbose
) {
return mismatchDescription.add(matchState['failure']);
}
}
\ No newline at end of file
......@@ -21,6 +21,9 @@ import 'matchers.dart';
import 'test_async_utils.dart';
import 'test_text_input.dart';
/// Keep users from needing multiple imports to test semantics.
export 'package:flutter/rendering.dart' show SemanticsHandle;
export 'package:test/test.dart' hide
expect, // we have our own wrapper below
TypeMatcher, // matcher's TypeMatcher conflicts with the one in the Flutter framework
......@@ -611,6 +614,44 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker
await tap(backButton);
}
/// Attempts to find the [SemanticsData] of first result from `finder`.
///
/// If the object identified by the finder doesn't own it's semantic node,
/// this will return the semantics data of the first ancestor with semantics
/// data. The ancestor's semantic data will include the child's as well as
/// other nodes that have been merged together.
///
/// Will throw a [StateError] if the finder returns more than one element or
/// if no semantics are found or are not enabled.
SemanticsData getSemanticsData(Finder finder) {
if (binding.pipelineOwner.semanticsOwner == null)
throw new StateError('Semantics are not enabled.');
final Iterable<Element> candidates = finder.evaluate();
if (candidates.isEmpty) {
throw new StateError('Finder returned no matching elements.');
}
if (candidates.length > 1) {
throw new StateError('Finder returned more than one element.');
}
final Element element = candidates.single;
RenderObject renderObject = element.findRenderObject();
SemanticsNode result = renderObject.debugSemantics;
while (renderObject != null && result == null) {
renderObject = renderObject?.parent;
result = renderObject?.debugSemantics;
}
if (result == null)
throw new StateError('No Semantics data found.');
return result.getSemanticsData();
}
/// Enable semantics in a test by creating a [SemanticsHandle].
///
/// The handle must be disposed at the end of the test.
SemanticsHandle ensureSemantics() {
return binding.pipelineOwner.ensureSemantics();
}
}
typedef void _TickerDisposeCallback(_TestTicker ticker);
......
......@@ -5,6 +5,7 @@
import 'dart:typed_data';
import 'dart:ui';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
......@@ -382,6 +383,98 @@ void main() {
autoUpdateGoldenFiles = false;
});
});
group('matchesSemanticsData', () {
testWidgets('matches SemanticsData', (WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
const Key key = const Key('semantics');
await tester.pumpWidget(new Semantics(
key: key,
namesRoute: true,
header: true,
button: true,
onTap: () {},
label: 'foo',
hint: 'bar',
value: 'baz',
textDirection: TextDirection.rtl,
));
expect(tester.getSemanticsData(find.byKey(key)),
matchesSemanticsData(
label: 'foo',
hint: 'bar',
value: 'baz',
textDirection: TextDirection.rtl,
hasTapAction: true,
isButton: true,
isHeader: true,
namesRoute: true,
),
);
handle.dispose();
});
testWidgets('Can match all semantics flags and actions', (WidgetTester tester) async {
int actions = 0;
int flags = 0;
for (int index in SemanticsAction.values.keys)
actions |= index;
for (int index in SemanticsFlag.values.keys)
flags |= index;
final SemanticsData data = new SemanticsData(
flags: flags,
actions: actions,
label: '',
increasedValue: '',
value: '',
decreasedValue: '',
hint: '',
textDirection: TextDirection.ltr,
rect: Rect.fromLTRB(0.0, 0.0, 10.0, 10.0),
textSelection: null,
scrollPosition: null,
scrollExtentMax: null,
scrollExtentMin: null,
);
expect(data, matchesSemanticsData(
/* Flags */
hasCheckedState: true,
isChecked: true,
isSelected: true,
isButton: true,
isTextField: true,
hasEnabledState: true,
isFocused: true,
isEnabled: true,
isInMutuallyExclusiveGroup: true,
isHeader: true,
isObscured: true,
namesRoute: true,
scopesRoute: true,
isHidden: true,
/* Actions */
hasTapAction: true,
hasLongPressAction: true,
hasScrollLeftAction: true,
hasScrollRightAction: true,
hasScrollUpAction: true,
hasScrollDownAction: true,
hasIncreaseAction: true,
hasDecreaseAction: true,
hasShowOnScreenAction: true,
hasMoveCursorForwardByCharacterAction: true,
hasMoveCursorBackwardByCharacterAction: true,
hasSetSelectionAction: true,
hasCopyAction: true,
hasCutAction: true,
hasPasteAction: true,
hasDidGainAccessibilityFocusAction: true,
hasDidLoseAccessibilityFocusAction: true,
));
});
});
}
enum _ComparatorBehavior {
......
......@@ -4,9 +4,11 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:test/test.dart' as test_package;
import 'package:test/src/frontend/async_matcher.dart' show AsyncMatcher;
......@@ -527,6 +529,91 @@ void main() {
await tester.showKeyboard(find.byType(TextField));
await tester.pump();
});
group('getSemanticsData', () {
testWidgets('throws when there are no semantics', (WidgetTester tester) async {
await tester.pumpWidget(
new MaterialApp(
home: const Scaffold(
body: const Text('hello'),
),
),
);
expect(() => tester.getSemanticsData(find.text('hello')),
throwsA(isInstanceOf<StateError>()));
});
testWidgets('throws when there are multiple results from the finder', (WidgetTester tester) async {
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
await tester.pumpWidget(
new MaterialApp(
home: new Scaffold(
body: new Row(
children: const <Widget>[
const Text('hello'),
const Text('hello'),
],
),
),
),
);
expect(() => tester.getSemanticsData(find.text('hello')),
throwsA(isInstanceOf<StateError>()));
semanticsHandle.dispose();
});
testWidgets('Returns the correct SemanticsData', (WidgetTester tester) async {
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
await tester.pumpWidget(
new MaterialApp(
home: new Scaffold(
body: new Container(
child: new OutlineButton(
onPressed: () {},
child: const Text('hello')
),
),
),
),
);
final SemanticsData semantics = tester.getSemanticsData(find.text('hello'));
expect(semantics.label, 'hello');
expect(semantics.hasAction(SemanticsAction.tap), true);
expect(semantics.hasFlag(SemanticsFlag.isButton), true);
semanticsHandle.dispose();
});
testWidgets('Returns merged SemanticsData', (WidgetTester tester) async {
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
const Key key = const Key('test');
await tester.pumpWidget(
new MaterialApp(
home: new Scaffold(
body: new Semantics(
label: 'A',
child: new Semantics(
label: 'B',
child: new Semantics(
key: key,
label: 'C',
child: new Container(),
),
),
)
),
),
);
final SemanticsData semantics = tester.getSemanticsData(find.byKey(key));
expect(semantics.label, 'A\nB\nC');
semanticsHandle.dispose();
});
});
}
class FakeMatcher extends AsyncMatcher {
......
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