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.geometry.Offset
20 import androidx.compose.ui.geometry.Rect
21 import androidx.compose.ui.input.rotary.RotaryScrollEvent
22 import androidx.compose.ui.layout.boundsInParent
23 import androidx.compose.ui.layout.positionInRoot
24 import androidx.compose.ui.semantics.AccessibilityAction
25 import androidx.compose.ui.semantics.CustomAccessibilityAction
26 import androidx.compose.ui.semantics.ScrollAxisRange
27 import androidx.compose.ui.semantics.SemanticsActions
28 import androidx.compose.ui.semantics.SemanticsActions.CustomActions
29 import androidx.compose.ui.semantics.SemanticsActions.ScrollBy
30 import androidx.compose.ui.semantics.SemanticsActions.ScrollToIndex
31 import androidx.compose.ui.semantics.SemanticsNode
32 import androidx.compose.ui.semantics.SemanticsProperties
33 import androidx.compose.ui.semantics.SemanticsProperties.HorizontalScrollAxisRange
34 import androidx.compose.ui.semantics.SemanticsProperties.IndexForKey
35 import androidx.compose.ui.semantics.SemanticsProperties.VerticalScrollAxisRange
36 import androidx.compose.ui.semantics.SemanticsPropertyKey
37 import androidx.compose.ui.semantics.getOrNull
38 import androidx.compose.ui.text.AnnotatedString
39 import androidx.compose.ui.text.LinkAnnotation
40 import androidx.compose.ui.unit.LayoutDirection
41 import androidx.compose.ui.unit.toSize
42 import androidx.compose.ui.util.fastFilter
43 import androidx.compose.ui.util.fastFlatMap
44 import kotlin.jvm.JvmName
45 import kotlin.math.abs
46 import kotlin.math.sign
47
48 internal expect fun SemanticsNodeInteraction.performClickImpl(): SemanticsNodeInteraction
49
50 /**
51 * Performs a click action on the element represented by the given semantics node. Depending on the
52 * platform this may be implemented by a touch click (tap), a mouse click, or another more
53 * appropriate method for that platform.
54 *
55 * @return The [SemanticsNodeInteraction] that is the receiver of this method
56 */
57 fun SemanticsNodeInteraction.performClick(): SemanticsNodeInteraction {
58 // invokeGlobalAssertions() and tryPerformAccessibilityChecks() will be called from the
59 // implementation that uses performTouchInput or performMouseInput
60 return performClickImpl()
61 }
62
63 /**
64 * Scrolls the closest enclosing scroll parent by the smallest amount such that this node is fully
65 * visible in its viewport. If this node is larger than the viewport, scrolls the scroll parent by
66 * the smallest amount such that this node fills the entire viewport. A scroll parent is a parent
67 * node that has the semantics action [SemanticsActions.ScrollBy] (usually implemented by defining
68 * [scrollBy][androidx.compose.ui.semantics.scrollBy]).
69 *
70 * This action should be performed on the [node][SemanticsNodeInteraction] that is part of the
71 * scrollable content, not on the scrollable container.
72 *
73 * Throws an [AssertionError] if there is no scroll parent.
74 *
75 * @return The [SemanticsNodeInteraction] that is the receiver of this method
76 */
SemanticsNodeInteractionnull77 fun SemanticsNodeInteraction.performScrollTo(): SemanticsNodeInteraction {
78 tryPerformAccessibilityChecks()
79 do {
80 val shouldContinueScroll =
81 fetchSemanticsNode("Action performScrollTo() failed.")
82 .scrollToNode(testContext.testOwner)
83 } while (shouldContinueScroll)
84
85 return this
86 }
87
88 /**
89 * Implementation of [performScrollTo]
90 *
91 * @return True if we were able to scroll and a subsequent scroll might be needed and false if no
92 * scroll was needed.
93 */
SemanticsNodenull94 private fun SemanticsNode.scrollToNode(testOwner: TestOwner): Boolean {
95 val scrollableNode =
96 findClosestParentNode { hasScrollAction().matches(it) }
97 ?: throw AssertionError(
98 "Semantic Node has no parent layout with a Scroll SemanticsAction"
99 )
100
101 // Figure out the (clipped) bounds of the viewPort in its direct parent's content area, in
102 // root coordinates. We only want the clipping from the direct parent on the scrollable, not
103 // from any other ancestors.
104 val viewportInParent = scrollableNode.layoutInfo.coordinates.boundsInParent()
105 val parentInRoot =
106 scrollableNode.layoutInfo.coordinates.parentLayoutCoordinates?.positionInRoot()
107 ?: Offset.Zero
108 val viewport = viewportInParent.translate(parentInRoot)
109 val target = Rect(positionInRoot, size.toSize())
110
111 // Given the desired scroll value to align either side of the target with the
112 // viewport, what delta should we go with?
113 // If we need to scroll in opposite directions for both sides, don't scroll at all.
114 // Otherwise, take the delta that scrolls the least amount.
115 fun scrollDelta(a: Float, b: Float): Float =
116 if (sign(a) == sign(b)) if (abs(a) < abs(b)) a else b else 0f
117
118 // Get the desired delta X
119 var dx = scrollDelta(target.left - viewport.left, target.right - viewport.right)
120 // And adjust for reversing properties
121 if (scrollableNode.isReversedHorizontally) dx = -dx
122 if (scrollableNode.isRtl) dx = -dx
123
124 // Get the desired delta Y
125 var dy = scrollDelta(target.top - viewport.top, target.bottom - viewport.bottom)
126 // And adjust for reversing properties
127 if (scrollableNode.isReversedVertically) dy = -dy
128
129 if (
130 dx != 0f && scrollableNode.horizontalScrollAxis != null ||
131 dy != 0f && scrollableNode.verticalScrollAxis != null
132 ) {
133 // we have something to scroll
134 testOwner.runOnUiThread { scrollableNode.config[ScrollBy].action?.invoke(dx, dy) }
135 return true
136 } else {
137 // we don't have anything to scroll
138 return false // no need to scroll again
139 }
140 }
141
142 /**
143 * Scrolls a scrollable container with items to the item with the given [index].
144 *
145 * Note that not all scrollable containers have item indices. For example, a
146 * [scrollable][androidx.compose.foundation.gestures.scrollable] doesn't have items with an index,
147 * while [LazyColumn][androidx.compose.foundation.lazy.LazyColumn] does.
148 *
149 * This action should be performed on a [node][SemanticsNodeInteraction] that is a scrollable
150 * container, not on a node that is part of the content of that container.
151 *
152 * Throws an [AssertionError] if the node doesn't have [ScrollToIndex] defined.
153 *
154 * @param index The index of the item to scroll to
155 * @return The [SemanticsNodeInteraction] that is the receiver of this method
156 * @see hasScrollToIndexAction
157 */
SemanticsNodeInteractionnull158 fun SemanticsNodeInteraction.performScrollToIndex(index: Int): SemanticsNodeInteraction {
159 tryPerformAccessibilityChecks()
160 fetchSemanticsNode("Failed: performScrollToIndex($index)").scrollToIndex(index, this)
161 return this
162 }
163
164 /** Implementation of [performScrollToIndex] */
SemanticsNodenull165 private fun SemanticsNode.scrollToIndex(index: Int, nodeInteraction: SemanticsNodeInteraction) {
166 nodeInteraction.requireSemantics(this, ScrollToIndex) { "Failed to scroll to index $index" }
167
168 nodeInteraction.testContext.testOwner.runOnUiThread {
169 config[ScrollToIndex].action!!.invoke(index)
170 }
171 }
172
173 /**
174 * Scrolls a scrollable container with keyed items to the item with the given [key], such as
175 * [LazyColumn][androidx.compose.foundation.lazy.LazyColumn] or
176 * [LazyRow][androidx.compose.foundation.lazy.LazyRow].
177 *
178 * This action should be performed on a [node][SemanticsNodeInteraction] that is a scrollable
179 * container, not on a node that is part of the content of that container.
180 *
181 * Throws an [AssertionError] if the node doesn't have [IndexForKey] or [ScrollToIndex] defined.
182 *
183 * @param key The key of the item to scroll to
184 * @return The [SemanticsNodeInteraction] that is the receiver of this method
185 * @see hasScrollToKeyAction
186 */
performScrollToKeynull187 fun SemanticsNodeInteraction.performScrollToKey(key: Any): SemanticsNodeInteraction {
188 tryPerformAccessibilityChecks()
189 val node = fetchSemanticsNode("Failed: performScrollToKey(\"$key\")")
190 requireSemantics(node, IndexForKey, ScrollToIndex) {
191 "Failed to scroll to the item identified by \"$key\""
192 }
193
194 val index = node.config[IndexForKey].invoke(key)
195 require(index >= 0) {
196 "Failed to scroll to the item identified by \"$key\", couldn't find the key."
197 }
198
199 testContext.testOwner.runOnUiThread { node.config[ScrollToIndex].action!!.invoke(index) }
200
201 return this
202 }
203
204 /**
205 * Scrolls a scrollable container to the content that matches the given [matcher]. If the content
206 * isn't yet visible, the scrollable container will be scrolled from the start till the end till it
207 * finds the content we're looking for. It is not defined where in the viewport the content will be
208 * on success of this function, but it will be either fully within the viewport if it is smaller
209 * than the viewport, or it will cover the whole viewport if it is larger than the viewport. If it
210 * doesn't find the content, the scrollable will be left at the end of the content and an
211 * [AssertionError] is thrown.
212 *
213 * This action should be performed on a [node][SemanticsNodeInteraction] that is a scrollable
214 * container, not on a node that is part of the content of that container. If the container is a
215 * lazy container, it must support the semantics actions [ScrollToIndex], [ScrollBy], and either
216 * [HorizontalScrollAxisRange] or [VerticalScrollAxisRange], for example
217 * [LazyColumn][androidx.compose.foundation.lazy.LazyColumn] and
218 * [LazyRow][androidx.compose.foundation.lazy.LazyRow]. If the container is not lazy, it must
219 * support the semantics action [ScrollBy], for example,
220 * [Row][androidx.compose.foundation.layout.Row] or
221 * [Column][androidx.compose.foundation.layout.Column].
222 *
223 * Throws an [AssertionError] if the scrollable node doesn't support the necessary semantics
224 * actions.
225 *
226 * @param matcher A matcher that identifies the content where the scrollable container needs to
227 * scroll to
228 * @return The [SemanticsNodeInteraction] that is the receiver of this method. Note that this is
229 * _not_ an interaction for the node that is identified by the [matcher].
230 * @see hasScrollToNodeAction
231 */
SemanticsNodeInteractionnull232 fun SemanticsNodeInteraction.performScrollToNode(
233 matcher: SemanticsMatcher
234 ): SemanticsNodeInteraction {
235 tryPerformAccessibilityChecks()
236 val node = scrollToMatchingDescendantOrReturnScrollable(matcher) ?: return this
237 // If this is NOT a lazy list, but we haven't found the node above ..
238 if (!node.isLazyList) {
239 // .. throw an error that the node doesn't exist
240 val msg = "No node found that matches ${matcher.description} in scrollable container"
241 throw AssertionError(buildGeneralErrorMessage(msg, selector, node))
242 }
243
244 // Go to start of the list
245 if (!node.horizontalScrollAxis.isAtStart || !node.verticalScrollAxis.isAtStart) {
246 node.scrollToIndex(0, this)
247 }
248
249 while (true) {
250 // Fetch the node again
251 val newNode = scrollToMatchingDescendantOrReturnScrollable(matcher) ?: return this
252
253 // Are we there yet? Are we there yet? Are we there yet?
254 if (newNode.horizontalScrollAxis.isAtEnd && newNode.verticalScrollAxis.isAtEnd) {
255 // If we're finished and we haven't found the node
256 val msg = "No node found that matches ${matcher.description} in scrollable container"
257 throw AssertionError(buildGeneralErrorMessage(msg, selector, newNode))
258 }
259
260 val viewPortSize = newNode.layoutInfo.coordinates.boundsInParent().size
261 val dx = newNode.horizontalScrollAxis?.let { viewPortSize.width } ?: 0f
262 val dy = newNode.verticalScrollAxis?.let { viewPortSize.height } ?: 0f
263
264 // Scroll one screen
265 testContext.testOwner.runOnUiThread { newNode.config[ScrollBy].action?.invoke(dx, dy) }
266 }
267 }
268
269 /**
270 * Searches a descendant of the caller node that matches [matcher] and scroll to that node using
271 * [scrollToNode]. Once scroll finishes this will return null. If no descendant node matches this
272 * will return the caller node.
273 */
SemanticsNodeInteractionnull274 private fun SemanticsNodeInteraction.scrollToMatchingDescendantOrReturnScrollable(
275 matcher: SemanticsMatcher
276 ): SemanticsNode? {
277 var node = fetchSemanticsNode("Failed: performScrollToNode(${matcher.description})")
278 var matchedNode = matcher.scrollToMatchingDescendantOrReturnScrollable(node)
279 while (matchedNode != null) {
280 val shouldContinueScroll = matchedNode.scrollToNode(testContext.testOwner)
281 if (!shouldContinueScroll) return null
282 node = fetchSemanticsNode("Failed: performScrollToNode(${matcher.description})")
283 matchedNode = matcher.scrollToMatchingDescendantOrReturnScrollable(node)
284 }
285
286 return node
287 }
288
289 /**
290 * Executes the (partial) gesture specified in the given [block]. The gesture doesn't need to be
291 * complete and can be resumed in a later invocation of [performGesture]. The event time is
292 * initialized to the current time of the [MainTestClock].
293 *
294 * Be aware that if you split a gesture over multiple invocations of [performGesture], everything
295 * that happens in between will run as if the gesture is still ongoing (imagine a finger still
296 * touching the screen).
297 *
298 * All events that are injected from the [block] are batched together and sent after [block] is
299 * complete. This method blocks while the events are injected. If an error occurs during execution
300 * of [block] or injection of the events, all (subsequent) events are dropped and the error is
301 * thrown here.
302 *
303 * Due to the batching of events, all events in a block are sent together and no recomposition will
304 * take place in between events. Additionally all events will be generated before any of the events
305 * take effect. This means that the screen coordinates of all events are resolved before any of the
306 * events can cause the position of the node being injected into to change. This has certain
307 * advantages, for example, in the cases of nested scrolling or dragging an element around, it
308 * prevents the injection of events into a moving target since all events are enqueued before any of
309 * them has taken effect.
310 *
311 * Example of performing a click:
312 *
313 * @sample androidx.compose.ui.test.samples.gestureClick
314 * @param block A lambda with [GestureScope] as receiver that describes the gesture by sending all
315 * touch events.
316 * @return The [SemanticsNodeInteraction] that is the receiver of this method
317 */
318 @Deprecated(
319 message = "Replaced by performTouchInput",
320 replaceWith =
321 ReplaceWith("performTouchInput(block)", "import androidx.compose.ui.test.performGesture")
322 )
323 @Suppress("DEPRECATION")
SemanticsNodeInteractionnull324 fun SemanticsNodeInteraction.performGesture(
325 block: GestureScope.() -> Unit
326 ): SemanticsNodeInteraction {
327 val node = fetchSemanticsNode("Failed to perform a gesture.")
328 with(GestureScope(node, testContext)) {
329 try {
330 block()
331 } finally {
332 dispose()
333 }
334 }
335 return this
336 }
337
338 /**
339 * Executes the touch gesture specified in the given [block]. The gesture doesn't need to be
340 * complete and can be resumed in a later invocation of one of the `perform.*Input` methods. The
341 * event time is initialized to the current time of the [MainTestClock].
342 *
343 * Be aware that if you split a gesture over multiple invocations of `perform.*Input`, everything
344 * that happens in between will run as if the gesture is still ongoing (imagine a finger still
345 * touching the screen).
346 *
347 * All events that are injected from the [block] are batched together and sent after [block] is
348 * complete. This method blocks while the events are injected. If an error occurs during execution
349 * of [block] or injection of the events, all (subsequent) events are dropped and the error is
350 * thrown here.
351 *
352 * Due to the batching of events, all events in a block are sent together and no recomposition will
353 * take place in between events. Additionally all events will be generated before any of the events
354 * take effect. This means that the screen coordinates of all events are resolved before any of the
355 * events can cause the position of the node being injected into to change. This has certain
356 * advantages, for example, in the cases of nested scrolling or dragging an element around, it
357 * prevents the injection of events into a moving target since all events are enqueued before any of
358 * them has taken effect.
359 *
360 * Example of performing a swipe up:
361 *
362 * @sample androidx.compose.ui.test.samples.touchInputSwipeUp
363 *
364 * Example of performing an off-center click:
365 *
366 * @sample androidx.compose.ui.test.samples.touchInputClickOffCenter
367 *
368 * Example of doing an assertion during a click:
369 *
370 * @sample androidx.compose.ui.test.samples.touchInputAssertDuringClick
371 *
372 * Example of performing a click-and-drag:
373 *
374 * @sample androidx.compose.ui.test.samples.touchInputClickAndDrag
375 * @param block A lambda with [TouchInjectionScope] as receiver that describes the gesture by
376 * sending all touch events.
377 * @return The [SemanticsNodeInteraction] that is the receiver of this method
378 * @see TouchInjectionScope
379 */
SemanticsNodeInteractionnull380 fun SemanticsNodeInteraction.performTouchInput(
381 block: TouchInjectionScope.() -> Unit
382 ): SemanticsNodeInteraction {
383 tryPerformAccessibilityChecks()
384 val node = fetchSemanticsNode("Failed to inject touch input.")
385 with(MultiModalInjectionScopeImpl(node, testContext)) {
386 try {
387 touch(block)
388 } finally {
389 dispose()
390 }
391 }
392 return this
393 }
394
395 /**
396 * Executes the mouse gesture specified in the given [block]. The gesture doesn't need to be
397 * complete and can be resumed in a later invocation of one of the `perform.*Input` methods. The
398 * event time is initialized to the current time of the [MainTestClock].
399 *
400 * Be aware that if you split a gesture over multiple invocations of `perform.*Input`, everything
401 * that happens in between will run as if the gesture is still ongoing (imagine a mouse button still
402 * being pressed).
403 *
404 * All events that are injected from the [block] are batched together and sent after [block] is
405 * complete. This method blocks while the events are injected. If an error occurs during execution
406 * of [block] or injection of the events, all (subsequent) events are dropped and the error is
407 * thrown here.
408 *
409 * Due to the batching of events, all events in a block are sent together and no recomposition will
410 * take place in between events. Additionally all events will be generated before any of the events
411 * take effect. This means that the screen coordinates of all events are resolved before any of the
412 * events can cause the position of the node being injected into to change. This has certain
413 * advantages, for example, in the cases of nested scrolling or dragging an element around, it
414 * prevents the injection of events into a moving target since all events are enqueued before any of
415 * them has taken effect.
416 *
417 * Example of performing a mouse click:
418 *
419 * @sample androidx.compose.ui.test.samples.mouseInputClick
420 *
421 * Example of scrolling the mouse wheel while the mouse button is pressed:
422 *
423 * @sample androidx.compose.ui.test.samples.mouseInputScrollWhileDown
424 * @param block A lambda with [MouseInjectionScope] as receiver that describes the gesture by
425 * sending all mouse events.
426 * @return The [SemanticsNodeInteraction] that is the receiver of this method
427 * @see MouseInjectionScope
428 */
SemanticsNodeInteractionnull429 fun SemanticsNodeInteraction.performMouseInput(
430 block: MouseInjectionScope.() -> Unit
431 ): SemanticsNodeInteraction {
432 tryPerformAccessibilityChecks()
433 val node = fetchSemanticsNode("Failed to inject mouse input.")
434 with(MultiModalInjectionScopeImpl(node, testContext)) {
435 try {
436 mouse(block)
437 } finally {
438 dispose()
439 }
440 }
441 return this
442 }
443
444 /**
445 * Executes the key input gesture specified in the given [block]. The gesture doesn't need to be
446 * complete and can be resumed in a later invocation of one of the `perform.*Input` methods. The
447 * event time is initialized to the current time of the [MainTestClock].
448 *
449 * All events that are injected from the [block] are batched together and sent after [block] is
450 * complete. This method blocks while the events are injected. If an error occurs during execution
451 * of [block] or injection of the events, all (subsequent) events are dropped and the error is
452 * thrown here.
453 *
454 * Due to the batching of events, all events in a block are sent together and no recomposition will
455 * take place in between events. Additionally all events will be generated before any of the events
456 * take effect. This means that the screen coordinates of all events are resolved before any of the
457 * events can cause the position of the node being injected into to change. This has certain
458 * advantages, for example, in the cases of nested scrolling or dragging an element around, it
459 * prevents the injection of events into a moving target since all events are enqueued before any of
460 * them has taken effect.
461 *
462 * @param block A lambda with [KeyInjectionScope] as receiver that describes the gesture by sending
463 * all key press events.
464 * @return The [SemanticsNodeInteraction] that is the receiver of this method
465 * @see KeyInjectionScope
466 */
467 @ExperimentalTestApi
SemanticsNodeInteractionnull468 fun SemanticsNodeInteraction.performKeyInput(
469 block: KeyInjectionScope.() -> Unit
470 ): SemanticsNodeInteraction {
471 tryPerformAccessibilityChecks()
472 val node = fetchSemanticsNode("Failed to inject key input.")
473 with(MultiModalInjectionScopeImpl(node, testContext)) {
474 try {
475 key(block)
476 } finally {
477 dispose()
478 }
479 }
480 return this
481 }
482
483 /**
484 * Executes the multi-modal gesture specified in the given [block]. The gesture doesn't need to be
485 * complete and can be resumed in a later invocation of one of the `perform.*Input` methods. The
486 * event time is initialized to the current time of the [MainTestClock]. If only a single modality
487 * is needed (e.g. touch, mouse, stylus, keyboard, etc), you should use the `perform.*Input` of that
488 * modality instead.
489 *
490 * Functions for each modality can be called by invoking that modality's function, like
491 * [touch][MultiModalInjectionScope.touch] to inject touch events. This allows you to inject events
492 * for each modality.
493 *
494 * Be aware that if you split a gesture over multiple invocations of `perform.*Input`, everything
495 * that happens in between will run as if the gesture is still ongoing (imagine a finger still
496 * touching the screen).
497 *
498 * All events that are injected from the [block] are batched together and sent after [block] is
499 * complete. This method blocks while the events are injected. If an error occurs during execution
500 * of [block] or injection of the events, all (subsequent) events are dropped and the error is
501 * thrown here.
502 *
503 * Due to the batching of events, all events in a block are sent together and no recomposition will
504 * take place in between events. Additionally all events will be generated before any of the events
505 * take effect. This means that the screen coordinates of all events are resolved before any of the
506 * events can cause the position of the node being injected into to change. This has certain
507 * advantages, for example, in the cases of nested scrolling or dragging an element around, it
508 * prevents the injection of events into a moving target since all events are enqueued before any of
509 * them has taken effect.
510 *
511 * @param block A lambda with [MultiModalInjectionScope] as receiver that describes the gesture by
512 * sending all multi modal events.
513 * @return The [SemanticsNodeInteraction] that is the receiver of this method
514 * @see MultiModalInjectionScope
515 */
516 // TODO(fresen): add example of multi-modal input when key input is added (touch and mouse
517 // don't work together, so an example with those two doesn't make sense)
SemanticsNodeInteractionnull518 fun SemanticsNodeInteraction.performMultiModalInput(
519 block: MultiModalInjectionScope.() -> Unit
520 ): SemanticsNodeInteraction {
521 val node = fetchSemanticsNode("Failed to inject multi-modal input.")
522 with(MultiModalInjectionScopeImpl(node, testContext)) {
523 try {
524 block.invoke(this)
525 } finally {
526 dispose()
527 }
528 }
529 return this
530 }
531
532 /**
533 * Requests the focus system to give focus to this node by invoking the
534 * [RequestFocus][SemanticsActions.RequestFocus] semantics action.
535 */
SemanticsNodeInteractionnull536 fun SemanticsNodeInteraction.requestFocus(): SemanticsNodeInteraction =
537 performSemanticsAction(SemanticsActions.RequestFocus)
538
539 @Deprecated(
540 message = "Replaced with same function, but with SemanticsNodeInteraction as return type",
541 level = DeprecationLevel.HIDDEN
542 )
543 @Suppress("unused")
544 @JvmName("performSemanticsAction")
545 fun <T : Function<Boolean>> SemanticsNodeInteraction.performSemanticsActionUnit(
546 key: SemanticsPropertyKey<AccessibilityAction<T>>,
547 invocation: (T) -> Unit
548 ) {
549 performSemanticsAction(key, invocation)
550 }
551
552 /**
553 * Provides support to call custom semantics actions on this node.
554 *
555 * This method is supposed to be used for actions with parameters.
556 *
557 * This will properly verify that the actions exists and provide clear error message in case it does
558 * not. It also handle synchronization and performing the action on the UI thread. This call is
559 * blocking until the action is performed
560 *
561 * @param key Key of the action to be performed.
562 * @param invocation Place where you call your action. In the argument is provided the underlying
563 * action from the given Semantics action.
564 * @return The [SemanticsNodeInteraction] that is the receiver of this method
565 * @throws AssertionError If the semantics action is not defined on this node.
566 */
performSemanticsActionnull567 fun <T : Function<Boolean>> SemanticsNodeInteraction.performSemanticsAction(
568 key: SemanticsPropertyKey<AccessibilityAction<T>>,
569 invocation: (T) -> Unit
570 ): SemanticsNodeInteraction {
571 val node = fetchSemanticsNode("Failed to perform ${key.name} action.")
572 requireSemantics(node, key) { "Failed to perform action ${key.name}" }
573
574 testContext.testOwner.runOnUiThread { node.config[key].action?.let(invocation) }
575
576 return this
577 }
578
579 @Deprecated(
580 message = "Replaced with same function, but with SemanticsNodeInteraction as return type",
581 level = DeprecationLevel.HIDDEN
582 )
583 @Suppress("unused")
584 @JvmName("performSemanticsAction")
SemanticsNodeInteractionnull585 fun SemanticsNodeInteraction.performSemanticsActionUnit(
586 key: SemanticsPropertyKey<AccessibilityAction<() -> Boolean>>
587 ) {
588 performSemanticsAction(key)
589 }
590
591 /**
592 * Provides support to call custom semantics actions on this node.
593 *
594 * This method is for calling actions that have no parameters.
595 *
596 * This will properly verify that the actions exists and provide clear error message in case it does
597 * not. It also handle synchronization and performing the action on the UI thread. This call is
598 * blocking until the action is performed
599 *
600 * @param key Key of the action to be performed.
601 * @return The [SemanticsNodeInteraction] that is the receiver of this method
602 * @throws AssertionError If the semantics action is not defined on this node.
603 */
SemanticsNodeInteractionnull604 fun SemanticsNodeInteraction.performSemanticsAction(
605 key: SemanticsPropertyKey<AccessibilityAction<() -> Boolean>>
606 ): SemanticsNodeInteraction {
607 return performSemanticsAction(key) { it.invoke() }
608 }
609
610 /**
611 * Send the specified [RotaryScrollEvent] to the focused component.
612 *
613 * @return true if the event was consumed. False otherwise.
614 */
615 @ExperimentalTestApi
SemanticsNodeInteractionnull616 fun SemanticsNodeInteraction.performRotaryScrollInput(
617 block: RotaryInjectionScope.() -> Unit
618 ): SemanticsNodeInteraction {
619 tryPerformAccessibilityChecks()
620 val node = fetchSemanticsNode("Failed to send rotary Event")
621 with(MultiModalInjectionScopeImpl(node, testContext)) {
622 try {
623 rotary(block)
624 } finally {
625 dispose()
626 }
627 }
628 return this
629 }
630
631 /**
632 * Finds the [CustomAccessibilityAction] in the node's [CustomActions] list whose label is equal to
633 * [label] and then invokes it.
634 *
635 * To use your own logic to find the action to perform instead of matching on the full label, use
636 * [performCustomAccessibilityActionWithLabelMatching].
637 *
638 * @param label The exact label of the [CustomAccessibilityAction] to perform.
639 * @throws AssertionError If no [SemanticsNode] is found, or no [CustomAccessibilityAction] has
640 * [label], or more than one [CustomAccessibilityAction] has [label].
641 * @see performCustomAccessibilityActionWithLabelMatching
642 */
643 @ExperimentalTestApi
SemanticsNodeInteractionnull644 fun SemanticsNodeInteraction.performCustomAccessibilityActionWithLabel(
645 label: String
646 ): SemanticsNodeInteraction =
647 performCustomAccessibilityActionWithLabelMatching("label is \"$label\"") { it == label }
648
649 /**
650 * Finds the [CustomAccessibilityAction] in the node's [CustomActions] list whose label satisfies a
651 * predicate function and then invokes it.
652 *
653 * @param predicateDescription A description of [labelPredicate] that will be included in the error
654 * message if zero or >1 actions match.
655 * @param labelPredicate A predicate function used to select the [CustomAccessibilityAction] to
656 * perform.
657 * @throws AssertionError If no [SemanticsNode] is found, or no [CustomAccessibilityAction] matches
658 * [labelPredicate], or more than one [CustomAccessibilityAction] matches [labelPredicate].
659 * @see performCustomAccessibilityActionWithLabel
660 */
661 @ExperimentalTestApi
SemanticsNodeInteractionnull662 fun SemanticsNodeInteraction.performCustomAccessibilityActionWithLabelMatching(
663 predicateDescription: String? = null,
664 labelPredicate: (label: String) -> Boolean
665 ): SemanticsNodeInteraction {
666 val node = fetchSemanticsNode()
667 val actions = node.config[CustomActions]
668 val matchingActions = actions.filter { labelPredicate(it.label) }
669 if (matchingActions.isEmpty()) {
670 throw AssertionError(
671 buildGeneralErrorMessage(
672 "No custom accessibility actions matched [$predicateDescription].",
673 selector,
674 node
675 )
676 )
677 } else if (matchingActions.size > 1) {
678 throw AssertionError(
679 buildGeneralErrorMessage(
680 "Expected exactly one custom accessibility action to match" +
681 " [$predicateDescription], but found ${matchingActions.size}.",
682 selector,
683 node
684 )
685 )
686 }
687 matchingActions[0].action()
688 return this
689 }
690
691 /**
692 * For a first link matching the [predicate] performs a click on it.
693 *
694 * A link in a Text composable is defined by a [LinkAnnotation] of the [AnnotatedString].
695 *
696 * @sample androidx.compose.ui.test.samples.touchInputOnFirstSpecificLinkInText
697 * @see getFirstLinkBounds
698 */
SemanticsNodeInteractionnull699 fun SemanticsNodeInteraction.performFirstLinkClick(
700 predicate: (AnnotatedString.Range<LinkAnnotation>) -> Boolean = { true }
701 ): SemanticsNodeInteraction {
702 tryPerformAccessibilityChecks()
703
704 val errorMessage = "Failed to click the link."
705 val node = fetchSemanticsNode(errorMessage)
706
707 val texts = node.config.getOrNull(SemanticsProperties.Text)
708 if (texts.isNullOrEmpty()) {
709 throw AssertionError("$errorMessage\n Reason: No text found on node.")
710 }
textnull711 val linksInTexts = texts.fastFlatMap { text -> text.getLinkAnnotations(0, text.length) }
<lambda>null712 val linkChildren = node.children.fastFilter { it.isLink() }
713 val matchedLinkIndex = linksInTexts.indexOfFirst(predicate)
714 if (matchedLinkIndex != -1) {
715 linkChildren[matchedLinkIndex].config.getOrNull(SemanticsActions.OnClick)?.action?.invoke()
716 } else {
717 throw AssertionError("$errorMessage\n Reason: No link found that matches the predicate.")
718 }
719 return this
720 }
721
722 /**
723 * Tries to perform accessibility checks on the current screen. This will only actually do something
724 * if (1) accessibility checks are enabled and (2) accessibility checks are implemented for the
725 * platform on which the test runs.
726 *
727 * @throws [AssertionError] if accessibility problems are found
728 */
tryPerformAccessibilityChecksnull729 expect fun SemanticsNodeInteraction.tryPerformAccessibilityChecks(): SemanticsNodeInteraction
730
731 /**
732 * Tries to perform accessibility checks on the current screen. This will only actually do something
733 * if (1) accessibility checks are enabled and (2) accessibility checks are implemented for the
734 * platform on which the test runs.
735 *
736 * @throws [AssertionError] if accessibility problems are found
737 */
738 fun SemanticsNodeInteractionCollection.tryPerformAccessibilityChecks():
739 SemanticsNodeInteractionCollection {
740 // Accessibility checks don't run on one node only, they run on the whole hierarchy. It doesn't
741 // matter where we start, so just run them on the first node.
742 onFirst().tryPerformAccessibilityChecks()
743 return this
744 }
745
isLinknull746 private fun SemanticsNode.isLink(): Boolean {
747 return config.contains(SemanticsProperties.LinkTestMarker)
748 }
749
750 // TODO(200928505): get a more accurate indication if it is a lazy list
751 private val SemanticsNode.isLazyList: Boolean
752 get() = ScrollBy in config && ScrollToIndex in config
753
754 private val SemanticsNode.horizontalScrollAxis: ScrollAxisRange?
755 get() = config.getOrNull(HorizontalScrollAxisRange)
756
757 private val SemanticsNode.verticalScrollAxis: ScrollAxisRange?
758 get() = config.getOrNull(VerticalScrollAxisRange)
759
760 private val SemanticsNode.isReversedHorizontally: Boolean
761 get() = horizontalScrollAxis?.reverseScrolling ?: false
762
763 private val SemanticsNode.isReversedVertically: Boolean
764 get() = verticalScrollAxis?.reverseScrolling ?: false
765
766 private val ScrollAxisRange?.isAtStart: Boolean
<lambda>null767 get() = this?.let { value() == 0f } ?: true
768
769 private val ScrollAxisRange?.isAtEnd: Boolean
<lambda>null770 get() = this?.let { value() == maxValue() } ?: true
771
772 private val SemanticsNode.isRtl: Boolean
773 get() = layoutInfo.layoutDirection == LayoutDirection.Rtl
774
SemanticsNodeInteractionnull775 private fun SemanticsNodeInteraction.requireSemantics(
776 node: SemanticsNode,
777 vararg properties: SemanticsPropertyKey<*>,
778 errorMessage: () -> String
779 ) {
780 val missingProperties = properties.filter { it !in node.config }
781 if (missingProperties.isNotEmpty()) {
782 val msg =
783 "${errorMessage()}, the node is missing [${
784 missingProperties.joinToString { it.name }
785 }]"
786 throw AssertionError(buildGeneralErrorMessage(msg, selector, node))
787 }
788 }
789
790 @Suppress("NOTHING_TO_INLINE") // Avoids doubling the stack depth for recursive search
scrollToMatchingDescendantOrReturnScrollablenull791 private inline fun SemanticsMatcher.scrollToMatchingDescendantOrReturnScrollable(
792 root: SemanticsNode
793 ): SemanticsNode? {
794 return root.children.firstOrNull { it.layoutInfo.isPlaced && findMatchInHierarchy(it) != null }
795 }
796
findMatchInHierarchynull797 private fun SemanticsMatcher.findMatchInHierarchy(node: SemanticsNode): SemanticsNode? {
798 return if (matches(node)) node else scrollToMatchingDescendantOrReturnScrollable(node)
799 }
800