Filters.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.ProgressBarRangeInfo
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.util.fastAny

/**
 * Returns whether the node is enabled.
 *
 * @see SemanticsProperties.Disabled
 */
fun isEnabled(): SemanticsMatcher =
    !hasKey(SemanticsProperties.Disabled)

/**
 * Returns whether the node is not enabled.
 *
 * @see SemanticsProperties.Disabled
 */
fun isNotEnabled(): SemanticsMatcher =
    hasKey(SemanticsProperties.Disabled)

/**
 * Return whether the node is checkable.
 *
 * @see SemanticsProperties.ToggleableState
 */
fun isToggleable(): SemanticsMatcher =
    hasKey(SemanticsProperties.ToggleableState)

/**
 * Returns whether the node is toggled.
 *
 * @see SemanticsProperties.ToggleableState
 */
fun isOn(): SemanticsMatcher = SemanticsMatcher.expectValue(
    SemanticsProperties.ToggleableState, ToggleableState.On
)

/**
 * Returns whether the node is not toggled.
 *
 * @see SemanticsProperties.ToggleableState
 */
fun isOff(): SemanticsMatcher = SemanticsMatcher.expectValue(
    SemanticsProperties.ToggleableState, ToggleableState.Off
)

/**
 * Return whether the node is selectable.
 *
 * @see SemanticsProperties.Selected
 */
fun isSelectable(): SemanticsMatcher =
    hasKey(SemanticsProperties.Selected)

/**
 * Returns whether the node is selected.
 *
 * @see SemanticsProperties.Selected
 */
fun isSelected(): SemanticsMatcher =
    SemanticsMatcher.expectValue(SemanticsProperties.Selected, true)

/**
 * Returns whether the node is not selected.
 *
 * @see SemanticsProperties.Selected
 */
fun isNotSelected(): SemanticsMatcher =
    SemanticsMatcher.expectValue(SemanticsProperties.Selected, false)

/**
 * Return whether the node is able to receive focus
 *
 * @see SemanticsProperties.Focused
 */
fun isFocusable(): SemanticsMatcher =
    hasKey(SemanticsProperties.Focused)

/**
 * Return whether the node is not able to receive focus.
 *
 * @see SemanticsProperties.Focused
 */
fun isNotFocusable(): SemanticsMatcher =
    SemanticsMatcher.keyNotDefined(SemanticsProperties.Focused)

/**
 * Returns whether the node is focused.
 *
 * @see SemanticsProperties.Focused
 */
fun isFocused(): SemanticsMatcher =
    SemanticsMatcher.expectValue(SemanticsProperties.Focused, true)

/**
 * Returns whether the node is not focused.
 *
 * @see SemanticsProperties.Focused
 */
fun isNotFocused(): SemanticsMatcher =
    SemanticsMatcher.expectValue(SemanticsProperties.Focused, false)

/**
 * Return whether the node has a semantics click action defined.
 *
 * @see SemanticsActions.OnClick
 */
fun hasClickAction(): SemanticsMatcher =
    hasKey(SemanticsActions.OnClick)

/**
 * Return whether the node has no semantics click action defined.
 *
 * @see SemanticsActions.OnClick
 */
fun hasNoClickAction(): SemanticsMatcher =
    SemanticsMatcher.keyNotDefined(SemanticsActions.OnClick)

/**
 * Return whether the node has a semantics scrollable action defined.
 *
 * @see SemanticsActions.ScrollBy
 */
fun hasScrollAction(): SemanticsMatcher =
    hasKey(SemanticsActions.ScrollBy)

/**
 * Return whether the node has no semantics scrollable action defined.
 *
 * @see SemanticsActions.ScrollBy
 */
fun hasNoScrollAction(): SemanticsMatcher =
    SemanticsMatcher.keyNotDefined(SemanticsActions.ScrollBy)

/**
 * Returns whether the node's content description contains the given [value].
 *
 * Note that in merged semantics tree there can be a list of content descriptions that got merged
 * from the child nodes. Typically an accessibility tooling will decide based on its heuristics
 * which ones to announce.
 *
 * @param value Value to match as one of the items in the list of content descriptions.
 * @param substring Whether to use substring matching.
 * @param ignoreCase Whether case should be ignored.
 *
 * @see SemanticsProperties.ContentDescription
 */
fun hasContentDescription(
    value: String,
    substring: Boolean = false,
    ignoreCase: Boolean = false
): SemanticsMatcher {
    return if (substring) {
        SemanticsMatcher(
            "${SemanticsProperties.ContentDescription.name} contains '$value' " +
                "(ignoreCase: $ignoreCase)"
        ) {
            it.config.getOrNull(SemanticsProperties.ContentDescription)
                ?.any { item -> item.contains(value, ignoreCase) } ?: false
        }
    } else {
        SemanticsMatcher(
            "${SemanticsProperties.ContentDescription.name} = '$value' (ignoreCase: $ignoreCase)"
        ) {
            it.config.getOrNull(SemanticsProperties.ContentDescription)
                ?.any { item -> item.equals(value, ignoreCase) } ?: false
        }
    }
}

/**
 * Returns whether the node's content description contains exactly the given [values] and nothing
 * else.
 *
 * Note that in merged semantics tree there can be a list of content descriptions that got merged
 * from the child nodes. Typically an accessibility tooling will decide based on its heuristics
 * which ones to announce.
 *
 * @param values List of values to match (the order does not matter)
 *
 * @see SemanticsProperties.ContentDescription
 */
fun hasContentDescriptionExactly(
    vararg values: String
): SemanticsMatcher {
    val expected = values.toList()
    return SemanticsMatcher(
        "${SemanticsProperties.ContentDescription.name} = " +
            "[${values.joinToString(",")}]"
    ) { node ->
        node.config.getOrNull(SemanticsProperties.ContentDescription)
            ?.let { given ->
                given.size == expected.size &&
                    given.containsAll(expected) && expected.containsAll(given)
            } ?: values.isEmpty()
    }
}

/**
 * Returns whether the node's text contains the given [text].
 *
 * This will also search in [SemanticsProperties.EditableText].
 *
 * Note that in merged semantics tree there can be a list of text items that got merged from
 * the child nodes. Typically an accessibility tooling will decide based on its heuristics which
 * ones to use.
 *
 * @param text Value to match as one of the items in the list of text values.
 * @param substring Whether to use substring matching.
 * @param ignoreCase Whether case should be ignored.
 *
 * @see SemanticsProperties.Text
 * @see SemanticsProperties.EditableText
 */
fun hasText(
    text: String,
    substring: Boolean = false,
    ignoreCase: Boolean = false
): SemanticsMatcher {
    val propertyName = "${SemanticsProperties.Text.name} + ${SemanticsProperties.EditableText.name}"
    return if (substring) {
        SemanticsMatcher(
            "$propertyName contains '$text' (ignoreCase: $ignoreCase) as substring"
        ) {
            val isInEditableTextValue = it.config.getOrNull(SemanticsProperties.EditableText)
                ?.text?.contains(text, ignoreCase) ?: false
            val isInTextValue = it.config.getOrNull(SemanticsProperties.Text)
                ?.any { item -> item.text.contains(text, ignoreCase) } ?: false
            isInEditableTextValue || isInTextValue
        }
    } else {
        SemanticsMatcher(
            "$propertyName contains '$text' (ignoreCase: $ignoreCase)"
        ) {
            val isInEditableTextValue = it.config.getOrNull(SemanticsProperties.EditableText)
                ?.text?.equals(text, ignoreCase) ?: false
            val isInTextValue = it.config.getOrNull(SemanticsProperties.Text)
                ?.any { item -> item.text.equals(text, ignoreCase) } ?: false
            isInEditableTextValue || isInTextValue
        }
    }
}

/**
 * Returns whether the node's text contains exactly the given [textValues] and nothing else.
 *
 * This will also search in [SemanticsProperties.EditableText] by default.
 *
 * Note that in merged semantics tree there can be a list of text items that got merged from
 * the child nodes. Typically an accessibility tooling will decide based on its heuristics which
 * ones to use.
 *
 * @param textValues List of values to match (the order does not matter)
 * @param includeEditableText Whether to also assert against the editable text
 *
 * @see SemanticsProperties.Text
 * @see SemanticsProperties.EditableText
 */
fun hasTextExactly(
    vararg textValues: String,
    includeEditableText: Boolean = true
): SemanticsMatcher {
    val expected = textValues.toList()
    val propertyName = if (includeEditableText) {
        "${SemanticsProperties.Text.name} + ${SemanticsProperties.EditableText.name}"
    } else {
        SemanticsProperties.Text.name
    }
    return SemanticsMatcher(
        "$propertyName = [${textValues.joinToString(",")}]"
    ) { node ->
        val actual = mutableListOf<String>()
        if (includeEditableText) {
            node.config.getOrNull(SemanticsProperties.EditableText)
                ?.let { actual.add(it.text) }
        }
        node.config.getOrNull(SemanticsProperties.Text)
            ?.let { actual.addAll(it.map { anStr -> anStr.text }) }
        actual.containsAll(expected) && expected.containsAll(actual)
    }
}

/**
 * Returns whether the node's value matches exactly to the given accessibility value.
 *
 * @param value Value to match.
 *
 * @see SemanticsProperties.StateDescription
 */
fun hasStateDescription(value: String): SemanticsMatcher = SemanticsMatcher.expectValue(
    SemanticsProperties.StateDescription, value
)

/**
 * Returns whether the node is marked as an accessibility header.
 *
 * @see SemanticsProperties.Heading
 */
fun isHeading(): SemanticsMatcher =
    hasKey(SemanticsProperties.Heading)

/**
 * Returns whether the node's range info matches exactly to the given accessibility range info.
 *
 * @param rangeInfo range info to match.
 *
 * @see SemanticsProperties.ProgressBarRangeInfo
 */
fun hasProgressBarRangeInfo(rangeInfo: ProgressBarRangeInfo): SemanticsMatcher = SemanticsMatcher
    .expectValue(SemanticsProperties.ProgressBarRangeInfo, rangeInfo)

/**
 * Returns whether the node is annotated by the given test tag.
 *
 * @param testTag Value to match.
 *
 * @see SemanticsProperties.TestTag
 */
fun hasTestTag(testTag: String): SemanticsMatcher =
    SemanticsMatcher.expectValue(SemanticsProperties.TestTag, testTag)

/**
 * Returns whether the node is a dialog.
 *
 * This only checks if the node itself is a dialog, not if it is _part of_ a dialog. Use
 * `hasAnyAncestorThat(isDialog())` for that.
 *
 * @see SemanticsProperties.IsDialog
 */
fun isDialog(): SemanticsMatcher =
    hasKey(SemanticsProperties.IsDialog)

/**
 * Returns whether the node is a popup.
 *
 * This only checks if the node itself is a popup, not if it is _part of_ a popup. Use
 * `hasAnyAncestorThat(isPopup())` for that.
 *
 * @see SemanticsProperties.IsPopup
 */
fun isPopup(): SemanticsMatcher =
    hasKey(SemanticsProperties.IsPopup)

/**
 * Returns whether the node defines the given IME action.
 *
 * @param actionType the action to match.
 */
fun hasImeAction(actionType: ImeAction) =
    SemanticsMatcher.expectValue(SemanticsProperties.ImeAction, actionType)

/**
 * Returns whether the node defines semantics action to set text to it.
 *
 * This can be used to for instance filter out text fields.
 *
 * @see SemanticsActions.SetText
 */
fun hasSetTextAction() =
    hasKey(SemanticsActions.SetText)

/**
 * Returns whether the node defines the ability to scroll to an item index.
 *
 * Note that not all scrollable containers have item indices. For example, a
 * [scrollable][androidx.compose.foundation.gestures.scrollable] doesn't have items with an
 * index, while [LazyColumn][androidx.compose.foundation.lazy.LazyColumn] does.
 */
fun hasScrollToIndexAction() =
    hasKey(SemanticsActions.ScrollToIndex)

/**
 * Returns whether the node defines the ability to scroll to an item identified by a key, such as
 * [LazyColumn][androidx.compose.foundation.lazy.LazyColumn] or
 * [LazyRow][androidx.compose.foundation.lazy.LazyRow].
 */
fun hasScrollToKeyAction() =
    hasKey(SemanticsActions.ScrollToIndex)
        .and(hasKey(SemanticsProperties.IndexForKey))

/**
 * Returns whether the node defines the ability to scroll to content identified by a matcher.
 */
fun hasScrollToNodeAction() =
    hasKey(SemanticsActions.ScrollToIndex)
        .and(hasKey(SemanticsActions.ScrollBy))
        .and(
            hasKey(SemanticsProperties.HorizontalScrollAxisRange)
                .or(hasKey(SemanticsProperties.VerticalScrollAxisRange))
        )

/**
 * Return whether the node is the root semantics node.
 *
 * There is always one root in every node tree, added implicitly by Compose.
 */
fun isRoot() =
    SemanticsMatcher("isRoot") { it.isRoot }

/**
 * Returns whether the node's parent satisfies the given matcher.
 *
 * Returns false if no parent exists.
 */
fun hasParent(matcher: SemanticsMatcher): SemanticsMatcher {
    // TODO(b/150292800): If this is used in assert we should print the parent's node semantics
    //  in the error message or say that no parent was found.
    return SemanticsMatcher("hasParentThat(${matcher.description})") {
        it.parent?.run { matcher.matches(this) } ?: false
    }
}

/**
 * Returns whether the node has at least one child that satisfies the given matcher.
 */
fun hasAnyChild(matcher: SemanticsMatcher): SemanticsMatcher {
    // TODO(b/150292800): If this is used in assert we should print the children nodes semantics
    //  in the error message or say that no children were found.
    return SemanticsMatcher("hasAnyChildThat(${matcher.description})") {
        matcher.matchesAny(it.children)
    }
}

/**
 * Returns whether the node has at least one sibling that satisfies the given matcher.
 *
 * Sibling is defined as a any other node that shares the same parent.
 */
fun hasAnySibling(matcher: SemanticsMatcher): SemanticsMatcher {
    // TODO(b/150292800): If this is used in assert we should print the sibling nodes semantics
    //  in the error message or say that no siblings were found.
    return SemanticsMatcher(
        "hasAnySiblingThat(${matcher.description})"
    ) {
        val node = it
        it.parent?.run { matcher.matchesAny(this.children.filter { child -> child.id != node.id }) }
            ?: false
    }
}

/**
 * Returns whether the node has at least one ancestor that satisfies the given matcher.
 *
 * Example: For the following tree
 * ```
 * |-X
 * |-A
 *   |-B
 *     |-C1
 *     |-C2
 * ```
 * In case of C1, we would check the matcher against A and B
 */
fun hasAnyAncestor(matcher: SemanticsMatcher): SemanticsMatcher {
    // TODO(b/150292800): If this is used in assert we should print the ancestor nodes semantics
    //  in the error message or say that no ancestors were found.
    return SemanticsMatcher("hasAnyAncestorThat(${matcher.description})") {
        matcher.matchesAny(it.ancestors)
    }
}

/**
 * Returns whether the node has at least one descendant that satisfies the given matcher.
 *
 * Example: For the following tree
 * ```
 * |-X
 * |-A
 *   |-B
 *     |-C1
 *     |-C2
 * ```
 * In case of A, we would check the matcher against B,C1 and C2
 */
fun hasAnyDescendant(matcher: SemanticsMatcher): SemanticsMatcher {
    // TODO(b/150292800): If this is used in assert we could consider printing the whole subtree but
    //  it might be too much to show. But we could at least warn if there were no ancestors found.
    fun checkIfSubtreeMatches(matcher: SemanticsMatcher, node: SemanticsNode): Boolean {
        if (matcher.matchesAny(node.children)) {
            return true
        }

        return node.children.fastAny { checkIfSubtreeMatches(matcher, it) }
    }

    return SemanticsMatcher("hasAnyDescendantThat(${matcher.description})") {
        checkIfSubtreeMatches(matcher, it)
    }
}

internal val SemanticsNode.ancestors: Iterable<SemanticsNode>
    get() = object : Iterable<SemanticsNode> {
        override fun iterator(): Iterator<SemanticsNode> {
            return object : Iterator<SemanticsNode> {
                var next = parent
                override fun hasNext(): Boolean {
                    return next != null
                }

                override fun next(): SemanticsNode {
                    return next!!.also { next = it.parent }
                }
            }
        }
    }

private fun hasKey(key: SemanticsPropertyKey<*>): SemanticsMatcher =
    SemanticsMatcher.keyIsDefined(key)