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