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