1 /*
<lambda>null2 * Copyright 2019 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.ui.test
18
19 import androidx.compose.ui.semantics.ProgressBarRangeInfo
20 import androidx.compose.ui.semantics.SemanticsActions
21 import androidx.compose.ui.semantics.SemanticsNode
22 import androidx.compose.ui.semantics.SemanticsProperties
23 import androidx.compose.ui.semantics.SemanticsProperties.EditableText
24 import androidx.compose.ui.semantics.SemanticsProperties.InputText
25 import androidx.compose.ui.semantics.SemanticsProperties.Text
26 import androidx.compose.ui.semantics.SemanticsPropertyKey
27 import androidx.compose.ui.semantics.getOrNull
28 import androidx.compose.ui.state.ToggleableState
29 import androidx.compose.ui.text.input.ImeAction
30 import androidx.compose.ui.util.fastAny
31 import kotlin.js.JsName
32
33 /**
34 * Returns whether the node is enabled.
35 *
36 * @see SemanticsProperties.Disabled
37 */
38 fun isEnabled(): SemanticsMatcher =
39 SemanticsMatcher("is enabled") { SemanticsProperties.Disabled !in it.config }
40
41 /**
42 * Returns whether the node is not enabled.
43 *
44 * @see SemanticsProperties.Disabled
45 */
isNotEnablednull46 fun isNotEnabled(): SemanticsMatcher =
47 SemanticsMatcher("is not enabled") { SemanticsProperties.Disabled in it.config }
48
49 /**
50 * Return whether the node is checkable.
51 *
52 * @see SemanticsProperties.ToggleableState
53 */
isToggleablenull54 fun isToggleable(): SemanticsMatcher = hasKey(SemanticsProperties.ToggleableState)
55
56 /**
57 * Returns whether the node is toggled.
58 *
59 * @see SemanticsProperties.ToggleableState
60 */
61 fun isOn(): SemanticsMatcher =
62 SemanticsMatcher.expectValue(SemanticsProperties.ToggleableState, ToggleableState.On)
63
64 /**
65 * Returns whether the node is not toggled.
66 *
67 * @see SemanticsProperties.ToggleableState
68 */
69 fun isOff(): SemanticsMatcher =
70 SemanticsMatcher.expectValue(SemanticsProperties.ToggleableState, ToggleableState.Off)
71
72 /**
73 * Return whether the node is selectable.
74 *
75 * @see SemanticsProperties.Selected
76 */
77 fun isSelectable(): SemanticsMatcher = hasKey(SemanticsProperties.Selected)
78
79 /**
80 * Returns whether the node is selected.
81 *
82 * @see SemanticsProperties.Selected
83 */
84 fun isSelected(): SemanticsMatcher =
85 SemanticsMatcher.expectValue(SemanticsProperties.Selected, true)
86
87 /**
88 * Returns whether the node is not selected.
89 *
90 * @see SemanticsProperties.Selected
91 */
92 fun isNotSelected(): SemanticsMatcher =
93 SemanticsMatcher.expectValue(SemanticsProperties.Selected, false)
94
95 /**
96 * Return whether the node is able to receive focus
97 *
98 * @see SemanticsProperties.Focused
99 */
100 fun isFocusable(): SemanticsMatcher = hasKey(SemanticsProperties.Focused)
101
102 /**
103 * Return whether the node is not able to receive focus.
104 *
105 * @see SemanticsProperties.Focused
106 */
107 fun isNotFocusable(): SemanticsMatcher = SemanticsMatcher.keyNotDefined(SemanticsProperties.Focused)
108
109 /**
110 * Returns whether the node is focused.
111 *
112 * @see SemanticsProperties.Focused
113 */
114 fun isFocused(): SemanticsMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Focused, true)
115
116 /**
117 * Returns whether the node is not focused.
118 *
119 * @see SemanticsProperties.Focused
120 */
121 fun isNotFocused(): SemanticsMatcher =
122 SemanticsMatcher.expectValue(SemanticsProperties.Focused, false)
123
124 /**
125 * Return whether the node has a semantics click action defined.
126 *
127 * @see SemanticsActions.OnClick
128 */
129 fun hasClickAction(): SemanticsMatcher = hasKey(SemanticsActions.OnClick)
130
131 /**
132 * Return whether the node has no semantics click action defined.
133 *
134 * @see SemanticsActions.OnClick
135 */
136 fun hasNoClickAction(): SemanticsMatcher = SemanticsMatcher.keyNotDefined(SemanticsActions.OnClick)
137
138 /**
139 * Return whether the node has a semantics scrollable action defined.
140 *
141 * @see SemanticsActions.ScrollBy
142 */
143 fun hasScrollAction(): SemanticsMatcher = hasKey(SemanticsActions.ScrollBy)
144
145 /**
146 * Return whether the node has no semantics scrollable action defined.
147 *
148 * @see SemanticsActions.ScrollBy
149 */
150 fun hasNoScrollAction(): SemanticsMatcher =
151 SemanticsMatcher.keyNotDefined(SemanticsActions.ScrollBy)
152
153 /**
154 * Returns whether the node's content description contains the given [value].
155 *
156 * Note that in merged semantics tree there can be a list of content descriptions that got merged
157 * from the child nodes. Typically an accessibility tooling will decide based on its heuristics
158 * which ones to announce.
159 *
160 * @param value Value to match as one of the items in the list of content descriptions.
161 * @param substring Whether to use substring matching.
162 * @param ignoreCase Whether case should be ignored.
163 * @see SemanticsProperties.ContentDescription
164 */
165 fun hasContentDescription(
166 value: String,
167 substring: Boolean = false,
168 ignoreCase: Boolean = false
169 ): SemanticsMatcher {
170 return if (substring) {
171 SemanticsMatcher(
172 "${SemanticsProperties.ContentDescription.name} contains '$value' " +
173 "(ignoreCase: $ignoreCase)"
174 ) {
175 it.config.getOrNull(SemanticsProperties.ContentDescription)?.any { item ->
176 item.contains(value, ignoreCase)
177 } ?: false
178 }
179 } else {
180 SemanticsMatcher(
181 "${SemanticsProperties.ContentDescription.name} = '$value' (ignoreCase: $ignoreCase)"
182 ) {
183 it.config.getOrNull(SemanticsProperties.ContentDescription)?.any { item ->
184 item.equals(value, ignoreCase)
185 } ?: false
186 }
187 }
188 }
189
190 /**
191 * Returns whether the node's content description contains exactly the given [values] and nothing
192 * else.
193 *
194 * Note that in merged semantics tree there can be a list of content descriptions that got merged
195 * from the child nodes. Typically an accessibility tooling will decide based on its heuristics
196 * which ones to announce.
197 *
198 * @param values List of values to match (the order does not matter)
199 * @see SemanticsProperties.ContentDescription
200 */
hasContentDescriptionExactlynull201 fun hasContentDescriptionExactly(vararg values: String): SemanticsMatcher {
202 val expected = values.toList()
203 return SemanticsMatcher(
204 "${SemanticsProperties.ContentDescription.name} = " + "[${values.joinToString(",")}]"
205 ) { node ->
206 node.config.getOrNull(SemanticsProperties.ContentDescription)?.let { given ->
207 given.size == expected.size &&
208 given.containsAll(expected) &&
209 expected.containsAll(given)
210 } ?: values.isEmpty()
211 }
212 }
213
214 /**
215 * Returns whether the node's text contains the given [text].
216 *
217 * This will also search in [SemanticsProperties.EditableText] and [SemanticsProperties.InputText].
218 *
219 * Note that in merged semantics tree there can be a list of text items that got merged from the
220 * child nodes. Typically an accessibility tooling will decide based on its heuristics which ones to
221 * use.
222 *
223 * @param text Value to match as one of the items in the list of text values.
224 * @param substring Whether to use substring matching.
225 * @param ignoreCase Whether case should be ignored.
226 * @see SemanticsProperties.Text
227 * @see SemanticsProperties.EditableText
228 */
hasTextnull229 fun hasText(
230 text: String,
231 substring: Boolean = false,
232 ignoreCase: Boolean = false
233 ): SemanticsMatcher {
234 val propertyName = "${Text.name} + ${InputText.name} + ${EditableText.name}"
235 return if (substring) {
236 SemanticsMatcher("$propertyName contains '$text' (ignoreCase: $ignoreCase) as substring") {
237 val isInInputTextValue =
238 it.config.getOrNull(InputText)?.text?.contains(text, ignoreCase) ?: false
239 val isInEditableTextValue =
240 it.config.getOrNull(EditableText)?.text?.contains(text, ignoreCase) ?: false
241 val isInTextValue =
242 it.config.getOrNull(Text)?.any { item -> item.text.contains(text, ignoreCase) }
243 ?: false
244 isInInputTextValue || isInEditableTextValue || isInTextValue
245 }
246 } else {
247 SemanticsMatcher("$propertyName contains '$text' (ignoreCase: $ignoreCase)") {
248 val isInInputTextValue =
249 it.config.getOrNull(InputText)?.text?.equals(text, ignoreCase) ?: false
250 val isInEditableTextValue =
251 it.config.getOrNull(EditableText)?.text?.equals(text, ignoreCase) ?: false
252 val isInTextValue =
253 it.config.getOrNull(Text)?.any { item -> item.text.equals(text, ignoreCase) }
254 ?: false
255 isInInputTextValue || isInEditableTextValue || isInTextValue
256 }
257 }
258 }
259
260 /**
261 * Returns whether the node's text contains exactly the given [textValues] and nothing else.
262 *
263 * This will also search in [SemanticsProperties.EditableText] by default.
264 *
265 * Note that in merged semantics tree there can be a list of text items that got merged from the
266 * child nodes. Typically an accessibility tooling will decide based on its heuristics which ones to
267 * use.
268 *
269 * @param textValues List of values to match (the order does not matter)
270 * @param includeEditableText Whether to also assert against the editable text
271 * @see SemanticsProperties.Text
272 * @see SemanticsProperties.EditableText
273 */
hasTextExactlynull274 fun hasTextExactly(
275 vararg textValues: String,
276 includeEditableText: Boolean = true
277 ): SemanticsMatcher {
278 val expected = textValues.toList()
279 val propertyName =
280 if (includeEditableText) {
281 "${Text.name} + ${EditableText.name}"
282 } else {
283 Text.name
284 }
285 return SemanticsMatcher("$propertyName = [${textValues.joinToString(",")}]") { node ->
286 val actual = mutableListOf<String>()
287 if (includeEditableText) {
288 node.config.getOrNull(EditableText)?.let { actual.add(it.text) }
289 }
290 node.config.getOrNull(Text)?.let { actual.addAll(it.map { anStr -> anStr.text }) }
291 actual.containsAll(expected) && expected.containsAll(actual)
292 }
293 }
294
295 /**
296 * Returns whether the node's value matches exactly to the given accessibility value.
297 *
298 * @param value Value to match.
299 * @see SemanticsProperties.StateDescription
300 */
hasStateDescriptionnull301 fun hasStateDescription(value: String): SemanticsMatcher =
302 SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, value)
303
304 /**
305 * Returns whether the node is marked as an accessibility header.
306 *
307 * @see SemanticsProperties.Heading
308 */
309 fun isHeading(): SemanticsMatcher = hasKey(SemanticsProperties.Heading)
310
311 /**
312 * Returns whether the node's range info matches exactly to the given accessibility range info.
313 *
314 * @param rangeInfo range info to match.
315 * @see SemanticsProperties.ProgressBarRangeInfo
316 */
317 fun hasProgressBarRangeInfo(rangeInfo: ProgressBarRangeInfo): SemanticsMatcher =
318 SemanticsMatcher.expectValue(SemanticsProperties.ProgressBarRangeInfo, rangeInfo)
319
320 /**
321 * Returns whether the node is annotated by the given test tag.
322 *
323 * @param testTag Value to match.
324 * @see SemanticsProperties.TestTag
325 */
326 fun hasTestTag(testTag: String): SemanticsMatcher =
327 SemanticsMatcher.expectValue(SemanticsProperties.TestTag, testTag)
328
329 /**
330 * Returns whether the node is a dialog.
331 *
332 * This only checks if the node itself is a dialog, not if it is _part of_ a dialog. Use
333 * `hasAnyAncestorThat(isDialog())` for that.
334 *
335 * @see SemanticsProperties.IsDialog
336 */
337 fun isDialog(): SemanticsMatcher = hasKey(SemanticsProperties.IsDialog)
338
339 /**
340 * Returns whether the node is a popup.
341 *
342 * This only checks if the node itself is a popup, not if it is _part of_ a popup. Use
343 * `hasAnyAncestorThat(isPopup())` for that.
344 *
345 * @see SemanticsProperties.IsPopup
346 */
347 fun isPopup(): SemanticsMatcher = hasKey(SemanticsProperties.IsPopup)
348
349 /**
350 * Returns whether the node defines the given IME action.
351 *
352 * @param actionType the action to match.
353 */
354 fun hasImeAction(actionType: ImeAction) =
355 SemanticsMatcher.expectValue(SemanticsProperties.ImeAction, actionType)
356
357 /**
358 * Returns whether the node defines a semantics action to set text on it.
359 *
360 * This can be used to, for instance, filter out text fields.
361 *
362 * @see SemanticsActions.SetText
363 */
364 fun hasSetTextAction() = hasKey(SemanticsActions.SetText)
365
366 /**
367 * Returns whether the node defines a semantics action to insert text on it.
368 *
369 * This can be used to, for instance, filter out text fields.
370 *
371 * @see SemanticsActions.InsertTextAtCursor
372 */
373 fun hasInsertTextAtCursorAction() = hasKey(SemanticsActions.InsertTextAtCursor)
374
375 /**
376 * Returns whether the node defines a semantics action to perform the
377 * [IME action][SemanticsProperties.ImeAction] on it.
378 *
379 * @see SemanticsActions.OnImeAction
380 */
381 fun hasPerformImeAction() = hasKey(SemanticsActions.OnImeAction)
382
383 /**
384 * Returns whether the node defines a semantics action to request focus.
385 *
386 * @see SemanticsActions.RequestFocus
387 */
388 fun hasRequestFocusAction() = hasKey(SemanticsActions.RequestFocus)
389
390 /**
391 * Returns whether the node defines the ability to scroll to an item index.
392 *
393 * Note that not all scrollable containers have item indices. For example, a
394 * [scrollable][androidx.compose.foundation.gestures.scrollable] doesn't have items with an index,
395 * while [LazyColumn][androidx.compose.foundation.lazy.LazyColumn] does.
396 */
397 fun hasScrollToIndexAction() = hasKey(SemanticsActions.ScrollToIndex)
398
399 /**
400 * Returns whether the node defines the ability to scroll to an item identified by a key, such as
401 * [LazyColumn][androidx.compose.foundation.lazy.LazyColumn] or
402 * [LazyRow][androidx.compose.foundation.lazy.LazyRow].
403 */
404 fun hasScrollToKeyAction() =
405 hasKey(SemanticsActions.ScrollToIndex).and(hasKey(SemanticsProperties.IndexForKey))
406
407 /** Returns whether the node defines the ability to scroll to content identified by a matcher. */
408 fun hasScrollToNodeAction() =
409 hasKey(SemanticsActions.ScrollToIndex)
410 .and(hasKey(SemanticsActions.ScrollBy))
411 .and(
412 hasKey(SemanticsProperties.HorizontalScrollAxisRange)
413 .or(hasKey(SemanticsProperties.VerticalScrollAxisRange))
414 )
415
416 /**
417 * Returns whether the node is editable.
418 *
419 * @see SemanticsProperties.IsEditable
420 */
421 fun isEditable() = SemanticsMatcher.expectValue(SemanticsProperties.IsEditable, true)
422
423 /**
424 * Return whether the node is the root semantics node.
425 *
426 * There is always one root in every node tree, added implicitly by Compose.
427 */
428 fun isRoot() = SemanticsMatcher("isRoot") { it.isRoot }
429
430 /**
431 * Returns whether the node's parent satisfies the given matcher.
432 *
433 * Returns false if no parent exists.
434 */
hasParentnull435 fun hasParent(matcher: SemanticsMatcher): SemanticsMatcher {
436 // TODO(b/150292800): If this is used in assert we should print the parent's node semantics
437 // in the error message or say that no parent was found.
438 return SemanticsMatcher("hasParentThat(${matcher.description})") {
439 it.parent?.run { matcher.matches(this) } ?: false
440 }
441 }
442
443 /** Returns whether the node has at least one child that satisfies the given matcher. */
hasAnyChildnull444 fun hasAnyChild(matcher: SemanticsMatcher): SemanticsMatcher {
445 // TODO(b/150292800): If this is used in assert we should print the children nodes semantics
446 // in the error message or say that no children were found.
447 return SemanticsMatcher("hasAnyChildThat(${matcher.description})") {
448 matcher.matchesAny(it.children)
449 }
450 }
451
452 /**
453 * Returns whether the node has at least one sibling that satisfies the given matcher.
454 *
455 * Sibling is defined as a any other node that shares the same parent.
456 */
hasAnySiblingnull457 fun hasAnySibling(matcher: SemanticsMatcher): SemanticsMatcher {
458 // TODO(b/150292800): If this is used in assert we should print the sibling nodes semantics
459 // in the error message or say that no siblings were found.
460 return SemanticsMatcher("hasAnySiblingThat(${matcher.description})") {
461 val node = it
462 it.parent?.run { matcher.matchesAny(this.children.filter { child -> child.id != node.id }) }
463 ?: false
464 }
465 }
466
467 /**
468 * Returns whether the node has at least one ancestor that satisfies the given matcher.
469 *
470 * Example: For the following tree
471 *
472 * ```
473 * |-X
474 * |-A
475 * |-B
476 * |-C1
477 * |-C2
478 * ```
479 *
480 * In case of C1, we would check the matcher against A and B
481 */
hasAnyAncestornull482 fun hasAnyAncestor(matcher: SemanticsMatcher): SemanticsMatcher {
483 // TODO(b/150292800): If this is used in assert we should print the ancestor nodes semantics
484 // in the error message or say that no ancestors were found.
485 return SemanticsMatcher("hasAnyAncestorThat(${matcher.description})") {
486 matcher.matchesAny(it.ancestors)
487 }
488 }
489
490 /**
491 * Returns whether the node has at least one descendant that satisfies the given matcher.
492 *
493 * Example: For the following tree
494 *
495 * ```
496 * |-X
497 * |-A
498 * |-B
499 * |-C1
500 * |-C2
501 * ```
502 *
503 * In case of A, we would check the matcher against B,C1 and C2
504 */
hasAnyDescendantnull505 fun hasAnyDescendant(matcher: SemanticsMatcher): SemanticsMatcher {
506 // TODO(b/150292800): If this is used in assert we could consider printing the whole subtree but
507 // it might be too much to show. But we could at least warn if there were no ancestors found.
508 fun checkIfSubtreeMatches(matcher: SemanticsMatcher, node: SemanticsNode): Boolean {
509 if (matcher.matchesAny(node.children)) {
510 return true
511 }
512
513 return node.children.fastAny { checkIfSubtreeMatches(matcher, it) }
514 }
515
516 return SemanticsMatcher("hasAnyDescendantThat(${matcher.description})") {
517 checkIfSubtreeMatches(matcher, it)
518 }
519 }
520
521 internal val SemanticsNode.ancestors: Iterable<SemanticsNode>
522 get() =
523 object : Iterable<SemanticsNode> {
iteratornull524 override fun iterator(): Iterator<SemanticsNode> {
525 return object : Iterator<SemanticsNode> {
526 @JsName("nextVar") var next = parent
527
528 override fun hasNext(): Boolean {
529 return next != null
530 }
531
532 override fun next(): SemanticsNode {
533 return next!!.also { next = it.parent }
534 }
535 }
536 }
537 }
538
hasKeynull539 private fun hasKey(key: SemanticsPropertyKey<*>): SemanticsMatcher =
540 SemanticsMatcher.keyIsDefined(key)
541