UnitTestFilters.kt

/*
 * Copyright 2023 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.glance.testing.unit

import android.annotation.SuppressLint
import android.app.Activity
import android.content.ComponentName
import android.os.Bundle
import androidx.glance.EmittableWithText
import androidx.glance.action.ActionModifier
import androidx.glance.action.ActionParameters
import androidx.glance.action.StartActivityClassAction
import androidx.glance.action.StartActivityComponentAction
import androidx.glance.action.actionParametersOf
import androidx.glance.semantics.SemanticsModifier
import androidx.glance.semantics.SemanticsProperties
import androidx.glance.semantics.SemanticsPropertyKey
import androidx.glance.testing.GlanceNode
import androidx.glance.testing.GlanceNodeAssertionsProvider
import androidx.glance.testing.GlanceNodeMatcher

// This file contains common filters that can be passed in "onNode", "onAllNodes" or
// "assert(matcher)". Surface specific filters can be found in UnitTestFilters.kt in surface
// specific lib project.

/**
 * Returns a matcher that matches if a node is annotated by the given test tag.
 *
 * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
 * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
 * matching node(s) or in assertions to validate that node(s) satisfy the condition.
 *
 * @param testTag value to match against the free form string specified in the `testTag` semantics
 *                modifier on the Glance composable nodes.
 */
fun hasTestTag(testTag: String): GlanceNodeMatcher<MappedNode> =
    hasSemanticsPropertyValue(SemanticsProperties.TestTag, testTag)

private fun <T> hasSemanticsPropertyValue(
    key: SemanticsPropertyKey<T>,
    expectedValue: T
): GlanceNodeMatcher<MappedNode> {
    return GlanceNodeMatcher("${key.name} = '$expectedValue'") { node ->
        node.value.emittable.modifier.any {
            it is SemanticsModifier &&
                it.configuration.getOrElseNullable(key) { null } == expectedValue
        }
    }
}

/**
 * Returns whether the content description set directly on the node contains the provided [value].
 *
 * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
 * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
 * matching node(s) or in assertions to validate that node(s) satisfy the condition.
 *
 * @param value value that should be substring of the content description set directly on the node.
 * @param ignoreCase whether case should be ignored. Default is case sensitive.
 *
 * @see SemanticsProperties.ContentDescription
 */
fun hasContentDescription(
    value: String,
    ignoreCase: Boolean = false
): GlanceNodeMatcher<MappedNode> =
    GlanceNodeMatcher(
        description =
            "${SemanticsProperties.ContentDescription.name} contains '$value'" +
                " (ignoreCase: '$ignoreCase') as substring"
    ) { node ->
        node.value.emittable.modifier.any {
            it is SemanticsModifier &&
                hasContentDescription(
                    semanticsModifier = it,
                    value = value,
                    substring = true,
                    ignoreCase = ignoreCase)
        }
    }

/**
 * Returns whether the content description set directly on the node is equal to the provided
 * [value].
 *
 * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
 * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
 * matching node(s) or in assertions to validate that node(s) satisfy the condition.
 *
 * @param value value that should match exactly with content description set directly on the node.
 * @param ignoreCase whether case should be ignored. Default is case sensitive.
 *
 * @see SemanticsProperties.ContentDescription
 */
fun hasContentDescriptionEqualTo(
    value: String,
    ignoreCase: Boolean = false
): GlanceNodeMatcher<MappedNode> =
    GlanceNodeMatcher(
        description =
        "${SemanticsProperties.ContentDescription.name} == '$value' (ignoreCase: '$ignoreCase')"
    ) { node ->
        node.value.emittable.modifier.any {
            it is SemanticsModifier &&
                hasContentDescription(
                    semanticsModifier = it,
                    value = value,
                    substring = false,
                    ignoreCase = ignoreCase
                )
        }
    }

@SuppressLint("ListIterator") // this is not a hot code path, nor is it optimized
private fun hasContentDescription(
    semanticsModifier: SemanticsModifier,
    value: String,
    substring: Boolean = false,
    ignoreCase: Boolean = false
): Boolean {
    val contentDescription =
        semanticsModifier.configuration.getOrNull(SemanticsProperties.ContentDescription)
            ?.joinToString()
            ?: return false
    return if (substring) {
        contentDescription.contains(value, ignoreCase)
    } else {
        contentDescription.equals(value, ignoreCase)
    }
}

/**
 * Returns a matcher that matches if text on node contains the provided text as its substring.
 *
 * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
 * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
 * matching node(s) or in assertions to validate that node(s) satisfy the condition.
 *
 * @param text value that should be matched as a substring of the node's text.
 * @param ignoreCase whether to perform case insensitive matching. Defaults to case sensitive
 *                   matching.
 */
fun hasText(
    text: String,
    ignoreCase: Boolean = false
): GlanceNodeMatcher<MappedNode> = GlanceNodeMatcher(
    description = "contains text '$text' (ignoreCase: '$ignoreCase') as substring"
) { node ->
    val emittable = node.value.emittable
    emittable is EmittableWithText && emittable.text.contains(text, ignoreCase)
}

/**
 * Returns a matcher that matches if node is a text node and its text is equal to the provided text.
 *
 * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
 * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
 * matching node(s) or in assertions to validate that node(s) satisfy the condition.
 *
 * @param text value that should exactly match the node's text.
 * @param ignoreCase whether to perform case insensitive matching. Defaults to case sensitive
 *                   matching.
 */
fun hasTextEqualTo(
    text: String,
    ignoreCase: Boolean = false
): GlanceNodeMatcher<MappedNode> = GlanceNodeMatcher(
    description = "text == '$text' (ignoreCase: '$ignoreCase')"
) { node ->
    val emittable = node.value.emittable
    emittable is EmittableWithText && emittable.text.equals(text, ignoreCase)
}

/**
 * Returns a matcher that matches if the given node has clickable modifier set.
 *
 * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
 * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
 * matching node(s) or in assertions to validate that node(s) satisfy the condition.
 */
fun hasClickAction(): GlanceNodeMatcher<MappedNode> = GlanceNodeMatcher(
    description = "has click action"
) { node ->
    node.value.emittable.modifier.any {
        it is ActionModifier
    }
}

/**
 * Returns a matcher that matches if the given node doesn't have a clickable modifier or `onClick`
 * set.
 *
 * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
 * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
 * matching node(s) or in assertions to validate that node(s) satisfy the condition.
 */
fun hasNoClickAction(): GlanceNodeMatcher<MappedNode> = GlanceNodeMatcher(
    description = "has no click action"
) { node ->
    node.value.emittable.modifier.all {
        it !is ActionModifier
    }
}

/**
 * Returns a matcher that matches if a given node has a clickable set with action that starts an
 * activity.
 *
 * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
 * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
 * matching node(s) or in assertions to validate that node(s) satisfy the condition.
 *
 * @param activityClass class of the activity that is expected to have been passed in the
 *                      `actionStartActivity` method call
 * @param parameters the parameters associated with the action that are expected to have been passed
 *                      in the `actionStartActivity` method call
 * @param activityOptions Additional options built from an [android.app.ActivityOptions] that are
 *                        expected to have been passed in the `actionStartActivity` method call
 */
@PublishedApi // See b/316353540; a reified version of this is available in the public api.
internal fun <T : Activity> hasStartActivityClickAction(
    activityClass: Class<T>,
    parameters: ActionParameters = actionParametersOf(),
    activityOptions: Bundle? = null
): GlanceNodeMatcher<MappedNode> = GlanceNodeMatcher(
    description =
    if (activityOptions != null) {
        "has start activity click action with activity: ${activityClass.name}, " +
            "parameters: $parameters and bundle: $activityOptions"
    } else {
        "has start activity click action with activity: ${activityClass.name} and " +
            "parameters: $parameters"
    }
) { node ->
    node.value.emittable.modifier.any {
        if (it is ActionModifier) {
            val action = it.action
            if (action is StartActivityClassAction) {
                var result = action.activityClass == activityClass &&
                    action.parameters == parameters
                if (activityOptions != null) {
                    result = result && activityOptions == action.activityOptions
                }
                return@any result
            }
        }
        false
    }
}

/**
 * Returns a matcher that matches if a given node has a clickable set with action that starts an
 * activity.
 *
 * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
 * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
 * matching node(s) or in assertions to validate that node(s) satisfy the condition.
 *
 * @param T class of the activity that is expected to have been passed in the
 *          `actionStartActivity` method call
 * @param parameters the parameters associated with the action that are expected to have been passed
 *                      in the `actionStartActivity` method call
 * @param activityOptions Additional options built from an [android.app.ActivityOptions] that are
 *                        expected to have been passed in the `actionStartActivity` method call
 */
inline fun <reified T : Activity> hasStartActivityClickAction(
    parameters: ActionParameters = actionParametersOf(),
    activityOptions: Bundle? = null
): GlanceNodeMatcher<MappedNode> =
    hasStartActivityClickAction(
        activityClass = T::class.java,
        parameters = parameters,
        activityOptions = activityOptions
    )

/**
 * Returns a matcher that matches if a given node has a descendant node somewhere in its
 * sub-hierarchy that the matches the provided matcher.
 *
 * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
 * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
 * matching node(s) or in assertions to validate that node(s) satisfy the condition.
 *
 * @param matcher a matcher that needs to be satisfied for the descendant node to be matched
 */
fun hasAnyDescendant(matcher: GlanceNodeMatcher<MappedNode>): GlanceNodeMatcher<MappedNode> {

    @SuppressLint("ListIterator") // this is not a hot code path
    fun checkIfSubtreeMatchesRecursive(
        matcher: GlanceNodeMatcher<MappedNode>,
        node: GlanceNode<MappedNode>
    ): Boolean {
        if (matcher.matchesAny(node.children())) {
            return true
        }

        return node.children().any { checkIfSubtreeMatchesRecursive(matcher, it) }
    }

    return GlanceNodeMatcher("hasAnyDescendantThat(${matcher.description})") {
        checkIfSubtreeMatchesRecursive(matcher, it)
    }
}

/**
 * Returns a matcher that matches if a given node has a clickable set with action that starts an
 * activity.
 *
 * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
 * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
 * matching node(s) or in assertions to validate that node(s) satisfy the condition.
 *
 * @param componentName component of the activity that is expected to have been passed in the
 *                      `actionStartActivity` method call
 * @param parameters the parameters associated with the action that are expected to have been passed
 *                      in the `actionStartActivity` method call
 */
fun hasStartActivityClickAction(
    componentName: ComponentName,
    parameters: ActionParameters = actionParametersOf()
): GlanceNodeMatcher<MappedNode> = GlanceNodeMatcher(
    description = "has start activity click action with componentName: $componentName and " +
        "parameters: $parameters"
) { node ->
    node.value.emittable.modifier.any {
        if (it is ActionModifier) {
            val action = it.action
            if (action is StartActivityComponentAction) {
                return@any action.componentName == componentName &&
                    action.parameters == parameters
            }
        }
        false
    }
}