@@ -23,6 +23,202 @@ const double kDragSlopDefault = 20.0;
23
23
24
24
const String _defaultPlatform = kIsWeb ? 'web' : 'android' ;
25
25
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
+
26
222
/// Class that programmatically interacts with widgets.
27
223
///
28
224
/// For a variant of this class suited specifically for unit tests, see
@@ -32,11 +228,30 @@ const String _defaultPlatform = kIsWeb ? 'web' : 'android';
32
228
/// Concrete subclasses must implement the [pump] method.
33
229
abstract class WidgetController {
34
230
/// Creates a widget controller that uses the given binding.
35
- WidgetController (this .binding);
231
+ WidgetController (this .binding)
232
+ : _semantics = SemanticsController ._(binding);
36
233
37
234
/// A reference to the current instance of the binding.
38
235
final WidgetsBinding binding;
39
236
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
+
40
255
// FINDER API
41
256
42
257
// TODO(ianh): verify that the return values are of type T and throw
@@ -1257,29 +1472,8 @@ abstract class WidgetController {
1257
1472
///
1258
1473
/// Will throw a [StateError] if the finder returns more than one element or
1259
1474
/// 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);
1283
1477
1284
1478
/// Enable semantics in a test by creating a [SemanticsHandle] .
1285
1479
///
0 commit comments