[go: nahoru, domu]

blob: 53c9775e1e05b5b0ed8ad06512ad7de0e6992a17 [file] [log] [blame]
/*
* Copyright 2021 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.tv.compose.foundation.lazy.list
import android.R.id.accessibilityActionScrollDown
import android.R.id.accessibilityActionScrollLeft
import android.R.id.accessibilityActionScrollRight
import android.R.id.accessibilityActionScrollUp
import android.view.View
import android.view.accessibility.AccessibilityNodeProvider
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD
import androidx.test.filters.MediumTest
import androidx.tv.foundation.PivotOffsets
import com.google.common.truth.IterableSubject
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@MediumTest
@RunWith(Parameterized::class)
class LazyScrollAccessibilityTest(private val config: TestConfig) {
data class TestConfig(
val horizontal: Boolean,
val rtl: Boolean,
val reversed: Boolean
) {
val vertical = !horizontal
override fun toString(): String {
return (if (horizontal) "horizontal" else "vertical") +
(if (rtl) ",rtl" else ",ltr") +
(if (reversed) ",reversed" else "")
}
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun params() =
listOf(true, false).flatMap { horizontal ->
listOf(false, true).flatMap { rtl ->
listOf(false, true).map { reversed ->
TestConfig(horizontal, rtl, reversed)
}
}
}
}
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
private val scrollerTag = "ScrollerTest"
private var composeView: View? = null
private val accessibilityNodeProvider: AccessibilityNodeProvider
get() = checkNotNull(composeView) {
"composeView not initialized. Did `composeView = LocalView.current` not work?"
}.let { composeView ->
ViewCompat
.getAccessibilityDelegate(composeView)!!
.getAccessibilityNodeProvider(composeView)!!
.provider as AccessibilityNodeProvider
}
@Test
fun scrollForward() {
testRelativeDirection(58, ACTION_SCROLL_FORWARD)
}
@Test
fun scrollBackward() {
testRelativeDirection(41, ACTION_SCROLL_BACKWARD)
}
@Test
fun scrollRight() {
testAbsoluteDirection(58, accessibilityActionScrollRight, config.horizontal)
}
@Test
fun scrollLeft() {
testAbsoluteDirection(41, accessibilityActionScrollLeft, config.horizontal)
}
@Test
fun scrollDown() {
testAbsoluteDirection(58, accessibilityActionScrollDown, config.vertical)
}
@Test
fun scrollUp() {
testAbsoluteDirection(41, accessibilityActionScrollUp, config.vertical)
}
@Test
fun verifyScrollActionsAtStart() {
createScrollableContent_StartAtStart()
verifyNodeInfoScrollActions(
expectForward = !config.reversed,
expectBackward = config.reversed
)
}
@Test
fun verifyScrollActionsInMiddle() {
createScrollableContent_StartInMiddle()
verifyNodeInfoScrollActions(
expectForward = true,
expectBackward = true
)
}
@Test
fun verifyScrollActionsAtEnd() {
createScrollableContent_StartAtEnd()
verifyNodeInfoScrollActions(
expectForward = config.reversed,
expectBackward = !config.reversed
)
}
/**
* Setup the test, run the given [accessibilityAction], and check if the [canonicalTarget]
* has been reached. The canonical target is the item that we expect to see when moving
* forward in a non-reversed scrollable (e.g. down in LazyColumn or right in LazyRow in LTR).
* The actual target is either the canonical target or the target that is as far from the
* middle of the lazy list as the canonical target, but on the other side of the middle,
* depending on the [configuration][config].
*/
private fun testRelativeDirection(canonicalTarget: Int, accessibilityAction: Int) {
val target = if (!config.reversed) canonicalTarget else 100 - canonicalTarget - 1
testScrollAction(target, accessibilityAction)
}
/**
* Setup the test, run the given [accessibilityAction], and check if the [canonicalTarget]
* has been reached (but only if we [expect][expectActionSuccess] the action to succeed).
* The canonical target is the item that we expect to see when moving forward in a
* non-reversed scrollable (e.g. down in LazyColumn or right in LazyRow in LTR). The actual
* target is either the canonical target or the target that is as far from the middle of the
* scrollable as the canonical target, but on the other side of the middle, depending on the
* [configuration][config].
*/
private fun testAbsoluteDirection(
canonicalTarget: Int,
accessibilityAction: Int,
expectActionSuccess: Boolean
) {
var target = canonicalTarget
if (config.horizontal && config.rtl) {
target = 100 - target - 1
}
if (config.reversed) {
target = 100 - target - 1
}
testScrollAction(target, accessibilityAction, expectActionSuccess)
}
/**
* Setup the test, run the given [accessibilityAction], and check if the [target] has been
* reached (but only if we [expect][expectActionSuccess] the action to succeed).
*/
private fun testScrollAction(
target: Int,
accessibilityAction: Int,
expectActionSuccess: Boolean = true
) {
createScrollableContent_StartInMiddle()
rule.onNodeWithText("$target").assertDoesNotExist()
val returnValue = rule.onNodeWithTag(scrollerTag).withSemanticsNode {
accessibilityNodeProvider.performAction(id, accessibilityAction, null)
}
assertThat(returnValue).isEqualTo(expectActionSuccess)
if (expectActionSuccess) {
rule.onNodeWithText("$target").assertIsDisplayed()
} else {
rule.onNodeWithText("$target").assertDoesNotExist()
}
}
/**
* Checks if all of the scroll actions are present or not according to what we expect based on
* [expectForward] and [expectBackward]. The scroll actions that are checked are forward,
* backward, left, right, up and down. The expectation parameters must already account for
* [reversing][TestConfig.reversed].
*/
private fun verifyNodeInfoScrollActions(expectForward: Boolean, expectBackward: Boolean) {
val nodeInfo = rule.onNodeWithTag(scrollerTag).withSemanticsNode {
rule.runOnUiThread {
accessibilityNodeProvider.createAccessibilityNodeInfo(id)
}
}
val actions = nodeInfo.actionList.map { it.id }
assertThat(actions).contains(expectForward, ACTION_SCROLL_FORWARD)
assertThat(actions).contains(expectBackward, ACTION_SCROLL_BACKWARD)
if (config.horizontal) {
val expectLeft = if (config.rtl) expectForward else expectBackward
val expectRight = if (config.rtl) expectBackward else expectForward
assertThat(actions).contains(expectLeft, accessibilityActionScrollLeft)
assertThat(actions).contains(expectRight, accessibilityActionScrollRight)
assertThat(actions).contains(false, accessibilityActionScrollDown)
assertThat(actions).contains(false, accessibilityActionScrollUp)
} else {
assertThat(actions).contains(false, accessibilityActionScrollLeft)
assertThat(actions).contains(false, accessibilityActionScrollRight)
assertThat(actions).contains(expectForward, accessibilityActionScrollDown)
assertThat(actions).contains(expectBackward, accessibilityActionScrollUp)
}
}
private fun IterableSubject.contains(expectPresent: Boolean, element: Any) {
if (expectPresent) {
contains(element)
} else {
doesNotContain(element)
}
}
/**
* Creates a Row/Column that starts at the first item, according to [createScrollableContent]
*/
private fun createScrollableContent_StartAtStart() {
createScrollableContent {
// Start at the start:
// -> pretty basic
rememberLazyListState(0, 0)
}
}
/**
* Creates a Row/Column that starts in the middle, according to [createScrollableContent]
*/
private fun createScrollableContent_StartInMiddle() {
createScrollableContent {
// Start at the middle:
// Content size: 100 items * 21dp per item = 2100dp
// Viewport size: 200dp rect - 50dp padding on both sides = 100dp
// Content outside viewport: 2100dp - 100dp = 2000dp
// -> centered when 1000dp on either side, which is 47 items + 13dp
rememberLazyListState(
47,
with(LocalDensity.current) { 13.dp.roundToPx() }
)
}
}
/**
* Creates a Row/Column that starts at the last item, according to [createScrollableContent]
*/
private fun createScrollableContent_StartAtEnd() {
createScrollableContent {
// Start at the end:
// Content size: 100 items * 21dp per item = 2100dp
// Viewport size: 200dp rect - 50dp padding on both sides = 100dp
// Content outside viewport: 2100dp - 100dp = 2000dp
// -> at the end when offset at 2000dp, which is 95 items + 5dp
rememberLazyListState(
95,
with(LocalDensity.current) { 5.dp.roundToPx() }
)
}
}
/**
* Creates a Row/Column with a viewport of 100.dp, containing 100 items each 17.dp in size.
* The items have a text with their index (ASC), and where the viewport starts is determined
* by the given [lambda][rememberLazyListState]. All properties from [config] are applied.
* The viewport has padding around it to make sure scroll distance doesn't include padding.
*/
private fun createScrollableContent(rememberLazyListState: @Composable () -> TvLazyListState) {
rule.setContent {
composeView = LocalView.current
val lazyContent: TvLazyListScope.() -> Unit = {
items(100) {
Box(Modifier.requiredSize(21.dp).background(Color.Yellow)) {
BasicText("$it", Modifier.align(Alignment.Center))
}
}
}
val state = rememberLazyListState()
Box(Modifier.requiredSize(200.dp).background(Color.White)) {
val direction = if (config.rtl) LayoutDirection.Rtl else LayoutDirection.Ltr
CompositionLocalProvider(LocalLayoutDirection provides direction) {
if (config.horizontal) {
TvLazyRow(
Modifier.testTag(scrollerTag).matchParentSize(),
state = state,
contentPadding = PaddingValues(50.dp),
reverseLayout = config.reversed,
verticalAlignment = Alignment.CenterVertically,
pivotOffsets =
PivotOffsets(parentFraction = 0f)
) {
lazyContent()
}
} else {
TvLazyColumn(
Modifier.testTag(scrollerTag).matchParentSize(),
state = state,
contentPadding = PaddingValues(50.dp),
reverseLayout = config.reversed,
horizontalAlignment = Alignment.CenterHorizontally,
pivotOffsets =
PivotOffsets(parentFraction = 0f)
) {
lazyContent()
}
}
}
}
}
}
private fun <T> SemanticsNodeInteraction.withSemanticsNode(block: SemanticsNode.() -> T): T {
return block.invoke(fetchSemanticsNode())
}
}