blob: 0f6574ddbf7477df9eaabe4c2672f8485f28896f [file] [log] [blame]
/*
* Copyright 2020 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.foundation.textfield
import android.os.Build
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.FocusedWindowTest
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.computeSizeForDefaultText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.testutils.assertPixelColor
import androidx.compose.testutils.assertShape
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalTextInputService
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.click
import androidx.compose.ui.test.hasImeAction
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.isFocused
import androidx.compose.ui.test.isNotFocused
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextInputSelection
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.requestFocus
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.UrlAnnotation
import androidx.compose.ui.text.VerbatimTtsAnnotation
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontSynthesis
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.toFontFamily
import androidx.compose.ui.text.input.CommitTextCommand
import androidx.compose.ui.text.input.EditCommand
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.PlatformTextInputService
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.TextFieldValue.Companion.Saver
import androidx.compose.ui.text.input.TextInputService
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.intl.LocaleList
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextGeometricTransform
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.text.toUpperCase
import androidx.compose.ui.text.withAnnotation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.FlakyTest
import androidx.test.filters.LargeTest
import androidx.test.filters.MediumTest
import androidx.test.filters.SdkSuppress
import androidx.testutils.fonts.R
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import kotlin.test.assertFailsWith
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.atLeastOnce
import org.mockito.kotlin.mock
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
@MediumTest
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalFoundationApi::class)
class TextFieldTest : FocusedWindowTest {
@get:Rule
val rule = createComposeRule()
private val Tag = "textField"
// This sample font provides the following features:
// 1. The width of most of visible characters equals to font size.
// 2. The LTR/RTL characters are rendered as â–¶/â—€.
// 3. The fontMetrics passed to TextPaint has descend - ascend equal to 1.2 * fontSize.
private val measureFontFamily = Font(
resId = R.font.sample_font,
weight = FontWeight.Normal,
style = FontStyle.Normal
).toFontFamily()
@Test
fun textField_focusInSemantics() {
val inputService = TextInputService(mock())
var isFocused = false
rule.setContent {
val state = remember { mutableStateOf("") }
CompositionLocalProvider(
LocalTextInputService provides inputService
) {
BasicTextField(
value = state.value,
modifier = Modifier
.fillMaxSize()
.onFocusChanged { isFocused = it.isFocused },
onValueChange = { state.value = it }
)
}
}
rule.onNode(hasSetTextAction()).performClick()
rule.runOnIdle {
assertThat(isFocused).isTrue()
}
}
@Composable
private fun TextFieldApp() {
val state = remember { mutableStateOf("") }
BasicTextField(
value = state.value,
modifier = Modifier.fillMaxSize(),
onValueChange = {
state.value = it
}
)
}
@Test
fun textField_commitTexts() {
val platformTextInputService = mock<PlatformTextInputService>()
val textInputService = TextInputService(platformTextInputService)
rule.setContent {
CompositionLocalProvider(
LocalTextInputService provides textInputService
) {
TextFieldApp()
}
}
rule.onNode(hasSetTextAction()).performClick()
var onEditCommandCallback: ((List<EditCommand>) -> Unit)? = null
rule.runOnIdle {
// Verify startInput is called and capture the callback.
val onEditCommandCaptor = argumentCaptor<(List<EditCommand>) -> Unit>()
verify(platformTextInputService, times(1)).startInput(
value = any(),
imeOptions = any(),
onEditCommand = onEditCommandCaptor.capture(),
onImeActionPerformed = any()
)
assertThat(onEditCommandCaptor.allValues.size).isEqualTo(1)
onEditCommandCallback = onEditCommandCaptor.firstValue
assertThat(onEditCommandCallback).isNotNull()
}
// Performs input events "1", "a", "2", "b", "3". Only numbers should remain.
arrayOf(
listOf(CommitTextCommand("1", 1)),
listOf(CommitTextCommand("a", 1)),
listOf(CommitTextCommand("2", 1)),
listOf(CommitTextCommand("b", 1)),
listOf(CommitTextCommand("3", 1))
).forEach {
// TODO: This should work only with runOnUiThread. But it seems that these events are
// not buffered and chaining multiple of them before composition happens makes them to
// get lost.
rule.runOnIdle { onEditCommandCallback!!.invoke(it) }
}
rule.runOnIdle {
val stateCaptor = argumentCaptor<TextFieldValue>()
verify(platformTextInputService, atLeastOnce())
.updateState(any(), stateCaptor.capture())
// Don't care about the intermediate state update. It should eventually be "1a2b3".
assertThat(stateCaptor.lastValue.text).isEqualTo("1a2b3")
}
}
@Composable
private fun OnlyDigitsApp() {
val state = remember { mutableStateOf("") }
BasicTextField(
value = state.value,
modifier = Modifier.fillMaxSize(),
onValueChange = { value ->
if (value.all { it.isDigit() }) {
state.value = value
}
}
)
}
@Test
fun textField_commitTexts_state_may_not_set() {
val platformTextInputService = mock<PlatformTextInputService>()
val textInputService = TextInputService(platformTextInputService)
rule.setContent {
CompositionLocalProvider(
LocalTextInputService provides textInputService
) {
OnlyDigitsApp()
}
}
rule.onNode(hasSetTextAction()).performClick()
var onEditCommandCallback: ((List<EditCommand>) -> Unit)? = null
rule.runOnIdle {
// Verify startInput is called and capture the callback.
val onEditCommandCaptor = argumentCaptor<(List<EditCommand>) -> Unit>()
verify(platformTextInputService, times(1)).startInput(
value = any(),
imeOptions = any(),
onEditCommand = onEditCommandCaptor.capture(),
onImeActionPerformed = any()
)
assertThat(onEditCommandCaptor.allValues.size).isEqualTo(1)
onEditCommandCallback = onEditCommandCaptor.firstValue
assertThat(onEditCommandCallback).isNotNull()
}
// Performs input events "1", "a", "2", "b", "3". Only numbers should remain.
arrayOf(
listOf(CommitTextCommand("1", 1)),
listOf(CommitTextCommand("a", 1)),
listOf(CommitTextCommand("2", 1)),
listOf(CommitTextCommand("b", 1)),
listOf(CommitTextCommand("3", 1))
).forEach {
// TODO: This should work only with runOnUiThread. But it seems that these events are
// not buffered and chaining multiple of them before composition happens makes them to
// get lost.
rule.runOnIdle { onEditCommandCallback!!.invoke(it) }
}
rule.runOnIdle {
val stateCaptor = argumentCaptor<TextFieldValue>()
verify(platformTextInputService, atLeastOnce())
.updateState(any(), stateCaptor.capture())
// Don't care about the intermediate state update. It should eventually be "123" since
// the rejects if the incoming model contains alphabets.
assertThat(stateCaptor.lastValue.text).isEqualTo("123")
}
}
@Test
fun textField_onTextLayoutCallback() {
val platformTextInputService = mock<PlatformTextInputService>()
val textInputService = TextInputService(platformTextInputService)
val onTextLayout: (TextLayoutResult) -> Unit = mock()
rule.setContent {
CompositionLocalProvider(
LocalTextInputService provides textInputService
) {
val state = remember { mutableStateOf("") }
BasicTextField(
value = state.value,
modifier = Modifier.fillMaxSize(),
onValueChange = {
state.value = it
},
onTextLayout = onTextLayout
)
}
}
rule.onNode(hasSetTextAction()).performClick()
var onEditCommandCallback: ((List<EditCommand>) -> Unit)? = null
rule.runOnIdle {
// Verify startInput is called and capture the callback.
val onEditCommandCaptor = argumentCaptor<(List<EditCommand>) -> Unit>()
verify(platformTextInputService, times(1)).startInput(
value = any(),
imeOptions = any(),
onEditCommand = onEditCommandCaptor.capture(),
onImeActionPerformed = any()
)
assertThat(onEditCommandCaptor.allValues.size).isEqualTo(1)
onEditCommandCallback = onEditCommandCaptor.firstValue
assertThat(onEditCommandCallback).isNotNull()
}
// Performs input events "1", "2", "3".
arrayOf(
listOf(CommitTextCommand("1", 1)),
listOf(CommitTextCommand("2", 1)),
listOf(CommitTextCommand("3", 1))
).forEach {
// TODO: This should work only with runOnUiThread. But it seems that these events are
// not buffered and chaining multiple of them before composition happens makes them to
// get lost.
rule.runOnIdle { onEditCommandCallback!!.invoke(it) }
}
rule.runOnIdle {
val layoutCaptor = argumentCaptor<TextLayoutResult>()
verify(onTextLayout, atLeastOnce()).invoke(layoutCaptor.capture())
// Don't care about the intermediate state update. It should eventually be "123"
assertThat(layoutCaptor.lastValue.layoutInput.text.text).isEqualTo("123")
}
}
@Test
fun textFieldInRow_fixedElementIsVisible() {
val parentSize = 300.dp
val boxSize = 50.dp
var size: Int? = null
rule.setContent {
Box(Modifier.size(parentSize)) {
Row {
BasicTextField(
value = "",
onValueChange = {},
modifier = Modifier
.weight(1f)
.onGloballyPositioned {
size = it.size.width
}
)
Box(Modifier.size(boxSize))
}
}
}
with(rule.density) {
assertThat(size).isEqualTo(parentSize.roundToPx() - boxSize.roundToPx())
}
}
@Test
fun textFieldValue_saverRestoresState() {
var state: MutableState<TextFieldValue>? = null
val restorationTester = StateRestorationTester(rule)
restorationTester.setContent {
state = rememberSaveable(stateSaver = Saver) { mutableStateOf(TextFieldValue()) }
}
rule.runOnIdle {
state!!.value = TextFieldValue("test", TextRange(1, 2))
// we null it to ensure recomposition happened
state = null
}
restorationTester.emulateSavedInstanceStateRestore()
rule.runOnIdle {
assertThat(state!!.value).isEqualTo(
TextFieldValue("test", TextRange(1, 2))
)
}
}
@OptIn(ExperimentalTextApi::class)
@Test
fun textFieldValue_saverRestoresState_withAnnotatedString() {
var state: MutableState<TextFieldValue>? = null
val annotatedString = buildAnnotatedString {
withStyle(ParagraphStyle(textAlign = TextAlign.Justify)) { append("1") }
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append("2") }
withAnnotation(tag = "Tag1", annotation = "Annotation1") { append("3") }
withAnnotation(VerbatimTtsAnnotation("verbatim1")) { append("4") }
withAnnotation(tag = "Tag2", annotation = "Annotation2") { append("5") }
withAnnotation(VerbatimTtsAnnotation("verbatim2")) { append("6") }
withAnnotation(UrlAnnotation("url1")) { append("7") }
withAnnotation(UrlAnnotation("url2")) { append("8") }
withStyle(
SpanStyle(
color = Color.Red,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic,
fontSynthesis = FontSynthesis.All,
fontFeatureSettings = "feature settings",
letterSpacing = 2.em,
baselineShift = BaselineShift.Superscript,
textGeometricTransform = TextGeometricTransform(2f, 3f),
localeList = LocaleList(
Locale("sr-Latn-SR"),
Locale("sr-Cyrl-SR"),
Locale.current
),
background = Color.Blue,
textDecoration = TextDecoration.LineThrough,
shadow = Shadow(color = Color.Red, offset = Offset(2f, 2f), blurRadius = 4f)
)
) {
append("7")
}
withStyle(
ParagraphStyle(
textAlign = TextAlign.Justify,
textDirection = TextDirection.Rtl,
lineHeight = 10.sp,
textIndent = TextIndent(firstLine = 2.sp, restLine = 3.sp)
)
) {
append("8")
}
}
val newTextFieldValue = TextFieldValue(annotatedString, TextRange(1, 2))
val restorationTester = StateRestorationTester(rule)
restorationTester.setContent {
state = rememberSaveable(stateSaver = Saver) { mutableStateOf(TextFieldValue()) }
}
rule.runOnIdle {
state!!.value = newTextFieldValue
// we null it to ensure recomposition happened
state = null
}
restorationTester.emulateSavedInstanceStateRestore()
rule.runOnIdle {
assertThat(state!!.value).isEqualTo(newTextFieldValue)
}
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun textFieldNotFocused_cursorNotRendered() {
rule.setContent {
BasicTextField(
value = "",
onValueChange = {},
textStyle = TextStyle(color = Color.White),
modifier = Modifier
.size(10.dp, 20.dp)
.background(color = Color.White),
cursorBrush = SolidColor(Color.Blue)
)
}
rule.onNode(hasSetTextAction())
.captureToImage()
.assertShape(
density = rule.density,
shape = RectangleShape,
shapeColor = Color.White,
backgroundColor = Color.White,
shapeOverlapPixelCount = 0.0f
)
}
@Test
fun defaultSemantics() {
rule.setContent {
BasicTextField(
modifier = Modifier.testTag(Tag),
value = "",
onValueChange = {},
decorationBox = {
Column {
BasicText("label")
it()
}
}
)
}
rule.onNodeWithTag(Tag)
.assertEditableTextEquals("")
.assertTextEquals("label", includeEditableText = false)
.assertHasClickAction()
.assert(hasSetTextAction())
.assert(hasImeAction(ImeAction.Default))
.assert(isNotFocused())
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.TextSelectionRange,
TextRange.Zero
)
)
.assert(SemanticsMatcher.keyIsDefined(SemanticsActions.SetText))
.assert(SemanticsMatcher.keyIsDefined(SemanticsActions.SetSelection))
.assert(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult))
val textLayoutResults = mutableListOf<TextLayoutResult>()
rule.onNodeWithTag(Tag)
.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(textLayoutResults) }
assert(textLayoutResults.size == 1) { "TextLayoutResult is null" }
}
@Test
fun semantics_setTextAction_doesNothingWhenReadOnly() {
rule.setContent {
var value by remember { mutableStateOf("") }
BasicTextField(
modifier = Modifier.testTag(Tag),
value = value,
onValueChange = { value = it },
readOnly = true
)
}
rule.onNodeWithTag(Tag)
.performTextReplacement("hello")
rule.onNodeWithTag(Tag)
.assertEditableTextEquals("")
}
@Test
fun semantics_setTextAction_throwsWhenDisabled() {
rule.setContent {
var value by remember { mutableStateOf("") }
BasicTextField(
modifier = Modifier.testTag(Tag),
value = value,
onValueChange = { value = it },
enabled = false
)
}
assertFailsWith<AssertionError> {
rule.onNodeWithTag(Tag)
.performTextReplacement("hello")
}
}
@Test
fun semantics_insertTextAction_doesNothingWhenReadOnly() {
rule.setContent {
var value by remember { mutableStateOf("") }
BasicTextField(
modifier = Modifier.testTag(Tag),
value = value,
onValueChange = { value = it },
readOnly = true
)
}
rule.onNodeWithTag(Tag)
.performTextInput("hello")
rule.onNodeWithTag(Tag)
.assertEditableTextEquals("")
}
@Test
fun semantics_insertTextAction_throwsWhenDisabled() {
rule.setContent {
var value by remember { mutableStateOf("") }
BasicTextField(
modifier = Modifier.testTag(Tag),
value = value,
onValueChange = { value = it },
enabled = false
)
}
assertFailsWith<AssertionError> {
rule.onNodeWithTag(Tag)
.performTextInput("hello")
}
}
@Test
fun semantics_clickAction() {
rule.setContent {
var value by remember { mutableStateOf("") }
BasicTextField(
modifier = Modifier.testTag(Tag),
value = value,
onValueChange = { value = it }
)
}
rule.onNodeWithTag(Tag)
.assert(isNotFocused())
.performSemanticsAction(SemanticsActions.OnClick)
rule.onNodeWithTag(Tag)
.assert(isFocused())
}
@Test
fun semantics_imeEnterAction() {
var done = false
rule.setContent {
var value by remember { mutableStateOf("") }
BasicTextField(
modifier = Modifier.testTag(Tag),
value = value,
onValueChange = { value = it },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { done = true })
)
}
rule.onNodeWithTag(Tag)
.requestFocus()
.assertIsFocused()
rule.runOnIdle {
assertThat(done).isFalse()
}
rule.onNodeWithTag(Tag)
.performImeAction()
rule.runOnIdle {
assertThat(done).isTrue()
}
}
@Test
fun semantics_defaultImeEnterAction() {
rule.setContent {
var value by remember { mutableStateOf("") }
BasicTextField(
modifier = Modifier.testTag(Tag),
value = value,
onValueChange = { value = it },
keyboardActions = KeyboardActions()
)
}
rule.onNodeWithTag(Tag)
.requestFocus()
.assertIsFocused()
val error = assertFailsWith<AssertionError> {
rule.onNodeWithTag(Tag)
.performImeAction()
}
assertThat(error).hasMessageThat().startsWith(
"Failed to perform IME action.\n" +
"Failed to assert the following: (NOT (ImeAction = 'Default'))\n" +
"Semantics of the node:"
)
}
@Test
fun semantics_setTextSetSelectionActions() {
rule.setContent {
var value by remember { mutableStateOf("") }
BasicTextField(
modifier = Modifier.testTag(Tag),
value = value,
onValueChange = { value = it }
)
}
val hello = AnnotatedString("Hello")
rule.onNodeWithTag(Tag)
.assertEditableTextEquals("")
.performSemanticsAction(SemanticsActions.SetText) { it(hello) }
.assertEditableTextEquals(hello.text)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.TextSelectionRange,
TextRange(hello.length)
)
)
rule.onNodeWithTag(Tag)
.performSemanticsAction(SemanticsActions.SetSelection) { it(1, 3, true) }
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.TextSelectionRange,
TextRange(1, 3)
)
)
}
@Test
fun setImeAction_isReflectedInSemantics() {
rule.setContent {
BasicTextField(
value = "",
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
onValueChange = {}
)
}
rule.onNode(hasSetTextAction())
.assert(hasImeAction(ImeAction.Search))
}
@Test
fun semantics_copyTextAction() {
val text = "Hello World"
var value by mutableStateOf(TextFieldValue(text, TextRange(0, 5)))
rule.setContent {
BasicTextField(
modifier = Modifier.testTag(Tag),
value = value,
onValueChange = { value = it }
)
}
rule.onNodeWithTag(Tag)
.performSemanticsAction(SemanticsActions.CopyText) { it() }
rule.runOnIdle {
assertThat(value.selection).isEqualTo(TextRange(5, 5))
}
}
@Test
fun semantics_pasteTextAction() {
val text = "Hello World"
var value by mutableStateOf(TextFieldValue(text, TextRange(0, 6)))
rule.setContent {
BasicTextField(
modifier = Modifier.testTag(Tag),
value = value,
onValueChange = { value = it }
)
}
// copy text to the clipboard
rule.onNodeWithTag(Tag)
.performSemanticsAction(SemanticsActions.CopyText) { it() }
rule.runOnIdle {
assertThat(value.selection.collapsed).isTrue()
assertThat(value.selection.start).isEqualTo(6)
}
// paste text from the clipboard
rule.onNodeWithTag(Tag)
.performSemanticsAction(SemanticsActions.PasteText) { it() }
rule.runOnIdle {
assertThat(value.text).isEqualTo("Hello Hello World")
}
}
@Test
fun semantics_cutTextAction() {
val text = "Hello World"
var value by mutableStateOf(TextFieldValue(text, TextRange(0, 6)))
rule.setContent {
BasicTextField(
modifier = Modifier.testTag(Tag),
value = value,
onValueChange = { value = it }
)
}
rule.onNodeWithTag(Tag)
.performSemanticsAction(SemanticsActions.CutText) { it() }
rule.runOnIdle {
assertThat(value.text).isEqualTo("World")
assertThat(value.selection).isEqualTo(TextRange(0, 0))
}
}
@Test
fun semantics_passwordTextField_noCopyCutActions() {
rule.setContent {
BasicTextField(
modifier = Modifier.testTag(Tag),
value = TextFieldValue("Hello", TextRange(0, 3)),
onValueChange = {},
visualTransformation = PasswordVisualTransformation()
)
}
rule.onNodeWithTag(Tag)
.assert(SemanticsMatcher.keyNotDefined(SemanticsActions.CopyText))
.assert(SemanticsMatcher.keyNotDefined(SemanticsActions.CutText))
}
@Test
fun semantics_transformedText() {
rule.setContent {
BasicTextField(
modifier = Modifier.testTag(Tag),
value = TextFieldValue("Hello"),
onValueChange = {},
visualTransformation = { text ->
TransformedText(
text.toUpperCase(LocaleList("en_US")),
OffsetMapping.Identity
)
}
)
}
rule.onNodeWithTag(Tag)
.assertTextEquals("HELLO")
}
@LargeTest
@Test
fun semantics_longClick() {
val text = "Hello World"
var value by mutableStateOf(TextFieldValue(text, TextRange(text.length)))
var toolbar: TextToolbar? = null
rule.setTextFieldTestContent {
toolbar = LocalTextToolbar.current
BasicTextField(
modifier = Modifier.testTag(Tag),
value = value,
onValueChange = { value = it }
)
}
rule.runOnIdle {
assertThat(toolbar?.status).isEqualTo(TextToolbarStatus.Hidden)
}
rule.onNodeWithTag(Tag)
.performSemanticsAction(SemanticsActions.OnLongClick) { it() }
rule.runOnIdle {
assertThat(toolbar?.status).isEqualTo(TextToolbarStatus.Shown)
}
}
@Test
fun stringOverrideTextField_canDeleteLastSymbol() {
var lastSeenText = ""
rule.setContent {
var text by remember { mutableStateOf("") }
BasicTextField(
value = text,
onValueChange = {
text = it
lastSeenText = it
},
modifier = Modifier.testTag(Tag)
)
}
rule.onNodeWithTag(Tag)
.performTextInput("A")
rule.runOnIdle {
assertThat(lastSeenText).isEqualTo("A")
}
rule.onNodeWithTag(Tag)
.performTextClearance()
rule.runOnIdle {
assertThat(lastSeenText).isEqualTo("")
}
}
@Test
fun decorationBox_clickable() {
val interactionSource = MutableInteractionSource()
var scope: CoroutineScope? = null
rule.setContent {
scope = rememberCoroutineScope()
Column {
BasicTextField(
value = "test",
onValueChange = {},
textStyle = TextStyle(fontSize = 2.sp),
modifier = Modifier
.requiredHeight(100.dp)
.fillMaxWidth(),
decorationBox = {
// the core text field is at the very bottom
Column {
BasicText("Label", Modifier.testTag("label"))
Spacer(Modifier.weight(1f))
it()
}
},
interactionSource = interactionSource
)
}
}
val interactions = mutableListOf<Interaction>()
scope!!.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
// click outside core text field area
rule.onNodeWithTag("label", useUnmergedTree = true)
.performTouchInput {
click(Offset.Zero)
}
rule.runOnIdle {
// Not asserting total size as we have other interactions here too
assertThat(interactions.filterIsInstance<FocusInteraction.Focus>()).hasSize(1)
}
}
@Test
fun textField_stringOverload_callsOnValueChange_whenTextChange() {
var onValueChangeCalled = false
rule.setContent {
val state = remember { mutableStateOf("abc") }
BasicTextField(
modifier = Modifier.testTag(Tag),
value = state.value,
onValueChange = {
onValueChangeCalled = true
state.value = it
}
)
}
@OptIn(ExperimentalTestApi::class)
rule.onNodeWithTag(Tag)
.performClick()
.performTextInputSelection(TextRange(0, 0))
// reset
rule.runOnIdle {
onValueChangeCalled = false
}
// change selection
@OptIn(ExperimentalTestApi::class)
rule.onNodeWithTag(Tag)
.performTextInputSelection(TextRange(1, 1))
rule.runOnIdle {
assertThat(onValueChangeCalled).isFalse()
}
// change text
rule.onNodeWithTag(Tag)
.performTextInput("d")
rule.runOnIdle {
assertThat(onValueChangeCalled).isTrue()
}
}
@Test
@Ignore // b/184750119
fun textField_callsOnValueChange_whenTextFieldValueChange() {
var onValueChangeCalled = false
var lastSeenTextFieldValue = TextFieldValue()
rule.setContent {
val state = remember { mutableStateOf(TextFieldValue("abc")) }
BasicTextField(
modifier = Modifier.testTag(Tag),
value = state.value,
onValueChange = {
onValueChangeCalled = true
lastSeenTextFieldValue = it
state.value = it
}
)
}
@OptIn(ExperimentalTestApi::class)
rule.onNodeWithTag(Tag)
.performClick()
.performTextInputSelection(TextRange(0, 0))
// reset flag since click might change selection
rule.runOnIdle {
onValueChangeCalled = false
}
@OptIn(ExperimentalTestApi::class)
rule.onNodeWithTag(Tag)
.performTextInputSelection(TextRange(1, 1))
// selection changed
rule.runOnIdle {
assertWithMessage("$lastSeenTextFieldValue").that(onValueChangeCalled).isTrue()
// reset flag
onValueChangeCalled = false
}
rule.waitUntil { onValueChangeCalled == false }
// set selection to same value, no change should occur
@OptIn(ExperimentalTestApi::class)
rule.onNodeWithTag(Tag)
.performTextInputSelection(TextRange(1, 1))
rule.runOnIdle {
assertWithMessage("$lastSeenTextFieldValue").that(onValueChangeCalled).isFalse()
}
rule.onNodeWithTag(Tag)
.performTextInput("d")
rule.runOnIdle {
assertWithMessage("$lastSeenTextFieldValue").that(onValueChangeCalled).isTrue()
}
}
@Test
fun textField_stringOverload_doesNotCallOnValueChange_whenCompositionUpdatesOnly() {
var callbackCounter = 0
rule.setContent {
val focusManager = LocalFocusManager.current
val text = remember { mutableStateOf("A") }
BasicTextField(
value = text.value,
onValueChange = {
callbackCounter += 1
text.value = it
// causes TextFieldValue's composition clearing
focusManager.clearFocus(true)
},
modifier = Modifier.testTag("tag")
)
}
rule.onNodeWithTag("tag")
.performClick()
rule.waitForIdle()
rule.onNodeWithTag("tag")
.performTextClearance()
rule.runOnIdle {
assertThat(callbackCounter).isEqualTo(1)
}
}
@OptIn(ExperimentalTestApi::class)
@Test
fun textField_stringOverload_doesNotCallOnValueChange_ifSelectionInherentlyChanges() {
var callbackCounter = 0
val text = mutableStateOf("ABCDE")
rule.setContent {
BasicTextField(
value = text.value,
onValueChange = {
callbackCounter += 1
text.value = it
},
modifier = Modifier.testTag("tag")
)
}
rule.onNodeWithTag("tag").performTextInputSelection(TextRange(0, 4))
rule.waitForIdle()
text.value = ""
rule.waitForIdle()
text.value = "ABCDE"
rule.runOnIdle {
assertThat(callbackCounter).isEqualTo(0)
}
}
@Test
fun textField_stringOverload_doesNotCallOnValueChange_whenCompositionUpdatesOnly_semantics() {
var callbackCounter = 0
rule.setContent {
val focusManager = LocalFocusManager.current
val text = remember { mutableStateOf("A") }
BasicTextField(
value = text.value,
onValueChange = {
callbackCounter += 1
text.value = it
// causes TextFieldValue's composition clearing
focusManager.clearFocus(true)
},
modifier = Modifier.testTag("tag")
)
}
rule.onNodeWithTag("tag")
.performClick()
rule.waitForIdle()
rule.onNodeWithTag("tag")
.performSemanticsAction(SemanticsActions.SetText) { it(AnnotatedString("")) }
rule.runOnIdle {
assertThat(callbackCounter).isEqualTo(1)
}
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun textField_textAlignCenter_defaultWidth() {
val fontSize = 50
val density = Density(1f, 1f)
val textStyle = TextStyle(
textAlign = TextAlign.Center,
color = Color.Black,
fontFamily = measureFontFamily,
fontSize = fontSize.sp
)
rule.setContent {
CompositionLocalProvider(LocalDensity provides density) {
BasicTextField(
modifier = Modifier.testTag(Tag),
value = "H",
onValueChange = { },
textStyle = textStyle,
singleLine = true
)
}
}
rule.waitForIdle()
rule.onNodeWithTag(Tag).captureToImage().assertCentered(fontSize)
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun textField_textAlignCenter_widthSmallerThanDefaultWidth() {
val fontSize = 50
val density = Density(1f, 1f)
val textStyle = TextStyle(
textAlign = TextAlign.Center,
color = Color.Black,
fontFamily = measureFontFamily,
fontSize = fontSize.sp
)
rule.setContent {
val fontFamilyResolver = LocalFontFamilyResolver.current
val defaultWidth = computeSizeForDefaultText(
style = textStyle,
density = density,
fontFamilyResolver = fontFamilyResolver,
maxLines = 1
).width
CompositionLocalProvider(LocalDensity provides density) {
BasicTextField(
modifier = Modifier
.testTag(Tag)
.width(defaultWidth.dp / 2),
value = "H",
onValueChange = { },
textStyle = textStyle,
singleLine = true
)
}
}
rule.waitForIdle()
rule.onNodeWithTag(Tag).captureToImage().assertCentered(fontSize)
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun textField_textAlignCenter_widthLargerThanDefaultWidth() {
val fontSize = 50
val density = Density(1f, 1f)
val textStyle = TextStyle(
textAlign = TextAlign.Center,
color = Color.Black,
fontFamily = measureFontFamily,
fontSize = fontSize.sp
)
rule.setContent {
val fontFamilyResolver = LocalFontFamilyResolver.current
val defaultWidth = computeSizeForDefaultText(
style = textStyle,
density = density,
fontFamilyResolver = fontFamilyResolver,
maxLines = 1
).width
CompositionLocalProvider(LocalDensity provides density) {
BasicTextField(
modifier = Modifier
.testTag(Tag)
.width(defaultWidth.dp * 2),
value = "H",
onValueChange = { },
textStyle = textStyle,
singleLine = true
)
}
}
rule.waitForIdle()
rule.onNodeWithTag(Tag).captureToImage().assertCentered(fontSize)
}
@OptIn(ExperimentalTestApi::class)
@Test
fun whenSelectedTextIsRemoved_SelectionCoerces() {
val textFieldValue = mutableStateOf("Hello")
rule.setContent {
BasicTextField(
value = textFieldValue.value,
onValueChange = {
textFieldValue.value = it
},
modifier = Modifier
.testTag(Tag)
.wrapContentSize()
)
}
val textNode = rule.onNodeWithTag(Tag)
textNode.performTextInputSelection(TextRange(0, 4))
textFieldValue.value = ""
rule.waitForIdle()
val expected = TextRange(0, 0)
val actual = textNode.fetchSemanticsNode().config
.getOrNull(SemanticsProperties.TextSelectionRange)
assertThat(actual).isEqualTo(expected)
}
@Ignore // b/284408746
@OptIn(ExperimentalTestApi::class)
@Test
fun whenPartiallySelectedTextIsRemoved_SelectionCoercesToEdges() {
val textFieldValue = mutableStateOf("Hello World!")
rule.setContent {
BasicTextField(
value = textFieldValue.value,
onValueChange = {
textFieldValue.value = it
},
modifier = Modifier
.testTag(Tag)
.wrapContentSize()
)
}
val textNode = rule.onNodeWithTag(Tag)
textNode.performTextInputSelection(TextRange(2, 8))
textFieldValue.value = "Hello"
rule.waitForIdle()
val expected = TextRange(2, 5)
val actual = textNode.fetchSemanticsNode().config
.getOrNull(SemanticsProperties.TextSelectionRange)
assertThat(actual).isEqualTo(expected)
}
@OptIn(ExperimentalTestApi::class)
@Test
fun whenSelectedTextIsRemoved_addedLater_SelectionDoesNotRemain() {
val textFieldValue = mutableStateOf("Hello")
rule.setContent {
BasicTextField(
value = textFieldValue.value,
onValueChange = {
textFieldValue.value = it
},
modifier = Modifier
.testTag(Tag)
.wrapContentSize()
)
}
val textNode = rule.onNodeWithTag(Tag)
textNode.performTextInputSelection(TextRange(0, 4))
rule.waitForIdle()
textFieldValue.value = ""
rule.waitForIdle()
textNode.assertTextEquals("")
textFieldValue.value = "Hello"
rule.waitForIdle()
val expected = TextRange.Zero
val actual = textNode.fetchSemanticsNode().config
.getOrNull(SemanticsProperties.TextSelectionRange)
assertThat(actual).isEqualTo(expected)
}
@OptIn(ExperimentalTestApi::class)
@FlakyTest(bugId = 300053741)
@Test
fun whenSelectedTextIsPartiallyRemoved_addedLater_SelectionRemainsPartially() {
val textFieldValue = mutableStateOf("Hello")
rule.setContent {
BasicTextField(
value = textFieldValue.value,
onValueChange = {
textFieldValue.value = it
},
modifier = Modifier
.testTag(Tag)
.wrapContentSize()
)
}
val textNode = rule.onNodeWithTag(Tag)
textNode.performTextInputSelection(TextRange(0, 4))
rule.waitForIdle()
textFieldValue.value = "He"
rule.waitForIdle()
textNode.assertTextEquals("He")
textFieldValue.value = "Hello"
rule.waitForIdle()
val expected = TextRange(0, 2)
val actual = textNode.fetchSemanticsNode().config
.getOrNull(SemanticsProperties.TextSelectionRange)
assertThat(actual).isEqualTo(expected)
}
@Test
fun whenSelectedTextIsRemovedByIME_SelectionDoesNotRevert() {
// hard to find a descriptive name. Take a look at
// `whenSelectedTextIsRemoved_addedLater_SelectionRemains` to understand this case better.
val textFieldValue = mutableStateOf("Hello")
rule.setContent {
BasicTextField(
value = textFieldValue.value,
onValueChange = {
textFieldValue.value = it
},
modifier = Modifier
.testTag(Tag)
.wrapContentSize()
)
}
val textNode = rule.onNodeWithTag(Tag)
textNode.performTextClearance()
rule.waitForIdle()
textNode.assertTextEquals("")
val selection = textNode.fetchSemanticsNode().config
.getOrNull(SemanticsProperties.TextSelectionRange)
assertThat(selection).isEqualTo(TextRange(0))
textFieldValue.value = "Hello"
rule.waitForIdle()
val actual = textNode.fetchSemanticsNode().config
.getOrNull(SemanticsProperties.TextSelectionRange)
assertThat(actual).isEqualTo(TextRange(0))
}
@Test
fun decorationBoxIntrinsics() {
var size: IntSize? = null
var dividerSize: IntSize? = null
rule.setContent {
Box(Modifier.onGloballyPositioned { size = it.size }) {
Row(Modifier.height(IntrinsicSize.Min)) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(10.dp)
.onGloballyPositioned { dividerSize = it.size }
.drawBehind { drawRect(Color.Gray) }
)
BasicTextField(
value = "",
onValueChange = {},
decorationBox = { content ->
Box(
modifier = Modifier
.border(1.dp, Color.Magenta)
.padding(4.dp),
propagateMinConstraints = true
) {
content()
}
}
)
}
}
}
rule.runOnIdle {
assertThat(dividerSize).isNotNull()
assertThat(size).isNotNull()
assertThat(dividerSize!!.height).isEqualTo(size!!.height)
}
}
@Test
fun decorationBox_togglingInnerTextField() {
var value by mutableStateOf("")
val decorationTag = "decorationTag"
rule.setContent {
Column {
BasicTextField(
value = value,
onValueChange = { value = it },
modifier = Modifier
.fillMaxWidth()
.testTag(Tag),
decorationBox = {
// the core text field is at the very bottom
if (value.isEmpty()) {
BasicText("test", modifier = Modifier.testTag(decorationTag))
} else {
it()
}
}
)
}
}
rule.onNodeWithTag(decorationTag, true).assertExists()
rule.onNode(hasSetTextAction()).performTextInput("hello")
rule.onNodeWithTag(decorationTag, true).assertDoesNotExist()
rule.onNode(hasSetTextAction()).performTextClearance()
rule.onNodeWithTag(decorationTag, true).assertExists()
rule.onNode(hasSetTextAction()).performTextInput("hello2")
rule.onNodeWithTag(decorationTag, true).assertDoesNotExist()
}
}
private fun SemanticsNodeInteraction.assertEditableTextEquals(
value: String
): SemanticsNodeInteraction =
assert(
SemanticsMatcher("${SemanticsProperties.EditableText.name} = '$value'") {
it.config.getOrNull(SemanticsProperties.EditableText)?.text.equals(value)
}
)
private fun ImageBitmap.assertCentered(excludedWidth: Int) {
val pixel = toPixelMap()
for (y in 0 until height) {
for (x in 0 until (width - excludedWidth) / 2) {
val leftPixel = pixel[x, y]
pixel.assertPixelColor(leftPixel, width - 1 - x, y)
}
}
}