Skip to content

Commit 671c532

Browse files
107866: Add support for verifying SemanticsNode ordering in widget tests (#113133)
1 parent e334ac1 commit 671c532

File tree

3 files changed

+524
-155
lines changed

3 files changed

+524
-155
lines changed

packages/flutter/lib/src/widgets/basic.dart

+1-1
Original file line numberDiff line numberDiff line change
@@ -6848,7 +6848,7 @@ class MetaData extends SingleChildRenderObjectWidget {
68486848
/// A widget that annotates the widget tree with a description of the meaning of
68496849
/// the widgets.
68506850
///
6851-
/// Used by accessibility tools, search engines, and other semantic analysis
6851+
/// Used by assitive technologies, search engines, and other semantic analysis
68526852
/// software to determine the meaning of the application.
68536853
///
68546854
/// {@youtube 560 315 https://www.youtube.com/watch?v=NvtMt_DtFrQ}

packages/flutter_test/lib/src/controller.dart

+218-24
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,202 @@ const double kDragSlopDefault = 20.0;
2323

2424
const String _defaultPlatform = kIsWeb ? 'web' : 'android';
2525

26+
/// Class that programatically interacts with the [Semantics] tree.
27+
///
28+
/// Allows for testing of the [Semantics] tree, which is used by assistive
29+
/// technology, search engines, and other analysis software to determine the
30+
/// meaning of an application.
31+
///
32+
/// Should be accessed through [WidgetController.semantics]. If no custom
33+
/// implementation is provided, a default [SemanticsController] will be created.
34+
class SemanticsController {
35+
/// Creates a [SemanticsController] that uses the given binding. Will be
36+
/// automatically created as part of instantiating a [WidgetController], but
37+
/// a custom implementation can be passed via the [WidgetController] constructor.
38+
SemanticsController._(WidgetsBinding binding) : _binding = binding;
39+
40+
static final int _scrollingActions =
41+
SemanticsAction.scrollUp.index |
42+
SemanticsAction.scrollDown.index |
43+
SemanticsAction.scrollLeft.index |
44+
SemanticsAction.scrollRight.index;
45+
46+
/// 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).
47+
static final int _importantFlagsForAccessibility =
48+
SemanticsFlag.hasCheckedState.index |
49+
SemanticsFlag.hasToggledState.index |
50+
SemanticsFlag.hasEnabledState.index |
51+
SemanticsFlag.isButton.index |
52+
SemanticsFlag.isTextField.index |
53+
SemanticsFlag.isFocusable.index |
54+
SemanticsFlag.isSlider.index |
55+
SemanticsFlag.isInMutuallyExclusiveGroup.index;
56+
57+
final WidgetsBinding _binding;
58+
59+
/// Attempts to find the [SemanticsNode] of first result from `finder`.
60+
///
61+
/// If the object identified by the finder doesn't own its semantic node,
62+
/// this will return the semantics data of the first ancestor with semantics.
63+
/// The ancestor's semantic data will include the child's as well as
64+
/// other nodes that have been merged together.
65+
///
66+
/// If the [SemanticsNode] of the object identified by the finder is
67+
/// force-merged into an ancestor (e.g. via the [MergeSemantics] widget)
68+
/// the node into which it is merged is returned. That node will include
69+
/// all the semantics information of the nodes merged into it.
70+
///
71+
/// Will throw a [StateError] if the finder returns more than one element or
72+
/// if no semantics are found or are not enabled.
73+
SemanticsNode find(Finder finder) {
74+
TestAsyncUtils.guardSync();
75+
if (_binding.pipelineOwner.semanticsOwner == null) {
76+
throw StateError('Semantics are not enabled.');
77+
}
78+
final Iterable<Element> candidates = finder.evaluate();
79+
if (candidates.isEmpty) {
80+
throw StateError('Finder returned no matching elements.');
81+
}
82+
if (candidates.length > 1) {
83+
throw StateError('Finder returned more than one element.');
84+
}
85+
final Element element = candidates.single;
86+
RenderObject? renderObject = element.findRenderObject();
87+
SemanticsNode? result = renderObject?.debugSemantics;
88+
while (renderObject != null && (result == null || result.isMergedIntoParent)) {
89+
renderObject = renderObject.parent as RenderObject?;
90+
result = renderObject?.debugSemantics;
91+
}
92+
if (result == null) {
93+
throw StateError('No Semantics data found.');
94+
}
95+
return result;
96+
}
97+
98+
/// Simulates a traversal of the currently visible semantics tree as if by
99+
/// assistive technologies.
100+
///
101+
/// Starts at the node for `start`. If `start` is not provided, then the
102+
/// traversal begins with the first accessible node in the tree. If `start`
103+
/// finds zero elements or more than one element, a [StateError] will be
104+
/// thrown.
105+
///
106+
/// Ends at the node for `end`, inclusive. If `end` is not provided, then the
107+
/// traversal ends with the last accessible node in the currently available
108+
/// tree. If `end` finds zero elements or more than one element, a
109+
/// [StateError] will be thrown.
110+
///
111+
/// Since the order is simulated, edge cases that differ between platforms
112+
/// (such as how the last visible item in a scrollable list is handled) may be
113+
/// inconsistent with platform behavior, but are expected to be sufficient for
114+
/// testing order, availability to assistive technologies, and interactions.
115+
///
116+
/// ## Sample Code
117+
///
118+
/// ```
119+
/// testWidgets('MyWidget', (WidgetTester tester) async {
120+
/// await tester.pumpWidget(MyWidget());
121+
///
122+
/// expect(
123+
/// tester.semantics.simulatedAccessibilityTraversal(),
124+
/// containsAllInOrder([
125+
/// containsSemantics(label: 'My Widget'),
126+
/// containsSemantics(label: 'is awesome!', isChecked: true),
127+
/// ]),
128+
/// );
129+
/// });
130+
/// ```
131+
///
132+
/// See also:
133+
///
134+
/// * [containsSemantics] and [matchesSemantics], which can be used to match
135+
/// against a single node in the traversal
136+
/// * [containsAllInOrder], which can be given an [Iterable<Matcher>] to fuzzy
137+
/// match the order allowing extra nodes before after and between matching
138+
/// parts of the traversal
139+
/// * [orderedEquals], which can be given an [Iterable<Matcher>] to exactly
140+
/// match the order of the traversal
141+
Iterable<SemanticsNode> simulatedAccessibilityTraversal({Finder? start, Finder? end}) {
142+
TestAsyncUtils.guardSync();
143+
final List<SemanticsNode> traversal = <SemanticsNode>[];
144+
_traverse(_binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!, traversal);
145+
146+
int startIndex = 0;
147+
int endIndex = traversal.length - 1;
148+
149+
if (start != null) {
150+
final SemanticsNode startNode = find(start);
151+
startIndex = traversal.indexOf(startNode);
152+
if (startIndex == -1) {
153+
throw StateError(
154+
'The expected starting node was not found.\n'
155+
'Finder: ${start.description}\n\n'
156+
'Expected Start Node: $startNode\n\n'
157+
'Traversal: [\n ${traversal.join('\n ')}\n]');
158+
}
159+
}
160+
161+
if (end != null) {
162+
final SemanticsNode endNode = find(end);
163+
endIndex = traversal.indexOf(endNode);
164+
if (endIndex == -1) {
165+
throw StateError(
166+
'The expected ending node was not found.\n'
167+
'Finder: ${end.description}\n\n'
168+
'Expected End Node: $endNode\n\n'
169+
'Traversal: [\n ${traversal.join('\n ')}\n]');
170+
}
171+
}
172+
173+
return traversal.getRange(startIndex, endIndex + 1);
174+
}
175+
176+
/// Recursive depth first traversal of the specified `node`, adding nodes
177+
/// that are important for semantics to the `traversal` list.
178+
void _traverse(SemanticsNode node, List<SemanticsNode> traversal){
179+
if (_isImportantForAccessibility(node)) {
180+
traversal.add(node);
181+
}
182+
183+
final List<SemanticsNode> children = node.debugListChildrenInOrder(DebugSemanticsDumpOrder.traversalOrder);
184+
for (final SemanticsNode child in children) {
185+
_traverse(child, traversal);
186+
}
187+
}
188+
189+
/// Whether or not the node is important for semantics. Should match most cases
190+
/// on the platforms, but certain edge cases will be inconsisent.
191+
///
192+
/// Based on:
193+
///
194+
/// * [flutter/engine/AccessibilityBridge.java#SemanticsNode.isFocusable()](https://github.com/flutter/engine/blob/main/shell/platform/android/io/flutter/view/AccessibilityBridge.java#L2641)
195+
/// * [flutter/engine/SemanticsObject.mm#SemanticsObject.isAccessibilityElement](https://github.com/flutter/engine/blob/main/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm#L449)
196+
bool _isImportantForAccessibility(SemanticsNode node) {
197+
// If the node scopes a route, it doesn't matter what other flags/actions it
198+
// has, it is _not_ important for accessibility, so we short circuit.
199+
if (node.hasFlag(SemanticsFlag.scopesRoute)) {
200+
return false;
201+
}
202+
203+
final bool hasNonScrollingAction = node.getSemanticsData().actions & ~_scrollingActions != 0;
204+
if (hasNonScrollingAction) {
205+
return true;
206+
}
207+
208+
final bool hasImportantFlag = node.getSemanticsData().flags & _importantFlagsForAccessibility != 0;
209+
if (hasImportantFlag) {
210+
return true;
211+
}
212+
213+
final bool hasContent = node.label.isNotEmpty || node.value.isNotEmpty || node.hint.isNotEmpty;
214+
if (hasContent) {
215+
return true;
216+
}
217+
218+
return false;
219+
}
220+
}
221+
26222
/// Class that programmatically interacts with widgets.
27223
///
28224
/// For a variant of this class suited specifically for unit tests, see
@@ -32,11 +228,30 @@ const String _defaultPlatform = kIsWeb ? 'web' : 'android';
32228
/// Concrete subclasses must implement the [pump] method.
33229
abstract class WidgetController {
34230
/// Creates a widget controller that uses the given binding.
35-
WidgetController(this.binding);
231+
WidgetController(this.binding)
232+
: _semantics = SemanticsController._(binding);
36233

37234
/// A reference to the current instance of the binding.
38235
final WidgetsBinding binding;
39236

237+
/// Provides access to a [SemanticsController] for testing anything related to
238+
/// the [Semantics] tree.
239+
///
240+
/// Assistive technologies, search engines, and other analysis tools all make
241+
/// use of the [Semantics] tree to determine the meaning of an application.
242+
/// If semantics has been disabled for the test, this will throw a [StateError].
243+
SemanticsController get semantics {
244+
if (binding.pipelineOwner.semanticsOwner == null) {
245+
throw StateError(
246+
'Semantics are not enabled. Enable them by passing '
247+
'`semanticsEnabled: true` to `testWidgets`, or by manually creating a '
248+
'`SemanticsHandle` with `WidgetController.ensureSemantics()`.');
249+
}
250+
251+
return _semantics;
252+
}
253+
final SemanticsController _semantics;
254+
40255
// FINDER API
41256

42257
// TODO(ianh): verify that the return values are of type T and throw
@@ -1257,29 +1472,8 @@ abstract class WidgetController {
12571472
///
12581473
/// Will throw a [StateError] if the finder returns more than one element or
12591474
/// if no semantics are found or are not enabled.
1260-
SemanticsNode getSemantics(Finder finder) {
1261-
if (binding.pipelineOwner.semanticsOwner == null) {
1262-
throw StateError('Semantics are not enabled.');
1263-
}
1264-
final Iterable<Element> candidates = finder.evaluate();
1265-
if (candidates.isEmpty) {
1266-
throw StateError('Finder returned no matching elements.');
1267-
}
1268-
if (candidates.length > 1) {
1269-
throw StateError('Finder returned more than one element.');
1270-
}
1271-
final Element element = candidates.single;
1272-
RenderObject? renderObject = element.findRenderObject();
1273-
SemanticsNode? result = renderObject?.debugSemantics;
1274-
while (renderObject != null && (result == null || result.isMergedIntoParent)) {
1275-
renderObject = renderObject.parent as RenderObject?;
1276-
result = renderObject?.debugSemantics;
1277-
}
1278-
if (result == null) {
1279-
throw StateError('No Semantics data found.');
1280-
}
1281-
return result;
1282-
}
1475+
// TODO(pdblasi-google): Deprecate this and point references to semantics.find. See https://github.com/flutter/flutter/issues/112670.
1476+
SemanticsNode getSemantics(Finder finder) => semantics.find(finder);
12831477

12841478
/// Enable semantics in a test by creating a [SemanticsHandle].
12851479
///

0 commit comments

Comments
 (0)