1 /*
2  * Copyright 2024 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.xr.compose.testing
18 
19 import androidx.annotation.RestrictTo
20 import androidx.xr.compose.subspace.node.SubspaceSemanticsInfo
21 import com.google.errorprone.annotations.CanIgnoreReturnValue
22 
23 /**
24  * Represents a semantics node and the path to fetch it from the semantics tree. One can perform
25  * assertions or navigate to other nodes such as [onChildren].
26  *
27  * An instance of [SubspaceSemanticsNodeInteraction] can be obtained from [onSubspaceNode] and
28  * convenience methods that use a specific filter.
29  */
30 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
31 public class SubspaceSemanticsNodeInteraction
32 internal constructor(
33     private val testContext: SubspaceTestContext,
34     private val selector: SubspaceSemanticsSelector,
35 ) {
36     public constructor(
37         testContext: SubspaceTestContext,
38         matcher: SubspaceSemanticsMatcher,
39     ) : this(testContext, SubspaceSemanticsSelector(matcher))
40 
fetchSemanticsNodesnull41     private fun fetchSemanticsNodes(
42         atLeastOneRootRequired: Boolean,
43         errorMessageOnFail: String? = null,
44     ): SubspaceSelectionResult {
45         return selector.map(
46             testContext.getAllSemanticsNodes(atLeastOneRootRequired = atLeastOneRootRequired),
47             errorMessageOnFail.orEmpty(),
48         )
49     }
50 
51     /**
52      * Returns the semantics node captured by this object.
53      *
54      * Note: Accessing this object involves synchronization with your UI. If you are accessing this
55      * multiple times in one atomic operation, it is better to cache the result instead of calling
56      * this API multiple times.
57      *
58      * This will fail if there is 0 or multiple nodes matching.
59      *
60      * @param errorMessageOnFail Error message prefix to be added to the message in case this fetch
61      *   fails. This is typically used by operations that rely on this assert. Example prefix could
62      *   be: "Failed to perform doOnClick.".
63      * @throws [AssertionError] if 0 or multiple nodes found.
64      */
fetchSemanticsNodenull65     public fun fetchSemanticsNode(errorMessageOnFail: String? = null): SubspaceSemanticsInfo {
66         return fetchOneOrThrow(errorMessageOnFail)
67     }
68 
69     /**
70      * Asserts that no item was found or that the item is no longer in the hierarchy.
71      *
72      * This will synchronize with the UI and fetch all the nodes again to ensure it has latest data.
73      *
74      * @throws [AssertionError] if the assert fails.
75      */
assertDoesNotExistnull76     public fun assertDoesNotExist() {
77         val result =
78             fetchSemanticsNodes(
79                 atLeastOneRootRequired = false,
80                 errorMessageOnFail = "Failed: assertDoesNotExist.",
81             )
82         if (result.selectedNodes.isNotEmpty()) {
83             throw AssertionError(
84                 "Failed: assertDoesNotExist. Expected 0 but found ${result.selectedNodes.size} nodes."
85             )
86         }
87     }
88 
89     /**
90      * Asserts that the component was found and is part of the component tree.
91      *
92      * This will synchronize with the UI and fetch all the nodes again to ensure it has latest data.
93      * If you are using [fetchSemanticsNode] you don't need to call this. In fact you would just
94      * introduce additional overhead.
95      *
96      * @param errorMessageOnFail Error message prefix to be added to the message in case this assert
97      *   fails. This is typically used by operations that rely on this assert. Example prefix could
98      *   be: "Failed to perform doOnClick.".
99      * @throws [AssertionError] if the assert fails.
100      */
101     @CanIgnoreReturnValue
assertExistsnull102     public fun assertExists(errorMessageOnFail: String? = null): SubspaceSemanticsNodeInteraction {
103         fetchOneOrThrow(errorMessageOnFail)
104         return this
105     }
106 
107     @CanIgnoreReturnValue
fetchOneOrThrownull108     private fun fetchOneOrThrow(errorMessageOnFail: String? = null): SubspaceSemanticsInfo {
109         val finalErrorMessage = errorMessageOnFail ?: "Failed: assertExists."
110 
111         val result =
112             fetchSemanticsNodes(
113                 atLeastOneRootRequired = true,
114                 errorMessageOnFail = finalErrorMessage
115             )
116         if (result.selectedNodes.count() != 1) {
117             if (result.customErrorOnNoMatch != null) {
118                 throw AssertionError(finalErrorMessage + "\n" + result.customErrorOnNoMatch)
119             }
120 
121             throw AssertionError(finalErrorMessage)
122         }
123 
124         return result.selectedNodes.first()
125     }
126 }
127 
128 /**
129  * Represents a collection of semantics nodes and the path to fetch them from the semantics tree.
130  * One can interact with these nodes by performing assertions such as [assertCountEquals], or
131  * navigate to other nodes such as [get].
132  *
133  * An instance of [SubspaceSemanticsNodeInteractionCollection] can be obtained from
134  * [onAllSubspaceNodes] and convenience methods that use a specific filter, such as
135  * [onAllNodesWithText].
136  */
137 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
138 public class SubspaceSemanticsNodeInteractionCollection
139 private constructor(
140     internal val testContext: SubspaceTestContext,
141     internal val selector: SubspaceSemanticsSelector,
142 ) {
143     @Suppress("PrimitiveInCollection") private var nodeIds: List<Int>? = null
144 
145     public constructor(
146         testContext: SubspaceTestContext,
147         matcher: SubspaceSemanticsMatcher,
148     ) : this(testContext, SubspaceSemanticsSelector(matcher))
149 
150     /**
151      * Returns the semantics nodes captured by this object.
152      *
153      * Note: Accessing this object involves synchronization with your UI. If you are accessing this
154      * multiple times in one atomic operation, it is better to cache the result instead of calling
155      * this API multiple times.
156      *
157      * @param atLeastOneRootRequired Whether to throw an error in case there is no compose content
158      *   in the current test app.
159      * @param errorMessageOnFail Error message prefix to be added to the message in case this fetch
160      *   fails. This is typically used by operations that rely on this assert. Example prefix could
161      *   be: "Failed to perform doOnClick.".
162      */
fetchSemanticsNodesnull163     private fun fetchSemanticsNodes(
164         atLeastOneRootRequired: Boolean = true,
165         errorMessageOnFail: String? = null,
166     ): List<SubspaceSemanticsInfo> {
167         if (nodeIds == null) {
168             return selector
169                 .map(
170                     testContext.getAllSemanticsNodes(atLeastOneRootRequired),
171                     errorMessageOnFail.orEmpty()
172                 )
173                 .apply { nodeIds = selectedNodes.map { it.semanticsId }.toList() }
174                 .selectedNodes
175         }
176 
177         return testContext.getAllSemanticsNodes(atLeastOneRootRequired).filter {
178             it.semanticsId in nodeIds!!
179         }
180     }
181 
182     /**
183      * Retrieve node at the given index of this collection.
184      *
185      * Any subsequent operation on its result will expect exactly one element found (unless
186      * [SubspaceSemanticsNodeInteraction.assertDoesNotExist] is used) and will throw
187      * [AssertionError] if none or more than one element is found.
188      */
getnull189     private operator fun get(index: Int): SubspaceSemanticsNodeInteraction {
190         return SubspaceSemanticsNodeInteraction(testContext, selector.addIndexSelector(index))
191     }
192 }
193