1 /*
2  * 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.SemanticsNode
20 
21 /**
22  * Represents a semantics node and the path to fetch it from the semantics tree. One can interact
23  * with this node by performing actions such as [performClick], assertions such as
24  * [assertHasClickAction], or navigate to other nodes such as [onChildren].
25  *
26  * An instance of [SemanticsNodeInteraction] can be obtained from
27  * [onNode][SemanticsNodeInteractionsProvider.onNode] and convenience methods that use a specific
28  * filter, such as [onNodeWithText].
29  *
30  * Here you can see how you can locate a checkbox, click it and verify that it's checked:
31  *
32  * @sample androidx.compose.ui.test.samples.clickAndVerifyCheckbox
33  *
34  * [useUnmergedTree] is for tests with a special need to inspect implementation detail within
35  * children. For example:
36  *
37  * @sample androidx.compose.ui.test.samples.useUnmergedTree
38  */
39 class SemanticsNodeInteraction
40 constructor(
41     internal val testContext: TestContext,
42     internal val useUnmergedTree: Boolean,
43     internal val selector: SemanticsSelector
44 ) {
45     constructor(
46         testContext: TestContext,
47         useUnmergedTree: Boolean,
48         matcher: SemanticsMatcher
49     ) : this(testContext, useUnmergedTree, SemanticsSelector(matcher))
50 
51     /**
52      * Anytime we refresh semantics we capture it here. This is then presented to the user in case
53      * their tests fails deu to a missing node. This helps to see what was the last state of the
54      * node before it disappeared. We dump it to string because trying to dump the node later can
55      * result in failure as it gets detached from its layout.
56      */
57     private var lastSeenSemantics: String? = null
58 
fetchSemanticsNodesnull59     internal fun fetchSemanticsNodes(
60         atLeastOneRootRequired: Boolean,
61         errorMessageOnFail: String? = null,
62         skipDeactivatedNodes: Boolean = true
63     ): SelectionResult =
64         testContext.testOwner.getAllSemanticsNodes(
65             atLeastOneRootRequired = atLeastOneRootRequired,
66             useUnmergedTree = useUnmergedTree,
67             skipDeactivatedNodes = skipDeactivatedNodes
68         ) {
69             selector.map(it, errorMessageOnFail.orEmpty())
70         }
71 
72     /**
73      * Returns the semantics node captured by this object.
74      *
75      * Note: Accessing this object involves synchronization with your UI. If you are accessing this
76      * multiple times in one atomic operation, it is better to cache the result instead of calling
77      * this API multiple times.
78      *
79      * This will fail if there is 0 or multiple nodes matching.
80      *
81      * @throws AssertionError if 0 or multiple nodes found.
82      */
fetchSemanticsNodenull83     fun fetchSemanticsNode(errorMessageOnFail: String? = null): SemanticsNode {
84         return fetchOneOrThrow(errorMessageOnFail)
85     }
86 
87     /**
88      * Asserts that no item was found or that the item is no longer in the hierarchy.
89      *
90      * This will synchronize with the UI and fetch all the nodes again to ensure it has latest data.
91      *
92      * @throws [AssertionError] if the assert fails.
93      */
assertDoesNotExistnull94     fun assertDoesNotExist() {
95         val result =
96             fetchSemanticsNodes(
97                 atLeastOneRootRequired = false,
98                 errorMessageOnFail = "Failed: assertDoesNotExist."
99             )
100         if (result.selectedNodes.isNotEmpty()) {
101             throw AssertionError(
102                 buildErrorMessageForCountMismatch(
103                     errorMessage = "Failed: assertDoesNotExist.",
104                     selector = selector,
105                     foundNodes = result.selectedNodes,
106                     expectedCount = 0
107                 )
108             )
109         }
110     }
111 
112     /**
113      * Asserts that the component was found and is part of the component tree.
114      *
115      * This will synchronize with the UI and fetch all the nodes again to ensure it has latest data.
116      * If you are using [fetchSemanticsNode] you don't need to call this. In fact you would just
117      * introduce additional overhead.
118      *
119      * @param errorMessageOnFail Error message prefix to be added to the message in case this
120      *   asserts fails. This is typically used by operations that rely on this assert. Example
121      *   prefix could be: "Failed to perform doOnClick.".
122      * @throws [AssertionError] if the assert fails.
123      */
assertExistsnull124     fun assertExists(errorMessageOnFail: String? = null): SemanticsNodeInteraction {
125         fetchOneOrThrow(errorMessageOnFail)
126         return this
127     }
128 
129     /**
130      * Asserts that the component was found and it is deactivated.
131      *
132      * For example, the children of [androidx.compose.ui.layout.SubcomposeLayout] which are retained
133      * to be reused in future are considered deactivated.
134      *
135      * @throws [AssertionError] if the assert fails.
136      */
assertIsDeactivatednull137     fun assertIsDeactivated(errorMessageOnFail: String? = null) {
138         val node = fetchOneOrThrow(skipDeactivatedNodes = false)
139         if (!node.layoutInfo.isDeactivated) {
140             throw AssertionError(
141                 buildGeneralErrorMessage(
142                     errorMessage = errorMessageOnFail ?: "Failed: assertDeactivated",
143                     selector = selector,
144                     node = node
145                 )
146             )
147         }
148     }
149 
fetchOneOrThrownull150     private fun fetchOneOrThrow(
151         errorMessageOnFail: String? = null,
152         skipDeactivatedNodes: Boolean = true
153     ): SemanticsNode {
154         val finalErrorMessage = errorMessageOnFail ?: "Failed: assertExists."
155 
156         val result =
157             fetchSemanticsNodes(
158                 atLeastOneRootRequired = true,
159                 errorMessageOnFail = finalErrorMessage,
160                 skipDeactivatedNodes = skipDeactivatedNodes
161             )
162         if (result.selectedNodes.count() != 1) {
163             if (result.selectedNodes.isEmpty() && lastSeenSemantics != null) {
164                 // This means that node we used to have is no longer in the tree.
165                 throw AssertionError(
166                     buildErrorMessageForNodeMissingInTree(
167                         errorMessage = finalErrorMessage,
168                         selector = selector,
169                         lastSeenSemantics = lastSeenSemantics!!
170                     )
171                 )
172             }
173 
174             if (result.customErrorOnNoMatch != null) {
175                 throw AssertionError(finalErrorMessage + "\n" + result.customErrorOnNoMatch)
176             }
177 
178             throw AssertionError(
179                 buildErrorMessageForCountMismatch(
180                     errorMessage = finalErrorMessage,
181                     foundNodes = result.selectedNodes,
182                     expectedCount = 1,
183                     selector = selector,
184                     foundNodesUnmerged = getNodesInUnmergedTree(errorMessageOnFail)
185                 )
186             )
187         }
188 
189         lastSeenSemantics = result.selectedNodes.first().printToString()
190         return result.selectedNodes.first()
191     }
192 
193     /** If using the merged tree, performs the same search in the unmerged tree. */
getNodesInUnmergedTreenull194     private fun getNodesInUnmergedTree(errorMessageOnFail: String?): List<SemanticsNode> {
195         return if (!useUnmergedTree) {
196             testContext.testOwner.getAllSemanticsNodes(
197                 atLeastOneRootRequired = true,
198                 useUnmergedTree = true
199             ) {
200                 selector.map(it, errorMessageOnFail.orEmpty()).selectedNodes
201             }
202         } else {
203             emptyList()
204         }
205     }
206 }
207 
208 /**
209  * Represents a collection of semantics nodes and the path to fetch them from the semantics tree.
210  * One can interact with these nodes by performing assertions such as [assertCountEquals], or
211  * navigate to other nodes such as [get].
212  *
213  * An instance of [SemanticsNodeInteractionCollection] can be obtained from
214  * [onAllNodes][SemanticsNodeInteractionsProvider.onAllNodes] and convenience methods that use a
215  * specific filter, such as [onAllNodesWithText].
216  *
217  * For example, here is how you verify that there are exactly two clickable items:
218  *
219  * @sample androidx.compose.ui.test.samples.verifyTwoClickableNodes
220  */
221 class SemanticsNodeInteractionCollection
222 constructor(
223     internal val testContext: TestContext,
224     internal val useUnmergedTree: Boolean,
225     internal val selector: SemanticsSelector
226 ) {
227     constructor(
228         testContext: TestContext,
229         useUnmergedTree: Boolean,
230         matcher: SemanticsMatcher
231     ) : this(testContext, useUnmergedTree, SemanticsSelector(matcher))
232 
233     /**
234      * Returns the semantics nodes captured by this object.
235      *
236      * Note: Accessing this object involves synchronization with your UI. If you are accessing this
237      * multiple times in one atomic operation, it is better to cache the result instead of calling
238      * this API multiple times.
239      *
240      * @param atLeastOneRootRequired Whether to throw an error in case there is no compose content
241      *   in the current test app.
242      * @param errorMessageOnFail Custom error message to append when this fails to retrieve the
243      *   nodes.
244      */
fetchSemanticsNodesnull245     fun fetchSemanticsNodes(
246         atLeastOneRootRequired: Boolean = true,
247         errorMessageOnFail: String? = null
248     ): List<SemanticsNode> {
249         return testContext.testOwner.getAllSemanticsNodes(atLeastOneRootRequired, useUnmergedTree) {
250             selector.map(it, errorMessageOnFail.orEmpty()).selectedNodes
251         }
252     }
253 
254     /**
255      * Retrieve node at the given index of this collection.
256      *
257      * Any subsequent operation on its result will expect exactly one element found (unless
258      * [SemanticsNodeInteraction.assertDoesNotExist] is used) and will throw [AssertionError] if
259      * none or more than one element is found.
260      */
getnull261     operator fun get(index: Int): SemanticsNodeInteraction {
262         return SemanticsNodeInteraction(
263             testContext,
264             useUnmergedTree,
265             selector.addIndexSelector(index)
266         )
267     }
268 }
269