SemanticsNodeInteraction.kt

/*
 * 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.compose.ui.test

import androidx.compose.ui.semantics.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].
 *
 * An instance of [SemanticsNodeInteraction] can be obtained from
 * [onNode][SemanticsNodeInteractionsProvider.onNode] and convenience
 * methods that use a specific filter, such as [onNodeWithText].
 *
 * Here you can see how you can locate a checkbox, click it and verify that it's checked:
 * @sample androidx.compose.ui.test.samples.clickAndVerifyCheckbox
 *
 * [useUnmergedTree] is for tests with a special need to inspect implementation detail within
 * children. For example:
 * @sample androidx.compose.ui.test.samples.useUnmergedTree
 */
class SemanticsNodeInteraction constructor(
    internal val testContext: TestContext,
    internal val useUnmergedTree: Boolean,
    internal val selector: SemanticsSelector
) {
    constructor(
        testContext: TestContext,
        useUnmergedTree: Boolean,
        matcher: SemanticsMatcher
    ) : this(testContext, useUnmergedTree, SemanticsSelector(matcher))

    /**
     * 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(
        atLeastOneRootRequired: Boolean,
        errorMessageOnFail: String? = null
    ): SelectionResult {
        return selector
            .map(
                testContext.getAllSemanticsNodes(atLeastOneRootRequired, useUnmergedTree),
                errorMessageOnFail.orEmpty()
            )
    }

    /**
     * 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(
            atLeastOneRootRequired = false,
            errorMessageOnFail = "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(atLeastOneRootRequired = true, 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,
                    foundNodesUnmerged = getNodesInUnmergedTree(errorMessageOnFail)
                )
            )
        }

        lastSeenSemantics = result.selectedNodes.first().printToString()
        return result.selectedNodes.first()
    }

    /**
     * If using the merged tree, performs the same search in the unmerged tree.
     */
    private fun getNodesInUnmergedTree(errorMessageOnFail: String?): List<SemanticsNode> {
        return if (!useUnmergedTree) {
            selector
                .map(
                    testContext.getAllSemanticsNodes(
                        atLeastOneRootRequired = true,
                        useUnmergedTree = true
                    ),
                    errorMessageOnFail.orEmpty()
                ).selectedNodes
        } else {
            emptyList()
        }
    }
}

/**
 * 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].
 *
 * An instance of [SemanticsNodeInteractionCollection] can be obtained from
 * [onAllNodes][SemanticsNodeInteractionsProvider.onAllNodes] and convenience
 * methods that use a specific filter, such as [onAllNodesWithText].
 *
 * For example, here is how you verify that there are exactly two clickable items:
 * @sample androidx.compose.ui.test.samples.verifyTwoClickableNodes
 */
class SemanticsNodeInteractionCollection constructor(
    internal val testContext: TestContext,
    internal val useUnmergedTree: Boolean,
    internal val selector: SemanticsSelector
) {
    private var nodeIds: List<Int>? = null

    constructor(
        testContext: TestContext,
        useUnmergedTree: Boolean,
        matcher: SemanticsMatcher
    ) : this(testContext, useUnmergedTree, SemanticsSelector(matcher))

    /**
     * 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.
     *
     * @param atLeastOneRootRequired Whether to throw an error in case there is no compose
     * content in the current test app.
     * @param errorMessageOnFail Custom error message to append when this fails to retrieve the
     * nodes.
     */
    fun fetchSemanticsNodes(
        atLeastOneRootRequired: Boolean = true,
        errorMessageOnFail: String? = null
    ): List<SemanticsNode> {
        if (nodeIds == null) {
            return selector
                .map(
                    testContext.getAllSemanticsNodes(atLeastOneRootRequired, useUnmergedTree),
                    errorMessageOnFail.orEmpty()
                )
                .apply { nodeIds = selectedNodes.map { it.id }.toList() }
                .selectedNodes
        }

        return testContext.getAllSemanticsNodes(atLeastOneRootRequired, 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(
            testContext,
            useUnmergedTree,
            selector.addIndexSelector(index)
        )
    }
}