blob: ba9bc9b21da440b8782a65c7829d1be983b92f90 [file] [log] [blame]
/*
* 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.tv.material3
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.SemanticsNodeInteractionCollection
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEqualTo
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotFocused
import androidx.compose.ui.test.assertWidthIsEqualTo
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTvMaterial3Api::class)
class ModalNavigationDrawerTest {
@get:Rule
val rule = createComposeRule()
@Test
fun modalNavigationDrawer_initialStateClosed_closedStateComposableDisplayed() {
rule.setContent {
ModalNavigationDrawer(
drawerState = remember { DrawerState(DrawerValue.Closed) },
drawerContent = {
BasicText(text = if (it == DrawerValue.Open) "Opened" else "Closed")
}
) { Box(Modifier.size(200.dp)) }
}
rule.onAllNodesWithText("Closed").assertAnyAreDisplayed()
}
@Test
fun modalNavigationDrawer_initialStateOpen_openStateComposableDisplayed() {
rule.setContent {
ModalNavigationDrawer(
drawerState = remember { DrawerState(DrawerValue.Open) },
drawerContent = {
BasicText(text = if (it == DrawerValue.Open) "Opened" else "Closed")
}) { BasicText("other content") }
}
rule.onAllNodesWithText("Opened").assertAnyAreDisplayed()
}
@Test
fun modalNavigationDrawer_focusInsideDrawer_openedStateComposableDisplayed() {
InstrumentationRegistry.getInstrumentation().setInTouchMode(false)
val drawerFocusRequester = FocusRequester()
rule.setContent {
val navigationDrawerValue = remember { DrawerState(DrawerValue.Closed) }
ModalNavigationDrawer(
modifier = Modifier.focusRequester(drawerFocusRequester),
drawerState = navigationDrawerValue,
drawerContent = {
BasicText(
text =
if (it == DrawerValue.Open) "Opened" else "Closed"
)
}) { BasicText("other content") }
}
rule.onAllNodesWithText("Closed").assertAnyAreDisplayed()
rule.runOnIdle {
drawerFocusRequester.requestFocus()
}
rule.onAllNodesWithText("Opened").assertAnyAreDisplayed()
}
@OptIn(ExperimentalTestApi::class, ExperimentalComposeUiApi::class)
@Test
fun modalNavigationDrawer_focusMovesOutOfDrawer_closedStateComposableDisplayed() {
InstrumentationRegistry.getInstrumentation().setInTouchMode(false)
val drawerFocusRequester = FocusRequester()
rule.setContent {
val navigationDrawerValue = remember { DrawerState(DrawerValue.Closed) }
Row {
ModalNavigationDrawer(
modifier = Modifier
.focusRequester(drawerFocusRequester)
.focusable(false),
drawerState = navigationDrawerValue,
drawerContent = {
BasicText(text = if (it == DrawerValue.Open) "Opened" else "Closed")
}) {
Box(modifier = Modifier.focusable()) {
BasicText("Button")
}
}
}
}
rule.runOnIdle {
drawerFocusRequester.requestFocus()
}
rule.onAllNodesWithText("Opened").assertAnyAreDisplayed()
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
rule.onAllNodesWithText("Closed").assertAnyAreDisplayed()
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalTestApi::class)
@Test
fun modalNavigationDrawer_focusMovesIntoDrawer_openStateComposableDisplayed() {
InstrumentationRegistry.getInstrumentation().setInTouchMode(false)
val buttonFocusRequester = FocusRequester()
rule.setContent {
val navigationDrawerValue = remember { DrawerState(DrawerValue.Closed) }
Row {
ModalNavigationDrawer(
drawerState = navigationDrawerValue,
drawerContent = {
var isFocused by remember { mutableStateOf(false) }
BasicText(
text = if (it == DrawerValue.Open) "Opened" else "Closed",
modifier = Modifier
.onFocusChanged { focusState ->
isFocused = focusState.isFocused
}
.background(if (isFocused) Color.Green else Color.Yellow)
.focusable()
.testTag("drawerItem")
)
}) {
Box(
modifier = Modifier
.focusRequester(buttonFocusRequester)
.focusable()
) {
BasicText("Button")
}
}
}
}
rule.runOnIdle {
buttonFocusRequester.requestFocus()
}
rule.onAllNodesWithText("Closed").assertAnyAreDisplayed()
rule.onRoot().performKeyInput { pressKey(Key.DirectionLeft) }
rule.waitForIdle()
rule.onAllNodesWithText("Opened").assertAnyAreDisplayed()
rule.onNodeWithTag("drawerItem").assertIsFocused()
}
@Test
fun modalNavigationDrawer_closedState_widthOfDrawerIsWidthOfContent() {
val contentWidthBoxTag = "contentWidthBox"
val totalWidth = 100.dp
val closedDrawerContentWidth = 30.dp
val expectedContentWidth = totalWidth - closedDrawerContentWidth
rule.setContent {
Box(modifier = Modifier.width(totalWidth)) {
NavigationDrawer(
drawerState = remember { DrawerState(DrawerValue.Closed) },
drawerContent = {
Box(Modifier.width(closedDrawerContentWidth)) {
// extra long content wrapped in a drawer-width restricting box
Box(Modifier.width(closedDrawerContentWidth * 10))
}
}
) { Box(
Modifier
.fillMaxWidth()
.testTag(contentWidthBoxTag)) }
}
}
rule.onNodeWithTag(contentWidthBoxTag).assertWidthIsEqualTo(expectedContentWidth)
}
@Test
fun modalNavigationDrawer_openState_widthOfDrawerIsWidthOfContent() {
val contentWidthBoxTag = "contentWidthBox"
val totalWidth = 100.dp
val openDrawerContentWidth = 70.dp
val expectedContentWidth = totalWidth - openDrawerContentWidth
rule.setContent {
Box(modifier = Modifier.width(totalWidth)) {
NavigationDrawer(
drawerState = remember { DrawerState(DrawerValue.Closed) },
drawerContent = {
Box(Modifier.width(openDrawerContentWidth)) {
Box(Modifier.width(openDrawerContentWidth * 10))
}
}
) { Box(
Modifier
.fillMaxWidth()
.testTag(contentWidthBoxTag)) }
}
}
rule.onNodeWithTag(contentWidthBoxTag).assertWidthIsEqualTo(expectedContentWidth)
}
@Test
fun modalNavigationDrawer_rtl_drawerIsDrawnAtTheStart() {
val contentWidthBoxTag = "contentWidthBox"
val drawerContentBoxTag = "drawerContentBox"
rule.setContent {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
ModalNavigationDrawer(
drawerState = remember { DrawerState(DrawerValue.Closed) },
drawerContent = {
Box(
Modifier
.testTag(drawerContentBoxTag)
.border(2.dp, Color.Red)) {
BasicText(text = if (it == DrawerValue.Open) "Opened" else "Closed")
}
}
) { Box(
Modifier
.fillMaxWidth()
.testTag(contentWidthBoxTag)) }
}
}
val rightEdgeOfRoot = rule.onRoot().getUnclippedBoundsInRoot().right
rule.onNodeWithTag(drawerContentBoxTag).assertRightPositionInRootIsEqualTo(rightEdgeOfRoot)
}
@Test
fun modalNavigationDrawer_rtl_drawerExpandsTowardsEnd() {
val contentWidthBoxTag = "contentWidthBox"
val drawerContentBoxTag = "drawerContentBox"
var drawerState: DrawerState? = null
rule.setContent {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
drawerState = remember { DrawerState(DrawerValue.Closed) }
ModalNavigationDrawer(
drawerState = drawerState!!,
drawerContent = {
Box(
Modifier
.testTag(drawerContentBoxTag)
.border(2.dp, Color.Red)) {
BasicText(text = if (it == DrawerValue.Open) "Opened" else "Closed")
}
}
) { Box(
Modifier
.fillMaxWidth()
.testTag(contentWidthBoxTag)) }
}
}
val endPositionInClosedState =
rule.onNodeWithTag(drawerContentBoxTag).getUnclippedBoundsInRoot().left
rule.runOnIdle { drawerState?.setValue(DrawerValue.Open) }
val endPositionInOpenState =
rule.onNodeWithTag(drawerContentBoxTag).getUnclippedBoundsInRoot().left
assert(endPositionInClosedState.value > endPositionInOpenState.value)
}
@OptIn(ExperimentalTestApi::class, ExperimentalComposeUiApi::class)
@Test
fun modalNavigationDrawer_parentContainerGainsFocus_onBackPress() {
val drawerFocusRequester = FocusRequester()
rule.setContent {
Box(
modifier = Modifier
.testTag("box-container")
.fillMaxSize()
.focusable()
) {
ModalNavigationDrawer(
modifier = Modifier.focusRequester(drawerFocusRequester),
drawerState = remember { DrawerState(DrawerValue.Closed) },
drawerContent = {
BasicText(
text = if (it == DrawerValue.Open) "Opened" else "Closed",
modifier = Modifier.focusable()
)
}
) {
BasicText("other content")
}
}
}
rule.onAllNodesWithText("Closed").assertAnyAreDisplayed()
rule.runOnIdle {
drawerFocusRequester.requestFocus()
}
rule.onAllNodesWithText("Opened").assertAnyAreDisplayed()
rule.onNodeWithTag("box-container").assertIsNotFocused()
// Trigger back press
rule.onRoot().performKeyInput { pressKey(Key.Back) }
rule.waitForIdle()
// Check if the parent container gains focus
rule.onNodeWithTag("box-container").assertIsFocused()
}
private fun SemanticsNodeInteractionCollection.assertAnyAreDisplayed() {
val result = (0 until fetchSemanticsNodes().size).map { get(it) }.any {
try {
it.assertIsDisplayed()
true
} catch (e: AssertionError) {
false
}
}
if (!result) throw AssertionError("Assert failed: None of the components are displayed!")
}
private fun SemanticsNodeInteraction.assertRightPositionInRootIsEqualTo(
expectedRight: Dp
): SemanticsNodeInteraction {
return withUnclippedBoundsInRoot {
it.right.assertIsEqualTo(expectedRight, "right")
}
}
private fun SemanticsNodeInteraction.withUnclippedBoundsInRoot(
assertion: (DpRect) -> Unit
): SemanticsNodeInteraction {
val node = fetchSemanticsNode("Failed to retrieve bounds of the node.")
val bounds = with(node.layoutInfo.density) {
node.unclippedBoundsInRoot.let {
DpRect(it.left.toDp(), it.top.toDp(), it.right.toDp(), it.bottom.toDp())
}
}
assertion.invoke(bounds)
return this
}
private val SemanticsNode.unclippedBoundsInRoot: Rect
get() {
return if (layoutInfo.isPlaced) {
Rect(positionInRoot, size.toSize())
} else {
Dp.Unspecified.value.let { Rect(it, it, it, it) }
}
}
}