| /* |
| * Copyright 2019 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.ui.test |
| |
| import androidx.compose.ui.node.ExperimentalLayoutNodeApi |
| import androidx.compose.ui.semantics.SemanticsNode |
| import androidx.compose.ui.unit.Density |
| import androidx.compose.ui.unit.PxBounds |
| |
| internal expect fun getAllSemanticsNodes(mergingEnabled: Boolean): List<SemanticsNode> |
| |
| /** |
| * Represents a semantics node and the path to fetch it from the semantics tree. One can interact |
| * with this node by performing actions such as [performClick], assertions such as |
| * [assertHasClickAction], or navigate to other nodes such as [onChildren]. |
| * |
| * This is usually obtained from methods like [onNodeWithTag], [onNode]. |
| * |
| * Example usage: |
| * ``` |
| * onNodeWithTag("myCheckbox") |
| * .performClick() |
| * .assertIsOn() |
| * ```` |
| * |
| * useUnmergedTree is for tests with a special need to inspect "implementation |
| * detail" children. For example: |
| * ``` |
| * composeTestRule.setMaterialContent { |
| * // IconButton is a semantically merging composable. All testTags of its children |
| * // are merged up into it in the default, "merged" semantics tree. |
| * IconButton(onClick = {}) { |
| * MyIcon(Modifier.testTag("icon")) |
| * } |
| * } |
| * |
| * // Assert that MyIcon is at the expected position inside the IconButton. |
| * // Without useUnmergedTree, then the test would check the position of the IconButton (0, 0) |
| * // instead of the position of the Icon (30, 30). |
| * onNodeWithTag("icon", useUnmergedTree = true) |
| * .assertLeftPosition(30.dp) |
| * .assertTopPosition(30.dp) |
| * ```` |
| */ |
| class SemanticsNodeInteraction internal constructor( |
| internal val useUnmergedTree: Boolean, |
| internal val selector: SemanticsSelector |
| ) { |
| private var nodeIds: List<Int>? = null |
| |
| /** |
| * Anytime we refresh semantics we capture it here. This is then presented to the user in case |
| * their tests fails deu to a missing node. This helps to see what was the last state of the |
| * node before it disappeared. We dump it to string because trying to dump the node later can |
| * result in failure as it gets detached from its layout. |
| */ |
| private var lastSeenSemantics: String? = null |
| |
| internal fun fetchSemanticsNodes(errorMessageOnFail: String? = null): SelectionResult { |
| if (nodeIds == null) { |
| return selector |
| .map(getAllSemanticsNodes(useUnmergedTree), errorMessageOnFail.orEmpty()) |
| .apply { nodeIds = selectedNodes.map { it.id }.toList() } |
| } |
| |
| return SelectionResult(getAllSemanticsNodes(useUnmergedTree).filter { it.id in nodeIds!! }) |
| } |
| |
| /** |
| * Returns the semantics node captured by this object. |
| * |
| * Note: Accessing this object involves synchronization with your UI. If you are accessing this |
| * multiple times in one atomic operation, it is better to cache the result instead of calling |
| * this API multiple times. |
| * |
| * This will fail if there is 0 or multiple nodes matching. |
| * |
| * @throws AssertionError if 0 or multiple nodes found. |
| */ |
| fun fetchSemanticsNode(errorMessageOnFail: String? = null): SemanticsNode { |
| return fetchOneOrDie(errorMessageOnFail) |
| } |
| |
| /** |
| * Asserts that no item was found or that the item is no longer in the hierarchy. |
| * |
| * This will synchronize with the UI and fetch all the nodes again to ensure it has latest data. |
| * |
| * @throws [AssertionError] if the assert fails. |
| */ |
| fun assertDoesNotExist() { |
| val result = fetchSemanticsNodes("Failed: assertDoesNotExist.") |
| if (result.selectedNodes.isNotEmpty()) { |
| throw AssertionError(buildErrorMessageForCountMismatch( |
| errorMessage = "Failed: assertDoesNotExist.", |
| selector = selector, |
| foundNodes = result.selectedNodes, |
| expectedCount = 0 |
| )) |
| } |
| } |
| |
| /** |
| * Asserts that the component was found and is part of the component tree. |
| * |
| * This will synchronize with the UI and fetch all the nodes again to ensure it has latest data. |
| * If you are using [fetchSemanticsNode] you don't need to call this. In fact you would just |
| * introduce additional overhead. |
| * |
| * @param errorMessageOnFail Error message prefix to be added to the message in case this |
| * asserts fails. This is typically used by operations that rely on this assert. Example prefix |
| * could be: "Failed to perform doOnClick.". |
| * |
| * @throws [AssertionError] if the assert fails. |
| */ |
| fun assertExists(errorMessageOnFail: String? = null): SemanticsNodeInteraction { |
| fetchOneOrDie(errorMessageOnFail) |
| return this |
| } |
| |
| private fun fetchOneOrDie(errorMessageOnFail: String? = null): SemanticsNode { |
| val finalErrorMessage = errorMessageOnFail |
| ?: "Failed: assertExists." |
| |
| val result = fetchSemanticsNodes(finalErrorMessage) |
| if (result.selectedNodes.count() != 1) { |
| if (result.selectedNodes.isEmpty() && lastSeenSemantics != null) { |
| // This means that node we used to have is no longer in the tree. |
| throw AssertionError(buildErrorMessageForNodeMissingInTree( |
| errorMessage = finalErrorMessage, |
| selector = selector, |
| lastSeenSemantics = lastSeenSemantics!! |
| )) |
| } |
| |
| if (result.customErrorOnNoMatch != null) { |
| throw AssertionError(finalErrorMessage + "\n" + result.customErrorOnNoMatch) |
| } |
| |
| throw AssertionError(buildErrorMessageForCountMismatch( |
| errorMessage = finalErrorMessage, |
| foundNodes = result.selectedNodes, |
| expectedCount = 1, |
| selector = selector |
| )) |
| } |
| |
| lastSeenSemantics = result.selectedNodes.first().printToString() |
| return result.selectedNodes.first() |
| } |
| } |
| |
| /** |
| * Represents a collection of semantics nodes and the path to fetch them from the semantics tree. |
| * One can interact with these nodes by performing assertions such as [assertCountEquals], or |
| * navigate to other nodes such as [get]. |
| * |
| * This is usually obtained from methods like [onAllNodes] or chains of [onNode].[onChildren]. |
| * |
| * Example usage: |
| * ``` |
| * onAllNodes(isClickable()) |
| * .assertCountEquals(2) |
| * ```` |
| */ |
| class SemanticsNodeInteractionCollection( |
| internal val useUnmergedTree: Boolean, |
| internal val selector: SemanticsSelector |
| ) { |
| private var nodeIds: List<Int>? = null |
| |
| /** |
| * Returns the semantics nodes captured by this object. |
| * |
| * Note: Accessing this object involves synchronization with your UI. If you are accessing this |
| * multiple times in one atomic operation, it is better to cache the result instead of calling |
| * this API multiple times. |
| */ |
| fun fetchSemanticsNodes(errorMessageOnFail: String? = null): List<SemanticsNode> { |
| if (nodeIds == null) { |
| return selector |
| .map(getAllSemanticsNodes(useUnmergedTree), errorMessageOnFail.orEmpty()) |
| .apply { nodeIds = selectedNodes.map { it.id }.toList() } |
| .selectedNodes |
| } |
| |
| return getAllSemanticsNodes(useUnmergedTree).filter { it.id in nodeIds!! } |
| } |
| |
| /** |
| * Retrieve node at the given index of this collection. |
| * |
| * Any subsequent operation on its result will expect exactly one element found (unless |
| * [SemanticsNodeInteraction.assertDoesNotExist] is used) and will throw [AssertionError] if |
| * none or more than one element is found. |
| */ |
| operator fun get(index: Int): SemanticsNodeInteraction { |
| return SemanticsNodeInteraction(useUnmergedTree, selector.addIndexSelector(index)) |
| } |
| } |
| |
| internal actual fun <R> SemanticsNodeInteraction.withDensity( |
| operation: Density.(SemanticsNode) -> R |
| ): R { |
| val node = fetchSemanticsNode("Failed to retrieve density for the node.") |
| @OptIn(ExperimentalLayoutNodeApi::class) |
| val density = node.componentNode.owner!!.density |
| return operation.invoke(density, node) |
| } |
| |
| internal actual fun SemanticsNodeInteraction.withUnclippedBoundsInRoot( |
| assertion: Density.(PxBounds) -> Unit |
| ): SemanticsNodeInteraction { |
| val node = fetchSemanticsNode("Failed to retrieve bounds of the node.") |
| @OptIn(ExperimentalLayoutNodeApi::class) |
| val density = node.componentNode.owner!!.density |
| |
| assertion.invoke(density, node.unclippedBoundsInRoot) |
| return this |
| } |