Unverified Commit 671c5320 authored by pdblasi-google's avatar pdblasi-google Committed by GitHub

107866: Add support for verifying SemanticsNode ordering in widget tests (#113133)

parent e334ac11
......@@ -6848,7 +6848,7 @@ class MetaData extends SingleChildRenderObjectWidget {
/// A widget that annotates the widget tree with a description of the meaning of
/// the widgets.
///
/// Used by accessibility tools, search engines, and other semantic analysis
/// Used by assitive technologies, search engines, and other semantic analysis
/// software to determine the meaning of the application.
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=NvtMt_DtFrQ}
......
......@@ -23,6 +23,202 @@ const double kDragSlopDefault = 20.0;
const String _defaultPlatform = kIsWeb ? 'web' : 'android';
/// Class that programatically interacts with the [Semantics] tree.
///
/// Allows for testing of the [Semantics] tree, which is used by assistive
/// technology, search engines, and other analysis software to determine the
/// meaning of an application.
///
/// Should be accessed through [WidgetController.semantics]. If no custom
/// implementation is provided, a default [SemanticsController] will be created.
class SemanticsController {
/// Creates a [SemanticsController] that uses the given binding. Will be
/// automatically created as part of instantiating a [WidgetController], but
/// a custom implementation can be passed via the [WidgetController] constructor.
SemanticsController._(WidgetsBinding binding) : _binding = binding;
static final int _scrollingActions =
SemanticsAction.scrollUp.index |
SemanticsAction.scrollDown.index |
SemanticsAction.scrollLeft.index |
SemanticsAction.scrollRight.index;
/// Based on Android's FOCUSABLE_FLAGS. See [flutter/engine/AccessibilityBridge.java](https://github.com/flutter/engine/blob/main/shell/platform/android/io/flutter/view/AccessibilityBridge.java).
static final int _importantFlagsForAccessibility =
SemanticsFlag.hasCheckedState.index |
SemanticsFlag.hasToggledState.index |
SemanticsFlag.hasEnabledState.index |
SemanticsFlag.isButton.index |
SemanticsFlag.isTextField.index |
SemanticsFlag.isFocusable.index |
SemanticsFlag.isSlider.index |
SemanticsFlag.isInMutuallyExclusiveGroup.index;
final WidgetsBinding _binding;
/// Attempts to find the [SemanticsNode] of first result from `finder`.
///
/// If the object identified by the finder doesn't own its semantic node,
/// this will return the semantics data of the first ancestor with semantics.
/// The ancestor's semantic data will include the child's as well as
/// other nodes that have been merged together.
///
/// If the [SemanticsNode] of the object identified by the finder is
/// force-merged into an ancestor (e.g. via the [MergeSemantics] widget)
/// the node into which it is merged is returned. That node will include
/// all the semantics information of the nodes merged into it.
///
/// Will throw a [StateError] if the finder returns more than one element or
/// if no semantics are found or are not enabled.
SemanticsNode find(Finder finder) {
TestAsyncUtils.guardSync();
if (_binding.pipelineOwner.semanticsOwner == null) {
throw StateError('Semantics are not enabled.');
}
final Iterable<Element> candidates = finder.evaluate();
if (candidates.isEmpty) {
throw StateError('Finder returned no matching elements.');
}
if (candidates.length > 1) {
throw 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 || result.isMergedIntoParent)) {
renderObject = renderObject.parent as RenderObject?;
result = renderObject?.debugSemantics;
}
if (result == null) {
throw StateError('No Semantics data found.');
}
return result;
}
/// 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.
///
/// 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.
///
/// Since the order is simulated, edge cases that differ between platforms
/// (such as how the last visible item in a scrollable list is handled) may be
/// inconsistent with platform behavior, but are expected to be sufficient for
/// testing order, availability to assistive technologies, and interactions.
///
/// ## Sample Code
///
/// ```
/// testWidgets('MyWidget', (WidgetTester tester) async {
/// await tester.pumpWidget(MyWidget());
///
/// expect(
/// tester.semantics.simulatedAccessibilityTraversal(),
/// containsAllInOrder([
/// containsSemantics(label: 'My Widget'),
/// containsSemantics(label: 'is awesome!', isChecked: true),
/// ]),
/// );
/// });
/// ```
///
/// See also:
///
/// * [containsSemantics] and [matchesSemantics], which can be used to match
/// against a single node in the traversal
/// * [containsAllInOrder], which can be given an [Iterable<Matcher>] to fuzzy
/// match the order allowing extra nodes before after and between matching
/// parts of the traversal
/// * [orderedEquals], which can be given an [Iterable<Matcher>] to exactly
/// match the order of the traversal
Iterable<SemanticsNode> simulatedAccessibilityTraversal({Finder? start, Finder? end}) {
TestAsyncUtils.guardSync();
final List<SemanticsNode> traversal = <SemanticsNode>[];
_traverse(_binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!, traversal);
int startIndex = 0;
int endIndex = traversal.length - 1;
if (start != null) {
final SemanticsNode startNode = find(start);
startIndex = traversal.indexOf(startNode);
if (startIndex == -1) {
throw StateError(
'The expected starting node was not found.\n'
'Finder: ${start.description}\n\n'
'Expected Start Node: $startNode\n\n'
'Traversal: [\n ${traversal.join('\n ')}\n]');
}
}
if (end != null) {
final SemanticsNode endNode = find(end);
endIndex = traversal.indexOf(endNode);
if (endIndex == -1) {
throw StateError(
'The expected ending node was not found.\n'
'Finder: ${end.description}\n\n'
'Expected End Node: $endNode\n\n'
'Traversal: [\n ${traversal.join('\n ')}\n]');
}
}
return traversal.getRange(startIndex, endIndex + 1);
}
/// 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){
if (_isImportantForAccessibility(node)) {
traversal.add(node);
}
final List<SemanticsNode> children = node.debugListChildrenInOrder(DebugSemanticsDumpOrder.traversalOrder);
for (final SemanticsNode child in children) {
_traverse(child, traversal);
}
}
/// Whether or not the node is important for semantics. Should match most cases
/// on the platforms, but certain edge cases will be inconsisent.
///
/// Based on:
///
/// * [flutter/engine/AccessibilityBridge.java#SemanticsNode.isFocusable()](https://github.com/flutter/engine/blob/main/shell/platform/android/io/flutter/view/AccessibilityBridge.java#L2641)
/// * [flutter/engine/SemanticsObject.mm#SemanticsObject.isAccessibilityElement](https://github.com/flutter/engine/blob/main/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm#L449)
bool _isImportantForAccessibility(SemanticsNode node) {
// If the node scopes a route, it doesn't matter what other flags/actions it
// has, it is _not_ important for accessibility, so we short circuit.
if (node.hasFlag(SemanticsFlag.scopesRoute)) {
return false;
}
final bool hasNonScrollingAction = node.getSemanticsData().actions & ~_scrollingActions != 0;
if (hasNonScrollingAction) {
return true;
}
final bool hasImportantFlag = node.getSemanticsData().flags & _importantFlagsForAccessibility != 0;
if (hasImportantFlag) {
return true;
}
final bool hasContent = node.label.isNotEmpty || node.value.isNotEmpty || node.hint.isNotEmpty;
if (hasContent) {
return true;
}
return false;
}
}
/// Class that programmatically interacts with widgets.
///
/// For a variant of this class suited specifically for unit tests, see
......@@ -32,11 +228,30 @@ const String _defaultPlatform = kIsWeb ? 'web' : 'android';
/// Concrete subclasses must implement the [pump] method.
abstract class WidgetController {
/// Creates a widget controller that uses the given binding.
WidgetController(this.binding);
WidgetController(this.binding)
: _semantics = SemanticsController._(binding);
/// A reference to the current instance of the binding.
final WidgetsBinding binding;
/// Provides access to a [SemanticsController] for testing anything related to
/// the [Semantics] tree.
///
/// Assistive technologies, search engines, and other analysis tools all make
/// use of the [Semantics] tree to determine the meaning of an application.
/// If semantics has been disabled for the test, this will throw a [StateError].
SemanticsController get semantics {
if (binding.pipelineOwner.semanticsOwner == null) {
throw StateError(
'Semantics are not enabled. Enable them by passing '
'`semanticsEnabled: true` to `testWidgets`, or by manually creating a '
'`SemanticsHandle` with `WidgetController.ensureSemantics()`.');
}
return _semantics;
}
final SemanticsController _semantics;
// FINDER API
// TODO(ianh): verify that the return values are of type T and throw
......@@ -1257,29 +1472,8 @@ abstract class WidgetController {
///
/// Will throw a [StateError] if the finder returns more than one element or
/// if no semantics are found or are not enabled.
SemanticsNode getSemantics(Finder finder) {
if (binding.pipelineOwner.semanticsOwner == null) {
throw StateError('Semantics are not enabled.');
}
final Iterable<Element> candidates = finder.evaluate();
if (candidates.isEmpty) {
throw StateError('Finder returned no matching elements.');
}
if (candidates.length > 1) {
throw 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 || result.isMergedIntoParent)) {
renderObject = renderObject.parent as RenderObject?;
result = renderObject?.debugSemantics;
}
if (result == null) {
throw StateError('No Semantics data found.');
}
return result;
}
// TODO(pdblasi-google): Deprecate this and point references to semantics.find. See https://github.com/flutter/flutter/issues/112670.
SemanticsNode getSemantics(Finder finder) => semantics.find(finder);
/// Enable semantics in a test by creating a [SemanticsHandle].
///
......
......@@ -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:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
......@@ -20,136 +21,6 @@ class TestDragData {
}
void main() {
group('getSemanticsData', () {
testWidgets('throws when there are no semantics', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: Text('hello'),
),
),
);
expect(() => tester.getSemantics(find.text('hello')), throwsStateError);
}, semanticsEnabled: false);
testWidgets('throws when there are multiple results from the finder', (WidgetTester tester) async {
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Row(
children: const <Widget>[
Text('hello'),
Text('hello'),
],
),
),
),
);
expect(() => tester.getSemantics(find.text('hello')), throwsStateError);
semanticsHandle.dispose();
});
testWidgets('Returns the correct SemanticsData', (WidgetTester tester) async {
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: OutlinedButton(
onPressed: () { },
child: const Text('hello'),
),
),
),
);
final SemanticsNode node = tester.getSemantics(find.text('hello'));
final SemanticsData semantics = node.getSemanticsData();
expect(semantics.label, 'hello');
expect(semantics.hasAction(SemanticsAction.tap), true);
expect(semantics.hasFlag(SemanticsFlag.isButton), true);
semanticsHandle.dispose();
});
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.getSemantics(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 {
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
const Key key = Key('test');
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Semantics(
label: 'A',
child: Semantics(
label: 'B',
child: Semantics(
key: key,
label: 'C',
child: Container(),
),
),
),
),
),
);
final SemanticsNode node = tester.getSemantics(find.byKey(key));
final SemanticsData semantics = node.getSemanticsData();
expect(semantics.label, 'A\nB\nC');
semanticsHandle.dispose();
});
testWidgets('Does not return partial semantics', (WidgetTester tester) async {
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
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.getSemantics(find.byKey(key));
final SemanticsData semantics = node.getSemanticsData();
expect(semantics.label, 'A\nB');
semanticsHandle.dispose();
});
});
testWidgets(
'WidgetTester.drag must break the offset into multiple parallel components if '
'the drag goes outside the touch slop values',
......@@ -805,4 +676,308 @@ void main() {
expect(find.text('Item b-45'), findsOneWidget);
});
});
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(
MaterialApp(
home: Scaffold(
body: Row(
children: const <Widget>[
Text('hello'),
Text('hello'),
],
),
),
),
);
expect(() => tester.semantics.find(find.text('hello')), throwsStateError);
});
testWidgets('Returns the correct SemanticsData', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: OutlinedButton(
onPressed: () { },
child: const Text('hello'),
),
),
),
);
final SemanticsNode node = tester.semantics.find(find.text('hello'));
final SemanticsData semantics = node.getSemanticsData();
expect(semantics.label, 'hello');
expect(semantics.hasAction(SemanticsAction.tap), true);
expect(semantics.hasFlag(SemanticsFlag.isButton), true);
});
testWidgets('Can enable semantics for tests via semanticsEnabled', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: OutlinedButton(
onPressed: () { },
child: const Text('hello'),
),
),
),
);
final SemanticsNode node = tester.semantics.find(find.text('hello'));
final SemanticsData semantics = node.getSemanticsData();
expect(semantics.label, 'hello');
expect(semantics.hasAction(SemanticsAction.tap), true);
expect(semantics.hasFlag(SemanticsFlag.isButton), true);
});
testWidgets('Returns merged SemanticsData', (WidgetTester tester) async {
const Key key = Key('test');
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Semantics(
label: 'A',
child: Semantics(
label: 'B',
child: Semantics(
key: key,
label: 'C',
child: Container(),
),
),
),
),
),
);
final SemanticsNode node = tester.semantics.find(find.byKey(key));
final SemanticsData semantics = node.getSemanticsData();
expect(semantics.label, 'A\nB\nC');
});
testWidgets('Does not return partial semantics', (WidgetTester tester) async {
final Key key = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MergeSemantics(
child: Semantics(
container: true,
label: 'A',
child: Semantics(
container: true,
key: key,
label: 'B',
child: Container(),
),
),
),
),
),
);
final SemanticsNode node = tester.semantics.find(find.byKey(key));
final SemanticsData semantics = node.getSemanticsData();
expect(semantics.label, 'A\nB');
});
});
group('simulatedTraversal', () {
final List<Matcher> fullTraversalMatchers = <Matcher>[
containsSemantics(isHeader: true, label: 'Semantics Test'),
containsSemantics(isTextField: true),
containsSemantics(label: 'Off Switch'),
containsSemantics(hasToggledState: true),
containsSemantics(label: 'On Switch'),
containsSemantics(hasToggledState: true, isToggled: true),
containsSemantics(label: "Multiline\nIt's a\nmultiline label!"),
containsSemantics(label: 'Slider'),
containsSemantics(isSlider: true, value: '50%'),
containsSemantics(label: 'Enabled Button'),
containsSemantics(isButton: true, label: 'Tap'),
containsSemantics(label: 'Disabled Button'),
containsSemantics(isButton: true, label: "Don't Tap"),
containsSemantics(label: 'Checked Radio'),
containsSemantics(hasCheckedState: true, isChecked: true),
containsSemantics(label: 'Unchecked Radio'),
containsSemantics(hasCheckedState: true, isChecked: false),
];
testWidgets('produces expected traversal', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget()));
expect(
tester.semantics.simulatedAccessibilityTraversal(),
orderedEquals(fullTraversalMatchers));
});
testWidgets('starts traversal at semantics node for `start`', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget()));
// We're expecting the traversal to start where the slider is.
final List<Matcher> expectedMatchers = <Matcher>[...fullTraversalMatchers]..removeRange(0, 8);
expect(
tester.semantics.simulatedAccessibilityTraversal(start: find.byType(Slider)),
orderedEquals(expectedMatchers));
});
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 accessiblity, so it won't show up in the traversal
expect(
() => tester.semantics.simulatedAccessibilityTraversal(start: find.byType(SingleChildScrollView)),
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, 9);
expect(
tester.semantics.simulatedAccessibilityTraversal(end: find.byType(Slider)),
orderedEquals(expectedMatchers));
});
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('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, 9);
expect(
tester.semantics.simulatedAccessibilityTraversal(
start: find.byType(TextField),
end: find.byType(Slider),
),
orderedEquals(expectedMatchers));
});
testWidgets('can do fuzzy traversal match with `containsAllInOrder`', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: _SemanticsTestWidget()));
// Grab a sample of the matchers to validate that not every matcher is
// needed to validate a traversal when using `containsAllInOrder`.
final Iterable<Matcher> expectedMatchers = <Matcher>[...fullTraversalMatchers]
..removeAt(0)
..removeLast()
..mapIndexed<Matcher?>((int i, Matcher element) => i.isEven ? element : null)
.whereNotNull();
expect(
tester.semantics.simulatedAccessibilityTraversal(),
containsAllInOrder(expectedMatchers));
});
});
});
}
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: 'TextField',
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),
),
);
}
}
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