blob: 2c921231255d27f5c626cfa23e7afbdca78ad83c [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.ui
import android.content.Context.ACCESSIBILITY_SERVICE
import android.graphics.Rect
import android.graphics.RectF
import android.os.Build
import android.os.Bundle
import android.view.InputDevice
import android.view.MotionEvent
import android.view.MotionEvent.ACTION_HOVER_ENTER
import android.view.MotionEvent.ACTION_HOVER_MOVE
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityEvent.TYPE_VIEW_CLICKED
import android.view.accessibility.AccessibilityEvent.TYPE_VIEW_HOVER_ENTER
import android.view.accessibility.AccessibilityEvent.TYPE_VIEW_HOVER_EXIT
import android.view.accessibility.AccessibilityEvent.TYPE_VIEW_SELECTED
import android.view.accessibility.AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
import android.view.accessibility.AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED
import android.view.accessibility.AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY
import android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED
import android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
import android.view.accessibility.AccessibilityManager
import android.view.accessibility.AccessibilityNodeInfo
import android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH
import android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX
import android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY
import android.view.accessibility.AccessibilityNodeProvider
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.progressSemantics
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.BottomAppBar
import androidx.compose.material.Button
import androidx.compose.material.DrawerValue
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.FabPosition
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Face
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.rememberDrawerState
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.AndroidComposeViewAccessibilityDelegateCompatTest.Companion.AccessibilityEventComparator
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.toComposeRect
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.AndroidComposeView
import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat
import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.ClassName
import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.InvalidId
import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.TextFieldClassName
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.Role
import androidx.compose.ui.semantics.SemanticsActions.SetText
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.SemanticsProperties.ContentDescription
import androidx.compose.ui.semantics.SemanticsProperties.EditableText
import androidx.compose.ui.semantics.SemanticsProperties.Focused
import androidx.compose.ui.semantics.SemanticsProperties.TextSelectionRange
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.semantics.invisibleToUser
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.semantics.textSelectionRange
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.test.SemanticsMatcher.Companion.expectValue
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.TestActivity
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertContentDescriptionEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertValueEquals
import androidx.compose.ui.test.getBoundsInRoot
import androidx.compose.ui.test.isEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.intl.LocaleList
import androidx.compose.ui.text.toUpperCase
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityEventCompat.CONTENT_CHANGE_TYPE_PANE_APPEARED
import androidx.core.view.accessibility.AccessibilityEventCompat.CONTENT_CHANGE_TYPE_PANE_DISAPPEARED
import androidx.core.view.accessibility.AccessibilityEventCompat.CONTENT_CHANGE_TYPE_PANE_TITLE
import androidx.core.view.accessibility.AccessibilityEventCompat.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION
import androidx.core.view.accessibility.AccessibilityEventCompat.CONTENT_CHANGE_TYPE_SUBTREE
import androidx.core.view.accessibility.AccessibilityEventCompat.CONTENT_CHANGE_TYPE_UNDEFINED
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_CLICK
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_FOCUS
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_LONG_CLICK
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_SET_SELECTION
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_SET_TEXT
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_IME_ENTER
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_FLOAT
import androidx.test.espresso.Espresso
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import java.lang.reflect.Method
import kotlin.math.max
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.hamcrest.CoreMatchers.instanceOf
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatcher
import org.mockito.ArgumentMatchers.any
import org.mockito.kotlin.argThat
import org.mockito.kotlin.atLeastOnce
import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.spy
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
@LargeTest
@RunWith(AndroidJUnit4::class)
class AndroidAccessibilityTest {
@get:Rule
val rule = createAndroidComposeRule<TestActivity>()
private val accessibilityEventLoopIntervalMs = 100L
private lateinit var androidComposeView: AndroidComposeView
private lateinit var container: OpenComposeView
private lateinit var delegate: AndroidComposeViewAccessibilityDelegateCompat
private lateinit var provider: AccessibilityNodeProvider
private val accessibilityManager: AccessibilityManager
get() = androidComposeView.context
.getSystemService(ACCESSIBILITY_SERVICE) as AccessibilityManager
private val tag = "Tag"
private val argument = ArgumentCaptor
.forClass(android.view.accessibility.AccessibilityEvent::class.java)
@Before
fun setup() {
// Use uiAutomation to enable accessibility manager.
InstrumentationRegistry.getInstrumentation().uiAutomation
rule.activityRule.scenario.onActivity { activity ->
container = spy(OpenComposeView(activity)) {
on { onRequestSendAccessibilityEvent(any(), any()) } doReturn false
}.apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
activity.setContentView(container)
androidComposeView = container.getChildAt(0) as AndroidComposeView
delegate = ViewCompat.getAccessibilityDelegate(androidComposeView) as
AndroidComposeViewAccessibilityDelegateCompat
delegate.accessibilityForceEnabledForTesting = true
provider = delegate.getAccessibilityNodeProvider(androidComposeView).provider
as AccessibilityNodeProvider
}
}
@Test
fun testCreateAccessibilityNodeInfo_forToggleable() {
// Arrange.
setContent {
var checked by remember { mutableStateOf(true) }
Box(
Modifier
.toggleable(value = checked, onValueChange = { checked = it })
.testTag(tag)
) {
BasicText("ToggleableText")
}
}
val virtualId = rule.onNodeWithTag(tag).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualId) }
// Assert.
rule.runOnIdle {
with(AccessibilityNodeInfoCompat.wrap(info)) {
assertThat(className).isEqualTo("android.view.View")
assertThat(isClickable).isTrue()
assertThat(isVisibleToUser).isTrue()
assertThat(isCheckable).isTrue()
assertThat(isChecked).isTrue()
assertThat(actionList)
.containsExactly(
AccessibilityActionCompat(ACTION_ACCESSIBILITY_FOCUS, "toggle"),
AccessibilityActionCompat(ACTION_FOCUS, "toggle"),
AccessibilityActionCompat(ACTION_CLICK, "toggle")
)
}
}
}
@Test
fun testCreateAccessibilityNodeInfo_forSwitch() {
// Arrange.
setContent {
var checked by remember { mutableStateOf(true) }
Box(
Modifier
.toggleable(
value = checked,
role = Role.Switch,
onValueChange = { checked = it }
)
.testTag(tag)
) {
BasicText("ToggleableText")
}
}
val toggleableNode = rule.onNodeWithTag(tag, true)
.fetchSemanticsNode("couldn't find node with tag $tag")
val switchRoleNode = toggleableNode.replacedChildren.last()
// Act.
rule.waitForIdle()
val info = createAccessibilityNodeInfo(toggleableNode.id)
val switchRoleInfo = createAccessibilityNodeInfo(switchRoleNode.id)
// Assert.
rule.runOnIdle {
with(AccessibilityNodeInfoCompat.wrap(info)) {
assertThat(stateDescription).isEqualTo("On")
assertThat(isClickable).isTrue()
assertThat(isVisibleToUser).isTrue()
assertThat(actionList)
.containsExactly(
AccessibilityActionCompat(ACTION_ACCESSIBILITY_FOCUS, null),
AccessibilityActionCompat(ACTION_FOCUS, null),
AccessibilityActionCompat(ACTION_CLICK, null)
)
}
// We temporary send Switch role as a separate fake node
with(AccessibilityNodeInfoCompat.wrap(switchRoleInfo)) {
assertThat(className).isEqualTo("android.view.View")
}
}
}
@Test
fun testCreateAccessibilityNodeInfo_forDropdown() {
// Arrange.
setContent {
var expanded by remember { mutableStateOf(false) }
IconButton(
modifier = Modifier
.semantics { role = Role.DropdownList }
.testTag(tag),
onClick = { expanded = true }
) {
Icon(Icons.Default.MoreVert, null)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
offset = DpOffset(24.dp, 0.dp),
) {
repeat(5) {
DropdownMenuItem(onClick = {}) { Text("Menu Item $it") }
}
}
}
val virtualId = rule.onNodeWithTag(tag, true).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualId) }
// Assert.
rule.runOnIdle {
with(AccessibilityNodeInfoCompat.wrap(info)) {
assertThat(className).isEqualTo("android.widget.Spinner")
assertThat(isClickable).isTrue()
assertThat(isVisibleToUser).isTrue()
assertThat(actionList)
.containsExactly(
AccessibilityActionCompat(ACTION_ACCESSIBILITY_FOCUS, null),
AccessibilityActionCompat(ACTION_FOCUS, null),
AccessibilityActionCompat(ACTION_CLICK, null)
)
}
}
}
@Test
fun testCreateAccessibilityNodeInfo_forSelectable() {
// Arrange.
setContent {
Box(
Modifier
.selectable(selected = true, onClick = {})
.testTag(tag)) {
BasicText("Text")
}
}
val virtualId = rule.onNodeWithTag(tag).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualId) }
// Assert.
rule.runOnIdle {
with(AccessibilityNodeInfoCompat.wrap(info)) {
assertThat(className).isEqualTo("android.view.View")
assertThat(stateDescription).isEqualTo("Selected")
assertThat(isClickable).isFalse()
assertThat(isCheckable).isTrue()
assertThat(isVisibleToUser).isTrue()
assertThat(actionList)
.containsExactly(
AccessibilityActionCompat(ACTION_ACCESSIBILITY_FOCUS, null),
AccessibilityActionCompat(ACTION_FOCUS, null),
)
}
}
}
@Test
fun testCreateAccessibilityNodeInfo_forTab() {
// Arrange.
setContent {
Box(
Modifier
.selectable(selected = true, onClick = {}, role = Role.Tab)
.testTag(tag)) {
BasicText("Text")
}
}
val virtualId = rule.onNodeWithTag(tag).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualId) }
// Assert.
rule.runOnIdle {
with(AccessibilityNodeInfoCompat.wrap(info)) {
assertThat(className).isEqualTo("android.view.View")
assertThat(stateDescription).isNull()
assertThat(isClickable).isFalse()
assertThat(isVisibleToUser).isTrue()
assertThat(isSelected).isTrue()
assertThat(actionList)
.containsExactly(
AccessibilityActionCompat(ACTION_ACCESSIBILITY_FOCUS, null),
AccessibilityActionCompat(ACTION_FOCUS, null),
)
}
}
}
@Test
fun testCreateAccessibilityNodeInfo_progressIndicator_determinate() {
// Arrange.
setContent {
Box(
Modifier
.progressSemantics(0.5f)
.testTag(tag)) {
BasicText("Text")
}
}
val virtualId = rule.onNodeWithTag(tag).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualId) }
// Assert.
rule.runOnIdle {
with(AccessibilityNodeInfoCompat.wrap(info)) {
assertThat(className).isEqualTo("android.widget.ProgressBar")
assertThat(stateDescription).isEqualTo("50 percent.")
assertThat(rangeInfo.type).isEqualTo(RANGE_TYPE_FLOAT)
assertThat(rangeInfo.current).isEqualTo(0.5f)
assertThat(rangeInfo.min).isEqualTo(0f)
assertThat(rangeInfo.max).isEqualTo(1f)
assertThat(actionList)
.containsExactly(AccessibilityActionCompat(ACTION_ACCESSIBILITY_FOCUS, null))
}
}
}
@Test
fun testCreateAccessibilityNodeInfo_progressIndicator_determinate_indeterminate() {
// Arrange.
setContent {
Box(
Modifier
.progressSemantics()
.testTag(tag)
) {
BasicText("Text")
}
}
val virtualId = rule.onNodeWithTag(tag).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualId) }
// Assert.
rule.runOnIdle {
with(AccessibilityNodeInfoCompat.wrap(info)) {
assertThat(className).isEqualTo("android.widget.ProgressBar")
assertThat(stateDescription).isEqualTo("In progress")
assertThat(rangeInfo).isNull()
assertThat(actionList)
.containsExactly(AccessibilityActionCompat(ACTION_ACCESSIBILITY_FOCUS, null))
}
}
}
@Test
fun testCreateAccessibilityNodeInfo_forTextField() {
// Arrange.
setContent {
var value by remember { mutableStateOf(TextFieldValue("hello")) }
BasicTextField(
modifier = Modifier.testTag(tag),
value = value,
onValueChange = { value = it }
)
}
val virtualId = rule.onNodeWithTag(tag).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualId) }
// Assert.
rule.runOnIdle {
with(AccessibilityNodeInfoCompat.wrap(info)) {
assertThat(className).isEqualTo("android.widget.EditText")
assertThat(text.toString()).isEqualTo("hello")
assertThat(isFocusable).isTrue()
assertThat(isFocused).isFalse()
assertThat(isEditable).isTrue()
assertThat(isVisibleToUser).isTrue()
assertThat(actionList)
.containsExactly(
AccessibilityActionCompat(ACTION_CLICK, null),
AccessibilityActionCompat(ACTION_LONG_CLICK, null),
AccessibilityActionCompat(ACTION_SET_TEXT, null),
AccessibilityActionCompat(ACTION_IME_ENTER.id, null),
AccessibilityActionCompat(ACTION_SET_SELECTION, null),
AccessibilityActionCompat(ACTION_NEXT_AT_MOVEMENT_GRANULARITY, null),
AccessibilityActionCompat(ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, null),
AccessibilityActionCompat(ACTION_FOCUS, null),
AccessibilityActionCompat(ACTION_ACCESSIBILITY_FOCUS, null)
)
if (Build.VERSION.SDK_INT >= 26) {
assertThat(availableExtraData)
.containsExactly(
"androidx.compose.ui.semantics.id",
// TODO(b/272068594): This looks like a bug. This should be
// AccessibilityNodeInfoCompat.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY
EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY,
"androidx.compose.ui.semantics.testTag"
)
}
}
}
}
@Test
fun testCreateAccessibilityNodeInfo_forText() {
// Arrange.
val text = "Test"
setContent {
BasicText(text = text)
}
val virtualId = rule.onNodeWithText(text).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualId) }
// Assert.
rule.runOnIdle {
with(AccessibilityNodeInfoCompat.wrap(info)) {
assertThat(className).isEqualTo("android.widget.TextView")
}
}
}
@Test
fun testCreateAccessibilityNodeInfo_forFocusable_notFocused() {
// Arrange.
setContent {
Box(
Modifier
.testTag(tag)
.focusable()) {
BasicText("focusable")
}
}
val virtualId = rule.onNodeWithTag(tag)
.assert(expectValue(Focused, false))
.semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualId) }
// Assert.
rule.runOnIdle {
with(AccessibilityNodeInfoCompat.wrap(info)) {
assertThat(actionList)
.containsExactly(
AccessibilityActionCompat(ACTION_FOCUS, null),
AccessibilityActionCompat(ACTION_ACCESSIBILITY_FOCUS, null)
)
@Suppress("DEPRECATION") recycle()
}
}
}
@Test
fun testCreateAccessibilityNodeInfo_forFocusable_focused() {
// Arrange.
val focusRequester = FocusRequester()
setContent {
Box(
Modifier
.testTag(tag)
.focusRequester(focusRequester)
.focusable()) {
BasicText("focusable")
}
}
rule.runOnIdle { focusRequester.requestFocus() }
val virtualId = rule.onNodeWithTag(tag)
.assert(expectValue(Focused, true))
.semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualId) }
// Assert.
rule.runOnIdle {
with(AccessibilityNodeInfoCompat.wrap(info)) {
assertThat(actionList)
.containsExactly(
AccessibilityActionCompat(ACTION_CLEAR_FOCUS, null),
AccessibilityActionCompat(ACTION_ACCESSIBILITY_FOCUS, null)
)
@Suppress("DEPRECATION") recycle()
}
}
}
@Composable
fun LastElementOverLaidColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
var yPosition = 0
Layout(modifier = modifier, content = content) { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
if (placeable != placeables[placeables.lastIndex]) {
placeable.placeRelative(x = 0, y = yPosition)
yPosition += placeable.height
} else {
// if the element is our last element (our overlaid node)
// then we'll put it over the middle of our previous elements
placeable.placeRelative(x = 0, y = yPosition / 2)
}
}
}
}
}
@Test
fun testSortedAccessibilityNodeInfo_forTraversalBefore_overlaidNodeLayout() {
// Arrange.
val overlaidText = "Overlaid node text"
val text1 = "Lorem1 ipsum dolor sit amet, consectetur adipiscing elit.\n"
val text2 = "Lorem2 ipsum dolor sit amet, consectetur adipiscing elit.\n"
val text3 = "Lorem3 ipsum dolor sit amet, consectetur adipiscing elit.\n"
setContent {
LastElementOverLaidColumn(modifier = Modifier.padding(8.dp)) {
Row {
Column {
Row { Text(text1) }
Row { Text(text2) }
Row { Text(text3) }
}
}
Row {
Text(overlaidText)
}
}
}
val node3VirtualId = rule.onNodeWithText(text3).semanticsId
val overlaidNodeVirtualId = rule.onNodeWithText(overlaidText).semanticsId
// Act.
val ani3 = rule.runOnIdle { createAccessibilityNodeInfo(node3VirtualId) }
// Assert.
// Nodes 1, 2, and 3 are all children of a larger column; this means with a hierarchy
// comparison (like SemanticsSort), the third text node should come before the overlaid node
// — OverlaidNode should be read last
rule.runOnIdle {
assertThat(ani3.extras.traversalBefore).isNotEqualTo(0)
assertThat(ani3.extras.traversalBefore).isEqualTo(overlaidNodeVirtualId)
}
}
@Test
fun testSortedAccessibilityNodeInfo_forTraversalAfter_overlaidNodeLayout() {
// Arrange.
val overlaidText = "Overlaid node text"
val text1 = "Lorem1 ipsum dolor sit amet, consectetur adipiscing elit.\n"
val text2 = "Lorem2 ipsum dolor sit amet, consectetur adipiscing elit.\n"
val text3 = "Lorem3 ipsum dolor sit amet, consectetur adipiscing elit.\n"
setContent {
LastElementOverLaidColumn(modifier = Modifier.padding(8.dp)) {
Row {
Column {
Row { Text(text1) }
Row { Text(text2) }
Row { Text(text3) }
}
}
Row {
Text(overlaidText)
}
}
}
val node3VirtualId = rule.onNodeWithText(text3).semanticsId
val overlaidNodeVirtualId = rule.onNodeWithText(overlaidText).semanticsId
// Act.
val ani3 = rule.runOnIdle { createAccessibilityNodeInfo(node3VirtualId) }
val overlaidANI = rule.runOnIdle { createAccessibilityNodeInfo(overlaidNodeVirtualId) }
// Assert.
rule.runOnIdle {
// Nodes 1, 2, and 3 are all children of a larger column; this means with a hierarchy
// comparison (like SemanticsSort), the third text node should come before the overlaid node
// — OverlaidNode should be read last
assertThat(ani3.extras.traversalBefore).isNotEqualTo(0)
assertThat(ani3.extras.traversalBefore).isEqualTo(overlaidNodeVirtualId)
// Older versions of Samsung voice assistant crash if both traversalBefore
// and traversalAfter redundantly express the same ordering relation, so
// we should only have traversalBefore here.
assertThat(overlaidANI.extras.traversalAfter).isEqualTo(0)
}
}
@Test
fun testSortedAccessibilityNodeInfo_readableTraversalGroups() {
// Arrange.
val clickableRowTag = "readableRow"
val clickableButtonTag = "readableButton"
setContent {
Column {
Row(
Modifier
.testTag(clickableRowTag)
.semantics { isTraversalGroup = true }
.clickable { }
) {
Icon(imageVector = Icons.Default.Add, contentDescription = "fab icon")
Button(onClick = { }, modifier = Modifier.testTag(clickableButtonTag)) {
Text("First button")
}
}
}
}
val rowVirtualId = rule.onNodeWithTag(clickableRowTag).semanticsId
val buttonId = rule.onNodeWithTag(clickableButtonTag).semanticsId
// Act.
val rowANI = rule.runOnIdle { createAccessibilityNodeInfo(rowVirtualId) }
// Assert - Since the column is screenReaderFocusable, it comes before the button.
rule.runOnIdle { assertThat(rowANI.extras.traversalBefore).isEqualTo(buttonId) }
}
@Composable
fun CardRow(
modifier: Modifier,
columnNumber: Int,
topSampleText: String,
bottomSampleText: String
) {
Row(
modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Column {
Text(topSampleText + columnNumber)
Text(bottomSampleText + columnNumber)
}
}
}
@Test
fun testSortedAccessibilityNodeInfo_peerTraversalGroups_traversalIndex() {
// Arrange.
val topSampleText = "Top text in column "
val bottomSampleText = "Bottom text in column "
setContent {
Column(
Modifier
.testTag("Test Tag")
.semantics { isTraversalGroup = false }
) {
Row { Modifier.semantics { isTraversalGroup = false }
CardRow(
// Setting a bigger traversalIndex here means that this CardRow will be
// read second, even though it is visually to the left of the other CardRow
Modifier
.semantics { isTraversalGroup = true }
.semantics { traversalIndex = 1f },
1,
topSampleText,
bottomSampleText)
CardRow(
Modifier.semantics { isTraversalGroup = true },
2,
topSampleText,
bottomSampleText)
}
}
}
val topText1 = rule.onNodeWithText(topSampleText + 1).semanticsId
val topText2 = rule.onNodeWithText(topSampleText + 2).semanticsId
val bottomText1 = rule.onNodeWithText(bottomSampleText + 1).semanticsId
val bottomText2 = rule.onNodeWithText(bottomSampleText + 2).semanticsId
// Act.
rule.waitForIdle()
val topText1ANI = createAccessibilityNodeInfo(topText1)
val topText2ANI = createAccessibilityNodeInfo(topText2)
val bottomText2ANI = createAccessibilityNodeInfo(bottomText2)
// Assert.
// Expected behavior: "Top text in column 2" -> "Bottom text in column 2" ->
// "Top text in column 1" -> "Bottom text in column 1"
rule.runOnIdle {
assertThat(topText2ANI.extras.traversalBefore).isAtMost(bottomText2)
assertThat(bottomText2ANI.extras.traversalBefore).isAtMost(topText1)
assertThat(topText1ANI.extras.traversalBefore).isAtMost(bottomText1)
}
}
@Test
fun testSortedAccessibilityNodeInfo_nestedTraversalGroups_outerFalse() {
// Arrange.
val topSampleText = "Top text in column "
val bottomSampleText = "Bottom text in column "
setContent {
Column(
Modifier
.testTag("Test Tag")
.semantics { isTraversalGroup = false }
) {
Row { Modifier.semantics { isTraversalGroup = false }
CardRow(
Modifier.semantics { isTraversalGroup = true },
1,
topSampleText,
bottomSampleText)
CardRow(
Modifier.semantics { isTraversalGroup = true },
2,
topSampleText,
bottomSampleText)
}
}
}
val topText1 = rule.onNodeWithText(topSampleText + 1).semanticsId
val topText2 = rule.onNodeWithText(topSampleText + 2).semanticsId
val bottomText1 = rule.onNodeWithText(bottomSampleText + 1).semanticsId
val bottomText2 = rule.onNodeWithText(bottomSampleText + 2).semanticsId
// Act.
rule.waitForIdle()
val topText1ANI = createAccessibilityNodeInfo(topText1)
val topText2ANI = createAccessibilityNodeInfo(topText2)
// Assert.
// Here we have the following hierarchy of containers:
// `isTraversalGroup = false`
// `isTraversalGroup = false`
// `isTraversalGroup = true`
// `isTraversalGroup = true`
// meaning the behavior should be as if the first two `isTraversalGroup = false` are not
// present and all of column 1 should be read before column 2.
rule.runOnIdle {
assertThat(topText1ANI.extras.traversalBefore).isEqualTo(bottomText1)
assertThat(topText2ANI.extras.traversalBefore).isEqualTo(bottomText2)
}
}
@Test
fun testSortedAccessibilityNodeInfo_nestedTraversalGroups_outerTrue() {
// Arrange.
val topSampleText = "Top text in column "
val bottomSampleText = "Bottom text in column "
setContent {
Column(
Modifier
.testTag("Test Tag")
.semantics { isTraversalGroup = true }
) {
Row { Modifier.semantics { isTraversalGroup = true }
CardRow(
Modifier
.testTag("Row 1")
.semantics { isTraversalGroup = false },
1,
topSampleText,
bottomSampleText)
CardRow(
Modifier
.testTag("Row 2")
.semantics { isTraversalGroup = false },
2,
topSampleText,
bottomSampleText)
}
}
}
val bottomText1 = rule.onNodeWithText(bottomSampleText + 1).semanticsId
val bottomText2 = rule.onNodeWithText(bottomSampleText + 2).semanticsId
// Act.
val bottomText1ANI = rule.runOnIdle { createAccessibilityNodeInfo(bottomText1) }
// Assert.
// Here we have the following hierarchy of traversal groups:
// `isTraversalGroup = true`
// `isTraversalGroup = true`
// `isTraversalGroup = false`
// `isTraversalGroup = false`
// In this case, we expect all the top text to be read first, then all the bottom text
rule.runOnIdle { assertThat(bottomText1ANI.extras.traversalBefore).isEqualTo(bottomText2) }
}
@Test
fun testSortedAccessibilityNodeInfo_tripleNestedTraversalGroups() {
// Arrange.
val topSampleText = "Top "
val bottomSampleText = "Bottom "
setContent {
Row {
CardRow(
Modifier.semantics { isTraversalGroup = false },
1,
topSampleText,
bottomSampleText)
CardRow(
Modifier.semantics { isTraversalGroup = false },
2,
topSampleText,
bottomSampleText)
CardRow(
Modifier.semantics { isTraversalGroup = true },
3,
topSampleText,
bottomSampleText)
}
}
val bottomText1 = rule.onNodeWithText(bottomSampleText + 1).semanticsId
val bottomText2 = rule.onNodeWithText(bottomSampleText + 2).semanticsId
val bottomText3 = rule.onNodeWithText(bottomSampleText + 3).semanticsId
val topText3 = rule.onNodeWithText(topSampleText + 3).semanticsId
// Act.
rule.waitForIdle()
val bottomText1ANI = createAccessibilityNodeInfo(bottomText1)
val topText3ANI = createAccessibilityNodeInfo(topText3)
// Assert.
// Here we have the following hierarchy of traversal groups:
// `isTraversalGroup = false`
// `isTraversalGroup = false`
// `isTraversalGroup = true`
// In this case, we expect to read in the order of: Top 1, Top 2, Bottom 1, Bottom 2,
// then Top 3, Bottom 3. The first two traversal groups are effectively merged since they are both
// set to false, while the third traversal group is structurally significant.
rule.runOnIdle {
assertThat(bottomText1ANI.extras.traversalBefore).isEqualTo(bottomText2)
assertThat(topText3ANI.extras.traversalBefore).isEqualTo(bottomText3)
}
}
@Test
fun testSortedAccessibilityNodeInfo_nestedTraversalGroups_hierarchy() {
// Arrange.
val topSampleText = "Top text in column "
val bottomSampleText = "Bottom text in column "
setContent {
Row {
CardRow(
Modifier
// adding a vertical scroll here makes the column scrollable, which would
// normally make it structurally significant
.verticalScroll(rememberScrollState())
// but adding in `traversalGroup = false` should negate that
.semantics { isTraversalGroup = false },
1,
topSampleText,
bottomSampleText
)
CardRow(
Modifier
// adding a vertical scroll here makes the column scrollable, which would
// normally make it structurally significant
.verticalScroll(rememberScrollState())
// but adding in `isTraversalGroup = false` should negate that
.semantics { isTraversalGroup = false },
2,
topSampleText,
bottomSampleText
)
}
}
val bottomText1 = rule.onNodeWithText(bottomSampleText + 1).semanticsId
val bottomText2 = rule.onNodeWithText(bottomSampleText + 2).semanticsId
// Act.
val bottomText1ANI = rule.runOnIdle { createAccessibilityNodeInfo(bottomText1) }
// Assert.
// In this case, we expect all the top text to be read first, then all the bottom text
rule.runOnIdle { assertThat(bottomText1ANI.extras.traversalBefore).isAtMost(bottomText2) }
}
@Test
fun testSortedAccessibilityNodeInfo_traversalIndex() {
// Arrange.
val overlaidText = "Overlaid node text"
val text1 = "Text 1\n"
val text2 = "Text 2\n"
val text3 = "Text 3\n"
setContent {
LastElementOverLaidColumn(
// None of the elements below should inherit `traversalIndex = 5f`
modifier = Modifier
.padding(8.dp)
.semantics { traversalIndex = 5f }
) {
Row {
Column {
Row { Text(text1) }
Row { Text(text2) }
Row { Text(text3) }
}
}
// Since default traversalIndex is 0, `traversalIndex = -1f` here means that the
// overlaid node is read first, even though visually it's below the other text.
Row {
Text(
text = overlaidText,
modifier = Modifier.semantics { traversalIndex = -1f }
)
}
}
}
val node1 = rule.onNodeWithText(text1).semanticsId
val overlaidNode = rule.onNodeWithText(overlaidText).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(overlaidNode) }
// Assert.
// Because the overlaid node has a smaller traversal index, it should be read before node 1
rule.runOnIdle { assertThat(info.extras.traversalBefore).isAtMost(node1) }
}
@Test
fun testSortedAccessibilityNodeInfo_nestedAndPeerTraversalIndex() {
// Arrange.
val text0 = "Text 0\n"
val text1 = "Text 1\n"
val text2 = "Text 2\n"
val text3 = "Text 3\n"
val text4 = "Text 4\n"
val text5 = "Text 5\n"
setContent {
Column(
Modifier
// Having a traversal index here as 8f shouldn't affect anything; this column
// has no other peers that its compared to
.semantics { traversalIndex = 8f; isTraversalGroup = true }
.padding(8.dp)
) {
Row(
Modifier.semantics { traversalIndex = 3f; isTraversalGroup = true }
) {
Column(modifier = Modifier.testTag("Tag1")) {
Row { Text(text3) }
Row { Text(
text = text5, modifier = Modifier.semantics { traversalIndex = 1f })
}
Row { Text(text4) }
}
}
Row {
Text(text = text2, modifier = Modifier.semantics { traversalIndex = 2f })
}
Row {
Text(text = text1, modifier = Modifier.semantics { traversalIndex = 1f })
}
Row {
Text(text = text0)
}
}
}
val virtualViewId0 = rule.onNodeWithText(text0).semanticsId
val virtualViewId1 = rule.onNodeWithText(text1).semanticsId
val virtualViewId2 = rule.onNodeWithText(text2).semanticsId
val virtualViewId3 = rule.onNodeWithText(text3).semanticsId
val virtualViewId4 = rule.onNodeWithText(text4).semanticsId
val virtualViewId5 = rule.onNodeWithText(text5).semanticsId
// Act.
rule.waitForIdle()
val ani0 = createAccessibilityNodeInfo(virtualViewId0)
val ani1 = createAccessibilityNodeInfo(virtualViewId1)
val ani2 = createAccessibilityNodeInfo(virtualViewId2)
val ani3 = createAccessibilityNodeInfo(virtualViewId3)
val ani4 = createAccessibilityNodeInfo(virtualViewId4)
// Assert - We want to read the texts in order: 0 -> 1 -> 2 -> 3 -> 4 -> 5
rule.runOnIdle {
assertThat(ani0.extras.traversalBefore).isAtMost(virtualViewId1)
assertThat(ani1.extras.traversalBefore).isAtMost(virtualViewId2)
assertThat(ani2.extras.traversalBefore).isAtMost(virtualViewId3)
assertThat(ani3.extras.traversalBefore).isAtMost(virtualViewId4)
assertThat(ani4.extras.traversalBefore).isAtMost(virtualViewId5)
}
}
@Test
fun testSortedAccessibilityNodeInfo_traversalIndexInherited_indexFirst() {
// Arrange.
val overlaidText = "Overlaid node text"
val text1 = "Text 1\n"
val text2 = "Text 2\n"
val text3 = "Text 3\n"
setContent {
LastElementOverLaidColumn(
modifier = Modifier
.semantics { traversalIndex = -1f }
.semantics { isTraversalGroup = true }
) {
Row {
Column {
Row { Text(text1) }
Row { Text(text2) }
Row { Text(text3) }
}
}
Row {
Text(
text = overlaidText,
modifier = Modifier
.semantics { traversalIndex = 1f }
.semantics { isTraversalGroup = true }
)
}
}
}
val node3Id = rule.onNodeWithText(text3).semanticsId
val overlayId = rule.onNodeWithText(overlaidText).semanticsId
// Act.
val node3ANI = rule.runOnIdle { createAccessibilityNodeInfo(node3Id) }
// Assert - Nodes 1 through 3 are read, and then overlaid node is read last
rule.runOnIdle { assertThat(node3ANI.extras.traversalBefore).isAtMost(overlayId) }
}
@Test
fun testSortedAccessibilityNodeInfo_traversalIndexInherited_indexSecond() {
// Arrange.
val overlaidText = "Overlaid node text"
val text1 = "Text 1\n"
val text2 = "Text 2\n"
val text3 = "Text 3\n"
// This test is identical to the one above, except with `isTraversalGroup` coming first in
// the modifier chain. Behavior-wise, this shouldn't change anything.
setContent {
LastElementOverLaidColumn(
modifier = Modifier
.semantics { isTraversalGroup = true }
.semantics { traversalIndex = -1f }
) {
Row {
Column {
Row { Text(text1) }
Row { Text(text2) }
Row { Text(text3) }
}
}
Row {
Text(
text = overlaidText,
modifier = Modifier
.semantics { isTraversalGroup = true }
.semantics { traversalIndex = 1f }
)
}
}
}
val node3Id = rule.onNodeWithText(text3).semanticsId
val overlayId = rule.onNodeWithText(overlaidText).semanticsId
// Act.
val node3ANI = rule.runOnIdle { createAccessibilityNodeInfo(node3Id) }
// Assert - Nodes 1 through 3 are read, and then overlaid node is read last
rule.runOnIdle { assertThat(node3ANI.extras.traversalBefore).isAtMost(overlayId) }
}
@Test
fun testSortedAccessibilityNodeInfo_SimpleTopAppBar() {
// Arrange.
val topAppBarText = "Top App Bar"
val textBoxTag = "Text Box"
setContent {
Box(Modifier.testTag(textBoxTag)) {
Text(text = "Lorem ipsum ".repeat(200))
}
TopAppBar(
title = {
Text(text = topAppBarText)
}
)
}
val textBoxId = rule.onNodeWithTag(textBoxTag).semanticsId
val topAppBarId = rule.onNodeWithText(topAppBarText).semanticsId
// Act.
val topAppBarANI = rule.runOnIdle { createAccessibilityNodeInfo(topAppBarId) }
// Assert.
rule.runOnIdle { assertThat(topAppBarANI.extras.traversalBefore).isLessThan(textBoxId) }
}
@Test
fun testSortedAccessibilityNodeInfo_SimpleScrollingTopAppBar() {
// Arrange.
val topAppBarText = "Top App Bar"
val sampleText = "Sample text "
val sampleText1 = "Sample text 1"
val sampleText2 = "Sample text 2"
var counter = 1
setContent {
Column(
Modifier
.verticalScroll(rememberScrollState())
) {
TopAppBar(title = { Text(text = topAppBarText) })
repeat(100) {
Text(sampleText + counter++)
}
}
}
val topAppBarId = rule.onNodeWithText(topAppBarText).semanticsId
val node1Id = rule.onNodeWithText(sampleText1).semanticsId
val node2Id = rule.onNodeWithText(sampleText2).semanticsId
// Act.
rule.waitForIdle()
val topAppBarANI = createAccessibilityNodeInfo(topAppBarId)
val ani1 = createAccessibilityNodeInfo(node1Id)
// Assert that the top bar comes before the first node (node 1) and that the first node
// comes before the second (node 2)
rule.runOnIdle {
assertThat(topAppBarANI.extras.traversalBefore).isEqualTo(node1Id)
assertThat(ani1.extras.traversalBefore).isEqualTo(node2Id)
}
}
@Test
fun testSortedAccessibilityNodeInfo_ScaffoldTopBar() {
// Arrange.
val topAppBarText = "Top App Bar"
val contentText = "Content"
val bottomAppBarText = "Bottom App Bar"
setContent {
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
Scaffold(
scaffoldState = scaffoldState,
topBar = { TopAppBar(title = { Text(topAppBarText) }) },
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { FloatingActionButton(onClick = {}) {
Icon(imageVector = Icons.Default.Add, contentDescription = "fab icon")
} },
drawerContent = { Text(text = "Drawer Menu 1") },
content = { padding -> Text(contentText, modifier = Modifier.padding(padding)) },
bottomBar = { BottomAppBar {
Text(bottomAppBarText) } }
)
}
val topAppBarId = rule.onNodeWithText(topAppBarText).semanticsId
val contentId = rule.onNodeWithText(contentText).semanticsId
val bottomAppBarId = rule.onNodeWithText(bottomAppBarText).semanticsId
// Act.
rule.waitForIdle()
val topAppBarANI = createAccessibilityNodeInfo(topAppBarId)
val contentANI = createAccessibilityNodeInfo(contentId)
// Assert.
rule.runOnIdle {
assertThat(topAppBarANI.extras.traversalBefore).isEqualTo(contentId)
assertThat(contentANI.extras.traversalBefore).isLessThan(bottomAppBarId)
}
}
@Test
fun testSortedAccessibilityNodeInfo_clearSemantics() {
// Arrange.
val content1 = "Face 1"
val content2 = "Face 2"
val content3 = "Face 3"
val contentText = "Content"
setContent {
Scaffold(
topBar = {
Row(
horizontalArrangement = Arrangement.SpaceEvenly
) {
IconButton(onClick = { }) {
Icon(Icons.Default.Face, contentDescription = content1)
}
IconButton(
onClick = { },
modifier = Modifier.clearAndSetSemantics { }
) {
Icon(Icons.Default.Face, contentDescription = content2)
}
IconButton(onClick = { }) {
Icon(Icons.Default.Face, contentDescription = content3)
}
}
},
content = { padding -> Text(contentText, modifier = Modifier.padding(padding)) }
)
}
val face1Id = rule.onNodeWithContentDescription(content1).semanticsId
val face3Id = rule.onNodeWithContentDescription(content3).semanticsId
val contentId = rule.onNodeWithText(contentText).semanticsId
// Act.
rule.waitForIdle()
val ani1 = createAccessibilityNodeInfo(face1Id)
val ani3 = createAccessibilityNodeInfo(face3Id)
// Assert.
// On screen we have three faces in a top app bar, and then a content node:
//
// Face1 Face2 Face3
// Content
//
// Since `clearAndSetSemantics` is set on Face2, it should not generate any semantics node.
rule.onNodeWithTag(content2).assertDoesNotExist()
// The traversal order for the elements on screen should then be Face1 -> Face3 -> content.
rule.runOnIdle {
assertThat(ani1.extras.traversalBefore).isEqualTo(face3Id)
assertThat(ani3.extras.traversalBefore).isEqualTo(contentId)
}
}
@Test
fun testSortedAccessibilityNodeInfo_zOcclusion() {
// Arrange.
val parentBox1Tag = "ParentForOverlappedChildren"
val childOneTag = "OverlappedChildOne"
val childTwoTag = "OverlappedChildTwo"
val childThreeTag = "ChildThree"
setContent {
Column {
Box(Modifier.testTag(parentBox1Tag)) {
with(LocalDensity.current) {
BasicText(
"Child One",
Modifier
// A child with larger [zIndex] will be drawn on top of all the
// children with smaller [zIndex]. So child 1 covers child 2.
.zIndex(1f)
.testTag(childOneTag)
.requiredSize(50.toDp())
)
BasicText(
"Child Two",
Modifier
.testTag(childTwoTag)
.requiredSize(50.toDp())
)
}
}
Box {
BasicText(
"Child Three",
Modifier
.testTag(childThreeTag)
)
}
}
}
val parentBox1Id = rule.onNodeWithTag(parentBox1Tag).semanticsId
val childOneId = rule.onNodeWithTag(childOneTag, useUnmergedTree = true).semanticsId
val childTwoId = rule.onNodeWithTag(childTwoTag, useUnmergedTree = true).semanticsId
val childThreeId = rule.onNodeWithTag(childThreeTag, useUnmergedTree = true).semanticsId
// Act.
rule.waitForIdle()
val parentANI = createAccessibilityNodeInfo(parentBox1Id)
val ani1 = createAccessibilityNodeInfo(childOneId)
val ani2 = provider.createAccessibilityNodeInfo(childTwoId)
// Assert.
rule.runOnIdle {
// Since child 2 is completely covered, it should not generate any ANI. The first box
// parent should only have one child (child 1).
assertThat(parentANI.childCount).isEqualTo(1)
assertThat(ani2).isNull()
// The traversal order for the elements on screen should then be child 1 -> child 3,
// completely skipping over child 2.
assertThat(ani1.extras.traversalBefore).isEqualTo(childThreeId)
}
}
@Composable
fun ScrollColumn(
padding: PaddingValues,
firstElement: String,
lastElement: String
) {
var counter = 0
val sampleText = "Sample text in column"
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(padding)
) {
Text(text = firstElement, modifier = Modifier.testTag(firstElement))
repeat(100) {
Text(sampleText + counter++)
}
Text(text = lastElement, modifier = Modifier.testTag(lastElement))
}
}
@Composable
fun ScrollColumnNoPadding(
firstElement: String,
lastElement: String
) {
var counter = 0
val sampleText = "Sample text in column"
Column(
Modifier
.verticalScroll(rememberScrollState())
) {
Text(text = firstElement, modifier = Modifier.testTag(firstElement))
repeat(30) {
Text(sampleText + counter++)
}
Text(text = lastElement, modifier = Modifier.testTag(lastElement))
}
}
@Test
fun testSortedAccessibilityNodeInfo_ScaffoldScrollingTopBar() {
// Arrange.
val topAppBarText = "Top App Bar"
val firstContentText = "First content text"
val lastContentText = "Last content text"
val bottomAppBarText = "Bottom App Bar"
setContent {
ScaffoldedSubcomposeLayout(
modifier = Modifier,
topBar = {
TopAppBar(
title = { Text(text = topAppBarText) }
)
},
content = { ScrollColumnNoPadding(firstContentText, lastContentText) },
bottomBar = {
BottomAppBar {
Text(bottomAppBarText)
}
}
)
}
val topAppBarId = rule.onNodeWithText(topAppBarText).semanticsId
val firstContentId = rule.onNodeWithTag(firstContentText).semanticsId
val lastContentId = rule.onNodeWithTag(lastContentText).semanticsId
// Act.
rule.waitForIdle()
val topAppBarANI = createAccessibilityNodeInfo(topAppBarId)
val firstContentANI = createAccessibilityNodeInfo(firstContentId)
// Assert.
rule.runOnIdle {
// First content comes right after the top bar, so the `before` value equals the first
// content node id.
assertThat(topAppBarANI.extras.traversalBefore).isNotEqualTo(0)
assertThat(topAppBarANI.extras.traversalBefore).isEqualTo(firstContentId)
// The scrolling content comes in between the first text element and the last, so
// check that the traversal value is not 0, then assert the first text comes before the
// last text.
assertThat(firstContentANI.extras.traversalBefore).isNotEqualTo(0)
assertThat(firstContentANI.extras.traversalBefore).isLessThan(lastContentId)
}
}
@Test
fun testSortedAccessibilityNodeInfo_vertical_zIndex() {
// Arrange.
val rootTag = "root"
val childTag1 = "child1"
val childTag2 = "child2"
setContent {
Column(Modifier.testTag(rootTag)) {
SimpleTestLayout(
Modifier
.requiredSize(50.dp)
.zIndex(1f)
.testTag(childTag1)
) {}
SimpleTestLayout(
Modifier
.requiredSize(50.dp)
.testTag(childTag2)
) {}
}
}
val root = rule.onNodeWithTag(rootTag).fetchSemanticsNode()
val child1Id = rule.onNodeWithTag(childTag1).semanticsId
val child2Id = rule.onNodeWithTag(childTag2).semanticsId
// Act.
rule.waitForIdle()
val child1ANI = createAccessibilityNodeInfo(child1Id)
val child2ANI = createAccessibilityNodeInfo(child2Id)
// Assert - We want child1 to come before child2
rule.runOnIdle {
assertThat(root.replacedChildren.size).isEqualTo(2)
assertThat(child1ANI.extras.traversalBefore).isLessThan(child2Id)
assertThat(child2ANI.extras.traversalAfter).isLessThan(child1Id)
}
}
@Test
fun testSortedAccessibilityNodeInfo_horizontal_zIndex() {
// Arrange.
val rootTag = "root"
val childTag1 = "child1"
val childTag2 = "child2"
setContent {
Row(
Modifier.testTag(rootTag)
) {
SimpleTestLayout(
Modifier
.requiredSize(50.dp)
.zIndex(1f)
.testTag(childTag1)
) {}
SimpleTestLayout(
Modifier
.requiredSize(50.dp)
.testTag(childTag2)
) {}
}
}
val root = rule.onNodeWithTag(rootTag).fetchSemanticsNode()
val child1Id = rule.onNodeWithTag(childTag1).semanticsId
val child2Id = rule.onNodeWithTag(childTag2).semanticsId
// Act.
rule.waitForIdle()
val child1ANI = createAccessibilityNodeInfo(child1Id)
val child2ANI = createAccessibilityNodeInfo(child2Id)
// Assert - We want child1 to come before child2
rule.runOnIdle {
assertThat(root.replacedChildren.size).isEqualTo(2)
assertThat(child1ANI.extras.traversalBefore).isLessThan(child2Id)
assertThat(child2ANI.extras.traversalAfter).isLessThan(child1Id)
}
}
@Test
fun testSortedAccessibilityNodeInfo_vertical_offset() {
// Arrange.
val rootTag = "root"
val childTag1 = "child1"
val childTag2 = "child2"
setContent {
Box(
Modifier.testTag(rootTag)
) {
SimpleTestLayout(
Modifier
.requiredSize(50.dp)
.offset(x = 0.dp, y = 50.dp)
.testTag(childTag1)
) {}
SimpleTestLayout(
Modifier
.requiredSize(50.dp)
.testTag(childTag2)
) {}
}
}
val root = rule.onNodeWithTag(rootTag).fetchSemanticsNode()
val child1Id = rule.onNodeWithTag(childTag1).semanticsId
val child2Id = rule.onNodeWithTag(childTag2).semanticsId
// Act.
rule.waitForIdle()
val child1ANI = createAccessibilityNodeInfo(child1Id)
val child2ANI = createAccessibilityNodeInfo(child2Id)
// Assert - We want child2 to come before child1
rule.runOnIdle {
assertThat(root.replacedChildren.size).isEqualTo(2)
assertThat(child2ANI.extras.traversalBefore).isLessThan(child1Id)
assertThat(child1ANI.extras.traversalAfter).isLessThan(child2Id)
}
}
@Test
fun testSortedAccessibilityNodeInfo_horizontal_offset() {
// Arrange.
val rootTag = "root"
val childTag1 = "child1"
val childTag2 = "child2"
setContent {
Box(
Modifier.testTag(rootTag)
) {
SimpleTestLayout(
Modifier
.requiredSize(50.dp)
.offset(x = 50.dp, y = 0.dp)
.testTag(childTag1)
) {}
SimpleTestLayout(
Modifier
.requiredSize(50.dp)
.testTag(childTag2)
) {}
}
}
val root = rule.onNodeWithTag(rootTag).fetchSemanticsNode()
val child1Id = rule.onNodeWithTag(childTag1).semanticsId
val child2Id = rule.onNodeWithTag(childTag2).semanticsId
// Act.
rule.waitForIdle()
val child1ANI = createAccessibilityNodeInfo(child1Id)
val child2ANI = createAccessibilityNodeInfo(child2Id)
// Assert - We want child2 to come before child1
rule.runOnIdle {
assertThat(root.replacedChildren.size).isEqualTo(2)
assertThat(child2ANI.extras.traversalBefore).isLessThan(child1Id)
assertThat(child1ANI.extras.traversalAfter).isLessThan(child2Id)
}
}
@Test
fun testSortedAccessibilityNodeInfo_vertical_offset_overlapped() {
// Arrange.
val rootTag = "root"
val childTag1 = "child1"
val childTag2 = "child2"
setContent {
Box(
Modifier.testTag(rootTag)
) {
SimpleTestLayout(
Modifier
.requiredSize(50.dp)
.offset(x = 0.dp, y = 20.dp)
.testTag(childTag1)
) {}
SimpleTestLayout(
Modifier
.requiredSize(50.dp)
.testTag(childTag2)
) {}
}
}
val root = rule.onNodeWithTag(rootTag).fetchSemanticsNode()
val child1 = rule.onNodeWithTag(childTag1).semanticsId
val child2 = rule.onNodeWithTag(childTag2).semanticsId
// Act.
rule.waitForIdle()
val child1ANI = createAccessibilityNodeInfo(child1)
val child2ANI = createAccessibilityNodeInfo(child2)
// Assert - We want child2 to come before child1
rule.runOnIdle {
assertThat(root.replacedChildren.size).isEqualTo(2)
assertThat(child2ANI.extras.traversalBefore).isLessThan(child1)
assertThat(child1ANI.extras.traversalAfter).isLessThan(child2)
}
}
@Test
fun testSortedAccessibilityNodeInfo_horizontal_offset_overlapped() {
// Arrange.
val rootTag = "root"
val childTag1 = "child1"
val childTag2 = "child2"
setContent {
Box(
Modifier.testTag(rootTag)
) {
// Layouts need to have `.clickable` on them in order to make the nodes
// speakable and therefore sortable
SimpleTestLayout(
Modifier
.requiredSize(50.dp)
.offset(x = 20.dp, y = 0.dp)
.testTag(childTag1)
.clickable(onClick = {})
) {}
SimpleTestLayout(
Modifier
.requiredSize(50.dp)
.offset(x = 0.dp, y = 20.dp)
.testTag(childTag2)
.clickable(onClick = {})
) {}
}
}
val root = rule.onNodeWithTag(rootTag).fetchSemanticsNode()
val child1Id = rule.onNodeWithTag(childTag1).semanticsId
val child2Id = rule.onNodeWithTag(childTag2).semanticsId
// Act.
val child2ANI = rule.runOnIdle { createAccessibilityNodeInfo(child2Id) }
// Assert - We want child2 to come before child1
rule.runOnIdle {
assertThat(root.replacedChildren.size).isEqualTo(2)
assertThat(child2ANI.extras.traversalBefore).isEqualTo(child1Id)
}
}
@Test
fun testSortedAccessibilityNodeInfo_vertical_subcompose() {
// Arrange.
val rootTag = "root"
val childTag1 = "child1"
val childTag2 = "child2"
val density = Density(1f)
val size = with(density) { 100.dp.roundToPx() }.toFloat()
setContent {
CompositionLocalProvider(LocalDensity provides density) {
SimpleSubcomposeLayout(
Modifier.testTag(rootTag),
{
SimpleTestLayout(
Modifier
.requiredSize(100.dp)
.testTag(childTag1)
) {}
},
Offset(0f, size),
{
SimpleTestLayout(
Modifier
.requiredSize(100.dp)
.testTag(childTag2)
) {}
},
Offset(0f, 0f)
)
}
}
val root = rule.onNodeWithTag(rootTag).fetchSemanticsNode()
val child1Id = rule.onNodeWithTag(childTag1).semanticsId
val child2Id = rule.onNodeWithTag(childTag2).semanticsId
// Act.
rule.waitForIdle()
val child1ANI = createAccessibilityNodeInfo(child1Id)
val child2ANI = createAccessibilityNodeInfo(child2Id)
// Assert - We want child2 to come before child1
rule.runOnIdle {
assertThat(root.replacedChildren.size).isEqualTo(2)
assertThat(child2ANI.extras.traversalBefore).isLessThan(child1Id)
assertThat(child1ANI.extras.traversalAfter).isLessThan(child2Id)
}
}
@Test
fun testSortedAccessibilityNodeInfo_horizontal_subcompose() {
// Arrange.
val rootTag = "root"
val childTag1 = "child1"
val childTag2 = "child2"
val density = Density(1f)
val size = with(density) { 100.dp.roundToPx() }.toFloat()
setContent {
CompositionLocalProvider(LocalDensity provides density) {
SimpleSubcomposeLayout(
Modifier.testTag(rootTag),
{
SimpleTestLayout(
Modifier
.requiredSize(100.dp)
.testTag(childTag1)
) {}
},
Offset(size, 0f),
{
SimpleTestLayout(
Modifier
.requiredSize(100.dp)
.testTag(childTag2)
) {}
},
Offset(0f, 0f)
)
}
}
val root = rule.onNodeWithTag(rootTag).fetchSemanticsNode()
val child1Id = rule.onNodeWithTag(childTag1).semanticsId
val child2Id = rule.onNodeWithTag(childTag2).semanticsId
// Act.
rule.waitForIdle()
val child1ANI = createAccessibilityNodeInfo(child1Id)
val child2ANI = createAccessibilityNodeInfo(child2Id)
// Assert - We want child2 to come before child1
rule.runOnIdle {
assertThat(root.replacedChildren.size).isEqualTo(2)
assertThat(child2ANI.extras.traversalBefore).isLessThan(child1Id)
assertThat(child1ANI.extras.traversalAfter).isLessThan(child2Id)
}
}
@Test
fun testChildrenSortedByBounds_rtl() {
// Arrange.
val rootTag = "root"
val childTag1 = "child1"
val childTag2 = "child2"
val childTag3 = "child3"
val rtlChildTag1 = "rtlChild1"
val rtlChildTag2 = "rtlChild2"
val rtlChildTag3 = "rtlChild3"
setContent {
Column(Modifier.testTag(rootTag)) {
Row {
SimpleTestLayout(
Modifier
.requiredSize(100.dp)
.testTag(childTag1)
) {}
SimpleTestLayout(
Modifier
.requiredSize(100.dp)
.testTag(childTag2)
) {}
SimpleTestLayout(
Modifier
.requiredSize(100.dp)
.testTag(childTag3)
) {}
}
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
// Will display rtlChild3 rtlChild2 rtlChild1
Row {
SimpleTestLayout(
Modifier
.requiredSize(100.dp)
.testTag(rtlChildTag1)
) {}
SimpleTestLayout(
Modifier
.requiredSize(100.dp)
.testTag(rtlChildTag2)
) {}
SimpleTestLayout(
Modifier
.requiredSize(100.dp)
.testTag(rtlChildTag3)
) {}
}
}
}
}
val root = rule.onNodeWithTag(rootTag).fetchSemanticsNode()
val rtlChild1Id = rule.onNodeWithTag(rtlChildTag1).semanticsId
val rtlChild2Id = rule.onNodeWithTag(rtlChildTag2).semanticsId
val rtlChild3Id = rule.onNodeWithTag(rtlChildTag3).semanticsId
// Act.
rule.waitForIdle()
val rtlChild1ANI = createAccessibilityNodeInfo(rtlChild1Id)
val rtlChild2ANI = createAccessibilityNodeInfo(rtlChild2Id)
// Assert - Rtl
rule.runOnIdle {
assertThat(root.replacedChildren.size).isEqualTo(6)
assertThat(rtlChild1ANI.extras.traversalBefore).isLessThan(rtlChild2Id)
assertThat(rtlChild2ANI.extras.traversalBefore).isLessThan(rtlChild3Id)
}
}
@Composable
fun InteropColumn(
padding: PaddingValues,
columnTag: String,
interopText: String,
firstButtonText: String,
lastButtonText: String
) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(padding)
.testTag(columnTag)
) {
Button(onClick = { }) {
Text(firstButtonText)
}
AndroidView(::TextView) {
it.text = interopText
}
Button(onClick = { }) {
Text(lastButtonText)
}
}
}
@Test
fun testChildrenSortedByBounds_ViewInterop() {
// Arrange.
val topAppBarText = "Top App Bar"
val columnTag = "Column Tag"
val interopText = "This is a text in a TextView"
val firstButtonText = "First Button"
val lastButtonText = "Last Button"
val fabContentText = "FAB Icon"
setContent {
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
Scaffold(
scaffoldState = scaffoldState,
topBar = { TopAppBar(title = { Text(topAppBarText) }) },
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { FloatingActionButton(onClick = {}) {
Icon(imageVector = Icons.Default.Add, contentDescription = fabContentText)
} },
drawerContent = { Text(text = "Drawer Menu 1") },
content = { padding -> InteropColumn(
padding, columnTag, interopText, firstButtonText, lastButtonText) },
bottomBar = { BottomAppBar { Text("Bottom App Bar") } }
)
}
val colSemanticsNode = rule.onNodeWithTag(columnTag)
.fetchSemanticsNode("can't find node with tag $columnTag")
val viewHolder = androidComposeView.androidViewsHandler
.layoutNodeToHolder[colSemanticsNode.replacedChildren[1].layoutNode]
checkNotNull(viewHolder)
val firstButtonId = rule.onNodeWithText(firstButtonText).semanticsId
val lastButtonId = rule.onNodeWithText(lastButtonText).semanticsId
// Act.
rule.waitForIdle()
val colAccessibilityNode = createAccessibilityNodeInfo(colSemanticsNode.id)
val firstButtonANI = createAccessibilityNodeInfo(firstButtonId)
val lastButtonANI = createAccessibilityNodeInfo(lastButtonId)
val viewANI = viewHolder.createAccessibilityNodeInfo()
// Assert.
// Desired ordering: Top App Bar -> first button -> android view -> last button -> FAB.
// First check that the View exists
rule.runOnIdle {
assertThat(colAccessibilityNode.childCount).isEqualTo(3)
assertThat(colSemanticsNode.replacedChildren.size).isEqualTo(3)
// Then verify that the first button comes before the View
assertThat(firstButtonANI.extras.traversalBefore)
.isEqualTo(viewHolder.layoutNode.semanticsId)
// And the last button comes after the View
assertThat(lastButtonANI.extras.traversalAfter)
.isEqualTo(viewHolder.layoutNode.semanticsId)
// Check the View's `before` and `after` values have also been set
assertThat(viewANI.extras.traversalAfter).isEqualTo(firstButtonId)
assertThat(viewANI.extras.traversalBefore).isEqualTo(lastButtonId)
}
}
@Composable
fun InteropColumnBackwards(
padding: PaddingValues,
columnTag: String,
interopText: String,
firstButtonText: String,
thirdButtonText: String,
fourthButtonText: String
) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(padding)
.testTag(columnTag)
) {
Button(modifier = Modifier.semantics { traversalIndex = 3f }, onClick = { }) {
Text(firstButtonText)
}
AndroidView(::TextView, modifier = Modifier.semantics { traversalIndex = 2f }) {
it.text = interopText
}
Button(modifier = Modifier.semantics { traversalIndex = 1f }, onClick = { }) {
Text(thirdButtonText)
}
Button(modifier = Modifier.semantics { traversalIndex = 0f }, onClick = { }) {
Text(fourthButtonText)
}
}
}
@Test
fun testChildrenSortedByBounds_ViewInteropBackwards() {
// Arrange.
val topAppBarText = "Top App Bar"
val columnTag = "Column Tag"
val interopText = "This is a text in a TextView"
val firstButtonText = "First Button"
val thirdButtonText = "Third Button"
val fourthButtonText = "Fourth Button"
val fabContentText = "FAB Icon"
setContent {
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
Scaffold(
scaffoldState = scaffoldState,
topBar = { TopAppBar(title = { Text(topAppBarText) }) },
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { FloatingActionButton(onClick = {}) {
Icon(imageVector = Icons.Default.Add, contentDescription = fabContentText)
} },
drawerContent = { Text(text = "Drawer Menu 1") },
content = { padding -> InteropColumnBackwards(
padding, columnTag, interopText,
firstButtonText, thirdButtonText, fourthButtonText
) },
bottomBar = { BottomAppBar { Text("Bottom App Bar") } }
)
}
val colSemanticsNode = rule.onNodeWithTag(columnTag)
.fetchSemanticsNode("can't find node with tag $columnTag")
val viewHolder = androidComposeView.androidViewsHandler
.layoutNodeToHolder[colSemanticsNode.replacedChildren[1].layoutNode]
checkNotNull(viewHolder) // Check that the View exists
val firstButtonId = rule.onNodeWithText(firstButtonText).semanticsId
val thirdButtonId = rule.onNodeWithText(thirdButtonText).semanticsId
val fourthButtonId = rule.onNodeWithText(fourthButtonText).semanticsId
// Act.
rule.waitForIdle()
val colAccessibilityNode = createAccessibilityNodeInfo(colSemanticsNode.id)
val firstButtonANI = createAccessibilityNodeInfo(firstButtonId)
val thirdButtonANI = createAccessibilityNodeInfo(thirdButtonId)
val fourthButtonANI = createAccessibilityNodeInfo(fourthButtonId)
val viewANI = viewHolder.createAccessibilityNodeInfo()
// Assert.
rule.runOnIdle {
assertThat(colAccessibilityNode.childCount).isEqualTo(4)
assertThat(colSemanticsNode.replacedChildren.size).isEqualTo(4)
// Desired ordering:
// Top App Bar -> fourth button -> third button -> android view -> first button -> FAB.
// Fourth button comes before the third button
assertThat(fourthButtonANI.extras.traversalBefore).isEqualTo(thirdButtonId)
// Then verify that the third button comes before Android View
assertThat(thirdButtonANI.extras.traversalBefore)
.isEqualTo(viewHolder.layoutNode.semanticsId)
// And the first button comes after the View
assertThat(firstButtonANI.extras.traversalAfter)
.isEqualTo(viewHolder.layoutNode.semanticsId)
// Check the View's `before` and `after` values have also been set
assertThat(viewANI.extras.traversalAfter).isEqualTo(thirdButtonId)
assertThat(viewANI.extras.traversalBefore).isEqualTo(firstButtonId)
}
}
private companion object {
private val Bundle.traversalAfter: Int
get() = getInt("android.view.accessibility.extra.EXTRA_DATA_TEST_TRAVERSALAFTER_VAL")
private val Bundle.traversalBefore: Int
get() = getInt("android.view.accessibility.extra.EXTRA_DATA_TEST_TRAVERSALBEFORE_VAL")
}
@Test
fun testPerformAction_showOnScreen() {
rule.mainClock.autoAdvance = false
val scrollState = ScrollState(initial = 0)
val target1Tag = "target1"
val target2Tag = "target2"
setContent {
Box {
with(LocalDensity.current) {
Column(
Modifier
.size(200.toDp())
.verticalScroll(scrollState)
) {
BasicText("Backward",
Modifier
.testTag(target2Tag)
.size(150.toDp()))
BasicText("Forward",
Modifier
.testTag(target1Tag)
.size(150.toDp()))
}
}
}
}
assertThat(scrollState.value).isEqualTo(0)
val showOnScreen = android.R.id.accessibilityActionShowOnScreen
val target1Id = rule.onNodeWithTag(target1Tag).semanticsId
rule.runOnUiThread {
assertThat(provider.performAction(target1Id, showOnScreen, null)).isTrue()
}
rule.mainClock.advanceTimeBy(5000)
assertThat(scrollState.value).isGreaterThan(99)
val target2Id = rule.onNodeWithTag(target2Tag).semanticsId
rule.runOnUiThread {
assertThat(provider.performAction(target2Id, showOnScreen, null)).isTrue()
}
rule.mainClock.advanceTimeBy(5000)
assertThat(scrollState.value).isEqualTo(0)
}
@Test
fun testPerformAction_showOnScreen_lazy() {
rule.mainClock.autoAdvance = false
val lazyState = LazyListState()
val target1Tag = "target1"
val target2Tag = "target2"
setContent {
Box {
with(LocalDensity.current) {
LazyColumn(
modifier = Modifier.size(200.toDp()),
state = lazyState
) {
item {
BasicText("Backward",
Modifier
.testTag(target2Tag)
.size(150.toDp()))
}
item {
BasicText("Forward",
Modifier
.testTag(target1Tag)
.size(150.toDp()))
}
}
}
}
}
assertThat(lazyState.firstVisibleItemScrollOffset).isEqualTo(0)
val showOnScreen = android.R.id.accessibilityActionShowOnScreen
val target1Id = rule.onNodeWithTag(target1Tag).semanticsId
rule.runOnUiThread {
assertThat(provider.performAction(target1Id, showOnScreen, null)).isTrue()
}
rule.mainClock.advanceTimeBy(5000)
assertThat(lazyState.firstVisibleItemIndex).isEqualTo(0)
assertThat(lazyState.firstVisibleItemScrollOffset).isGreaterThan(99)
val target2Id = rule.onNodeWithTag(target2Tag).semanticsId
rule.runOnUiThread {
assertThat(provider.performAction(target2Id, showOnScreen, null)).isTrue()
}
rule.mainClock.advanceTimeBy(5000)
assertThat(lazyState.firstVisibleItemIndex).isEqualTo(0)
assertThat(lazyState.firstVisibleItemScrollOffset).isEqualTo(0)
}
@Test
fun testPerformAction_showOnScreen_lazynested() {
val parentLazyState = LazyListState()
val lazyState = LazyListState()
val target1Tag = "target1"
val target2Tag = "target2"
setContent {
Box {
with(LocalDensity.current) {
LazyRow(
modifier = Modifier.size(250.toDp()),
state = parentLazyState
) {
item {
LazyColumn(
modifier = Modifier.size(200.toDp()),
state = lazyState
) {
item {
BasicText(
"Backward",
Modifier
.testTag(target2Tag)
.size(150.toDp())
)
}
item {
BasicText(
"Forward",
Modifier
.testTag(target1Tag)
.size(150.toDp())
)
}
}
}
}
}
}
}
assertThat(lazyState.firstVisibleItemIndex).isEqualTo(0)
assertThat(lazyState.firstVisibleItemScrollOffset).isEqualTo(0)
// Test that child column scrolls to make it fully visible in its context, without being
// influenced by or influencing the parent row.
// TODO(b/190865803): Is this the ultimate right behavior we want?
val showOnScreen = android.R.id.accessibilityActionShowOnScreen
val target1Id = rule.onNodeWithTag(target1Tag).semanticsId
rule.runOnUiThread {
assertThat(provider.performAction(target1Id, showOnScreen, null)).isTrue()
}
rule.mainClock.advanceTimeBy(5000)
assertThat(lazyState.firstVisibleItemIndex).isEqualTo(0)
assertThat(lazyState.firstVisibleItemScrollOffset).isGreaterThan(99)
assertThat(parentLazyState.firstVisibleItemScrollOffset).isEqualTo(0)
val target2Id = rule.onNodeWithTag(target2Tag).semanticsId
rule.runOnUiThread {
assertThat(provider.performAction(target2Id, showOnScreen, null)).isTrue()
}
rule.mainClock.advanceTimeBy(5000)
assertThat(lazyState.firstVisibleItemIndex).isEqualTo(0)
assertThat(lazyState.firstVisibleItemScrollOffset).isEqualTo(0)
assertThat(parentLazyState.firstVisibleItemScrollOffset).isEqualTo(0)
}
@Test
fun testPerformAction_focus() {
// Arrange.
setContent {
Box(
Modifier
.testTag(tag)
.focusable()) {
BasicText("focusable")
}
}
val virtualViewId = rule.onNodeWithTag(tag)
.assert(expectValue(Focused, false))
.semanticsId
// Act.
rule.runOnUiThread {
assertThat(provider.performAction(virtualViewId, ACTION_FOCUS, null)).isTrue()
}
// Assert.
rule.onNodeWithTag(tag).assert(expectValue(Focused, true))
}
@Test
fun testPerformAction_clearFocus() {
// Arrange.
val focusRequester = FocusRequester()
setContent {
Box(
Modifier
.testTag(tag)
.focusRequester(focusRequester)
.focusable()) {
BasicText("focusable")
}
}
rule.runOnIdle { focusRequester.requestFocus() }
val virtualViewId = rule.onNodeWithTag(tag)
.assert(expectValue(Focused, true))
.semanticsId
// Act.
rule.runOnUiThread {
assertThat(provider.performAction(virtualViewId, ACTION_CLEAR_FOCUS, null)).isTrue()
}
// Assert.
rule.onNodeWithTag(tag).assert(expectValue(Focused, false))
}
@Test
fun testPerformAction_succeedOnEnabledNodes() {
// Arrange.
setContent {
var checked by remember { mutableStateOf(true) }
Box(
Modifier
.toggleable(value = checked, onValueChange = { checked = it })
.testTag(tag)
) {
BasicText("ToggleableText")
}
}
rule.onNodeWithTag(tag)
.assertIsDisplayed()
.assertIsOn()
val toggleableNodeId = rule.onNodeWithTag(tag).semanticsId
// Act.
val actionPerformed = rule.runOnUiThread {
provider.performAction(toggleableNodeId, ACTION_CLICK, null)
}
// Assert.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.onNodeWithTag(tag).assertIsOff()
assertThat(actionPerformed).isTrue()
}
@Test
fun testPerformAction_failOnDisabledNodes() {
// Arrange.
setContent {
var checked by remember { mutableStateOf(true) }
Box(
Modifier
.toggleable(
value = checked,
enabled = false,
onValueChange = { checked = it }
)
.testTag(tag),
content = {
BasicText("ToggleableText")
}
)
}
val toggleableId = rule.onNodeWithTag(tag)
.assertIsDisplayed()
.assertIsOn()
.semanticsId
// Act.
val actionPerformed = rule.runOnUiThread {
provider.performAction(toggleableId, ACTION_CLICK, null)
}
// Assert.
rule.onNodeWithTag(tag).assertIsOn()
assertThat(actionPerformed).isFalse()
}
@Test
fun testTextField_performClickAction_succeedOnEnabledNode() {
// Arrange.
setContent {
BasicTextField(
modifier = Modifier.testTag(tag),
value = "value",
onValueChange = {}
)
}
val textFieldNodeId = rule.onNodeWithTag(tag)
.assertIsDisplayed()
.semanticsId
// Act.
val actionPerformed = rule.runOnUiThread {
provider.performAction(textFieldNodeId, ACTION_CLICK, null)
}
// Assert.
rule.onNodeWithTag(tag).assert(expectValue(Focused, true))
assertThat(actionPerformed).isTrue()
}
@Test
fun testTextField_performSetSelectionAction_succeedOnEnabledNode() {
// Arrange.
var textFieldSelectionOne = false
setContent {
var value by remember { mutableStateOf(TextFieldValue("hello")) }
BasicTextField(
modifier = Modifier
.semantics {
// Make sure this block will be executed when selection changes.
this.textSelectionRange = value.selection
if (value.selection == TextRange(1)) {
textFieldSelectionOne = true
}
}
.testTag(tag),
value = value,
onValueChange = { value = it }
)
}
val textFieldId = rule.onNodeWithTag(tag)
.assertIsDisplayed()
.semanticsId
val argument = Bundle()
argument.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, 1)
argument.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, 1)
// Act.
val actionPerformed = rule.runOnUiThread {
textFieldSelectionOne = false
provider.performAction(textFieldId, ACTION_SET_SELECTION, argument)
}
rule.waitUntil(5_000) { textFieldSelectionOne }
// Assert.
rule.onNodeWithTag(tag).assert(expectValue(TextSelectionRange, TextRange(1)))
assertThat(actionPerformed).isTrue()
}
@Test
fun testTextField_testFocusClearFocusAction() {
// Arrange.
setContent {
BasicTextField(
modifier = Modifier.testTag(tag),
value = "value",
onValueChange = {}
)
}
val textFieldId = rule.onNodeWithTag(tag)
.assert(expectValue(Focused, false))
.semanticsId
// Act.
var actionPerformed = rule.runOnUiThread {
provider.performAction(textFieldId, ACTION_FOCUS, null)
}
// Assert.
rule.onNodeWithTag(tag).assert(expectValue(Focused, true))
assertThat(actionPerformed).isTrue()
// Act.
actionPerformed = rule.runOnUiThread {
provider.performAction(textFieldId, ACTION_CLEAR_FOCUS, null)
}
// Assert.
rule.onNodeWithTag(tag).assert(expectValue(Focused, false))
assertThat(actionPerformed).isTrue()
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Suppress("DEPRECATION")
fun testAddExtraDataToAccessibilityNodeInfo_notMerged() {
lateinit var textLayoutResult: TextLayoutResult
setContent {
BasicTextField(
modifier = Modifier.testTag(tag),
value = "texy",
onValueChange = {},
onTextLayout = { textLayoutResult = it }
)
}
val textFieldNode = rule.onNodeWithTag(tag)
.fetchSemanticsNode("couldn't find node with tag $tag")
@Suppress("DEPRECATION") val info = AccessibilityNodeInfo.obtain()
val argument = Bundle().apply {
putInt(EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX, 0)
putInt(EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH, 1)
}
// TODO(b/272068594): This looks like a bug. This should be
// AccessibilityNodeInfoCompat.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY
provider.addExtraDataToAccessibilityNodeInfo(
textFieldNode.id,
info,
EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY,
argument
)
// TODO(b/272068594): This looks like a bug. This should be
// AccessibilityNodeInfoCompat.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY
val data = info.extras
.getParcelableArray(EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY)
assertThat(data!!.size).isEqualTo(1)
val rectF = data[0] as RectF // result in screen coordinates
val expectedRectInLocalCoords = textLayoutResult.getBoundingBox(0).translate(
textFieldNode.positionInWindow
)
val expectedTopLeftInScreenCoords = androidComposeView.localToScreen(
expectedRectInLocalCoords.topLeft
)
assertThat(rectF.left).isEqualTo(expectedTopLeftInScreenCoords.x)
assertThat(rectF.top).isEqualTo(expectedTopLeftInScreenCoords.y)
assertThat(rectF.width()).isEqualTo(expectedRectInLocalCoords.width)
assertThat(rectF.height()).isEqualTo(expectedRectInLocalCoords.height)
val testTagKey = "androidx.compose.ui.semantics.testTag"
provider.addExtraDataToAccessibilityNodeInfo(
textFieldNode.id,
info,
testTagKey,
argument
)
assertThat(info.extras.getCharSequence(testTagKey)).isEqualTo(tag)
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun getSemanticsNodeIdFromExtraData() {
// Arrange.
setContent { BasicText("texy") }
val textId = rule.onNodeWithText("texy").semanticsId
val info = AccessibilityNodeInfoCompat.obtain().unwrap()
val argument = Bundle()
val idKey = "androidx.compose.ui.semantics.id"
// Act.
rule.runOnIdle {
provider.addExtraDataToAccessibilityNodeInfo(textId, info, idKey, argument)
}
// Assert.
rule.runOnIdle { assertThat(info.extras.getInt(idKey)).isEqualTo(textId) }
}
@Test
fun sendClickedEvent_whenClick() {
// Arrange.
setContent {
Box(
Modifier
.clickable(onClick = {})
.testTag(tag)) {
BasicText("Text")
}
}
val virtualViewId = rule.onNodeWithTag(tag).semanticsId
// Act.
val actionPerformed = rule.runOnUiThread {
provider.performAction(virtualViewId, ACTION_CLICK, null)
}
// Assert.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.runOnIdle {
assertThat(actionPerformed).isTrue()
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == virtualViewId &&
it.eventType == TYPE_VIEW_CLICKED
}
)
)
}
}
@Test
fun sendStateChangeEvent_whenStateChange() {
// Arrange.
var state by mutableStateOf("state one")
setContent {
Box(
Modifier
.semantics { stateDescription = state }
.testTag(tag)
) {
BasicText("Text")
}
}
val virtualViewId = rule.onNodeWithTag(tag)
.assertValueEquals("state one")
.semanticsId
// Act.
rule.runOnIdle { state = "state two" }
// Assert.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.runOnIdle {
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == virtualViewId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_STATE_DESCRIPTION
}
)
)
// Temporary(b/192295060) fix, sending CONTENT_CHANGE_TYPE_UNDEFINED to
// force ViewRootImpl to update its accessibility-focused virtual-node.
// If we have an androidx fix, we can remove this event.
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == virtualViewId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_UNDEFINED
}
)
)
}
}
@Test
fun sendStateChangeEvent_whenClickToggleable() {
// Arrange.
setContent {
var checked by remember { mutableStateOf(true) }
Box(
Modifier
.toggleable(
value = checked,
onValueChange = { checked = it }
)
.testTag(tag)
) {
BasicText("ToggleableText")
}
}
val virtualViewId = rule.onNodeWithTag(tag)
.assertIsDisplayed()
.assertIsOn()
.semanticsId
// Act.
rule.onNodeWithTag(tag).performClick()
// Assert.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.onNodeWithTag(tag).assertIsOff()
rule.runOnIdle {
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == virtualViewId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_STATE_DESCRIPTION
}
)
)
// Temporary(b/192295060) fix, sending CONTENT_CHANGE_TYPE_UNDEFINED to
// force ViewRootImpl to update its accessibility-focused virtual-node.
// If we have an androidx fix, we can remove this event.
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == virtualViewId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_UNDEFINED
}
)
)
}
}
@Test
fun sendStateChangeEvent_whenSelectedChange() {
// Arrange.
setContent {
var selected by remember { mutableStateOf(false) }
Box(
Modifier
.selectable(selected = selected, onClick = { selected = true })
.testTag(tag)
) {
BasicText("Text")
}
}
val virtualViewId = rule.onNodeWithTag(tag)
.assertIsDisplayed()
.assertIsNotSelected()
.semanticsId
// Act.
rule.onNodeWithTag(tag).performClick()
// Assert.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.onNodeWithTag(tag).assertIsSelected()
rule.runOnIdle {
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == virtualViewId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_STATE_DESCRIPTION
}
)
)
// Temporary(b/192295060) fix, sending CONTENT_CHANGE_TYPE_UNDEFINED to
// force ViewRootImpl to update its accessibility-focused virtual-node.
// If we have an androidx fix, we can remove this event.
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == virtualViewId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_UNDEFINED
}
)
)
}
}
@Test
fun sendViewSelectedEvent_whenSelectedChange_forTab() {
// Arrange.
setContent {
var selected by remember { mutableStateOf(false) }
Box(
Modifier
.selectable(selected = selected, onClick = { selected = true }, role = Role.Tab)
.testTag(tag)
) {
BasicText("Text")
}
}
val virtualViewId = rule.onNodeWithTag(tag)
.assertIsDisplayed()
.assertIsNotSelected()
.semanticsId
// Act.
rule.onNodeWithTag(tag).performClick()
// Assert.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.onNodeWithTag(tag).assertIsSelected()
rule.runOnIdle {
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == virtualViewId &&
it.eventType == TYPE_VIEW_SELECTED &&
it.text.size == 1 &&
it.text[0].toString() == "Text"
}
)
)
}
}
@Test
fun sendStateChangeEvent_whenRangeInfoChange() {
// Arrange.
var current by mutableStateOf(0.5f)
setContent {
Box(
Modifier
.progressSemantics(current)
.testTag(tag)) {
BasicText("Text")
}
}
val virtualViewId = rule.onNodeWithTag(tag).semanticsId
// Act.
rule.runOnIdle { current = 0.9f }
// Assert.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.runOnIdle {
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == virtualViewId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_STATE_DESCRIPTION
}
)
)
// Temporary(b/192295060) fix, sending CONTENT_CHANGE_TYPE_UNDEFINED to
// force ViewRootImpl to update its accessibility-focused virtual-node.
// If we have an androidx fix, we can remove this event.
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == virtualViewId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_UNDEFINED
}
)
)
}
}
@Test
fun sendTextEvents_whenSetText() {
// Arrange.
val locale = LocaleList("en_US")
val initialText = "h"
val finalText = "hello"
setContent {
var value by remember { mutableStateOf(TextFieldValue(initialText)) }
BasicTextField(
modifier = Modifier.testTag(tag),
value = value,
onValueChange = { value = it },
visualTransformation = {
TransformedText(it.toUpperCase(locale), OffsetMapping.Identity)
}
)
}
rule.onNodeWithTag(tag)
.assertIsDisplayed()
.assert(expectValue(EditableText, AnnotatedString("H")))
// TODO(b/272068594): Extra TYPE_WINDOW_CONTENT_CHANGED sent 100ms after setup.
rule.mainClock.advanceTimeBy(100L)
clearInvocations(container)
// Act.
rule.onNodeWithTag(tag).performSemanticsAction(SetText) { it(AnnotatedString(finalText)) }
// Assert.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
val virtualId = rule.onNodeWithTag(tag)
.assert(expectValue(EditableText, AnnotatedString("HELLO")))
.semanticsId
rule.runOnIdle {
verify(container, atLeastOnce()).requestSendAccessibilityEvent(
eq(androidComposeView), argument.capture()
)
assertThat(argument.allValues)
.comparingElementsUsing(AccessibilityEventComparator)
.containsExactly(
AccessibilityEvent().apply {
eventType = TYPE_VIEW_TEXT_CHANGED
className = TextFieldClassName
setSource(androidComposeView, virtualId)
isPassword = false
fromIndex = initialText.length
removedCount = 0
addedCount = finalText.length - initialText.length
beforeText = initialText.toUpperCase(locale)
this.text.add(finalText.toUpperCase(locale))
},
AccessibilityEvent().apply {
eventType = TYPE_VIEW_TEXT_SELECTION_CHANGED
setSource(androidComposeView, virtualId)
isPassword = false
fromIndex = finalText.length
toIndex = finalText.length
itemCount = finalText.length
this.text.add(finalText.toUpperCase(locale))
}
).inOrder()
}
}
@Test
@Ignore("b/177656801")
fun sendSubtreeChangeEvents_whenNodeRemoved() {
val columnTag = "topColumn"
val textFieldTag = "TextFieldTag"
var isTextFieldVisible by mutableStateOf(true)
setContent {
Column(Modifier.testTag(columnTag)) {
if (isTextFieldVisible) {
BasicTextField(
modifier = Modifier.testTag(textFieldTag),
value = "text",
onValueChange = {}
)
}
}
}
val columnId = rule.onNodeWithTag(columnTag).semanticsId
rule.runOnIdle {
verify(container, atLeastOnce()).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == columnId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_SUBTREE
}
)
)
}
clearInvocations(container)
// Act- TextField is removed compared to setup.
rule.runOnIdle { isTextFieldVisible = false }
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.onNodeWithTag(textFieldTag).assertDoesNotExist()
rule.runOnIdle {
verify(container, atLeastOnce()).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == columnId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_SUBTREE
}
)
)
}
}
@Test
fun selectionEventBeforeTraverseEvent_whenTraverseTextField() {
val text = "h"
setContent {
var value by remember { mutableStateOf(TextFieldValue(text)) }
BasicTextField(
modifier = Modifier.testTag(tag),
value = value,
onValueChange = { value = it },
visualTransformation = PasswordVisualTransformation(),
decorationBox = {
BasicText("Label")
it()
}
)
}
val virtualViewId = rule.onNodeWithTag(tag).assertIsDisplayed().semanticsId
// TODO(b/272068594): Extra TYPE_WINDOW_CONTENT_CHANGED sent 100ms after setup.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
clearInvocations(container)
// Act.
rule.runOnUiThread {
provider.performAction(
virtualViewId,
ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
createMovementGranularityCharacterArgs()
)
}
// Assert.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.runOnIdle {
verify(container, atLeastOnce()).requestSendAccessibilityEvent(
eq(androidComposeView), argument.capture()
)
assertThat(argument.allValues)
.comparingElementsUsing(AccessibilityEventComparator)
.containsExactly(
AccessibilityEvent().apply {
eventType = TYPE_VIEW_TEXT_SELECTION_CHANGED
isPassword = true
setSource(androidComposeView, virtualViewId)
fromIndex = 1
toIndex = 1
itemCount = 1
this.text.add("•")
},
AccessibilityEvent().apply {
eventType = TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY
isPassword = true
setSource(androidComposeView, virtualViewId)
action = ACTION_NEXT_AT_MOVEMENT_GRANULARITY
movementGranularity = 1
fromIndex = 0
toIndex = 1
this.text.add("•")
}
)
.inOrder()
}
}
@Test
fun selectionEventBeforeTraverseEvent_whenTraverseText() {
// Arrange.
val text = "h"
setContent {
BasicText(text, Modifier.testTag(tag))
}
val virtualViewId = rule.onNodeWithTag(tag).assertIsDisplayed().semanticsId
// TODO(b/272068594): Extra TYPE_WINDOW_CONTENT_CHANGED sent 100ms after setup.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
clearInvocations(container)
// Act.
rule.runOnUiThread {
provider.performAction(
virtualViewId,
ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
createMovementGranularityCharacterArgs()
)
}
// Assert.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.runOnIdle {
verify(container, atLeastOnce()).requestSendAccessibilityEvent(
eq(androidComposeView), argument.capture()
)
assertThat(argument.allValues)
.comparingElementsUsing(AccessibilityEventComparator)
.containsExactly(
AccessibilityEvent().apply {
eventType = TYPE_VIEW_TEXT_SELECTION_CHANGED
setSource(androidComposeView, virtualViewId)
fromIndex = 1
toIndex = 1
itemCount = 1
this.text.add("h")
},
AccessibilityEvent().apply {
eventType = TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY
setSource(androidComposeView, virtualViewId)
action = ACTION_NEXT_AT_MOVEMENT_GRANULARITY
movementGranularity = 1
fromIndex = 0
toIndex = 1
this.text.add("h")
}
)
.inOrder()
}
}
@Test
@Ignore("b/177656801")
fun semanticsNodeBeingMergedLayoutChange_sendThrottledSubtreeEventsForMergedSemanticsNode() {
setContent {
var checked by remember { mutableStateOf(true) }
Box(
Modifier
.toggleable(value = checked, onValueChange = { checked = it })
.testTag(tag)
) {
BasicText("ToggleableText")
Box {
BasicText("TextNode")
}
}
}
val toggleableId = rule.onNodeWithTag(tag).semanticsId
val textNode = rule.onNodeWithText("TextNode", useUnmergedTree = true)
.fetchSemanticsNode("couldn't find node with text TextNode")
rule.runOnIdle {
verify(container, atLeastOnce()).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == toggleableId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_SUBTREE
}
)
)
}
rule.runOnUiThread {
// Directly call onLayoutChange because this guarantees short time.
for (i in 1..10) {
delegate.onLayoutChange(textNode.layoutNode)
}
}
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.runOnIdle {
verify(container, atLeastOnce()).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == toggleableId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_SUBTREE
}
)
)
}
}
@Test
@Ignore("b/177656801")
fun layoutNodeWithoutSemanticsLayoutChange_sendThrottledSubtreeEventsForMergedSemanticsNode() {
setContent {
var checked by remember { mutableStateOf(true) }
Box(
Modifier
.toggleable(value = checked, onValueChange = { checked = it })
.testTag(tag)
) {
BasicText("ToggleableText")
Box {
BasicText("TextNode")
}
}
}
val toggleableId = rule.onNodeWithTag(tag).semanticsId
val textNode = rule.onNodeWithText("TextNode", useUnmergedTree = true)
.fetchSemanticsNode("couldn't find node with text TextNode")
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.runOnIdle {
verify(container, atLeastOnce()).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == toggleableId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_SUBTREE
}
)
)
}
rule.runOnUiThread {
// Directly call onLayoutChange because this guarantees short time.
for (i in 1..10) {
// layout change for the parent box node
delegate.onLayoutChange(textNode.layoutNode.parent!!)
}
}
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.runOnIdle {
// One from initialization and one from layout changes.
verify(container, atLeastOnce()).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == toggleableId &&
it.eventType == TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_SUBTREE
}
)
)
}
}
@Test
fun testSemanticsHitTest() {
// Arrange.
setContent {
var checked by remember { mutableStateOf(true) }
Box(
Modifier
.toggleable(value = checked, onValueChange = { checked = it })
.testTag(tag)
) {
BasicText("ToggleableText")
}
}
val toggleableId = rule.onNodeWithTag(tag).semanticsId
val toggleableBounds = with(rule.density) {
rule.onNodeWithTag(tag).getBoundsInRoot().toRect()
}
// Act.
val toggleableNodeId = rule.runOnIdle {
delegate.hitTestSemanticsAt(
(toggleableBounds.left + toggleableBounds.right) / 2,
(toggleableBounds.top + toggleableBounds.bottom) / 2,
)
}
// Assert.
assertThat(toggleableId).isEqualTo(toggleableNodeId)
}
@Test
fun testSemanticsHitTest_overlappedChildren() {
// Arrange.
val childOneTag = "OverlappedChildOne"
val childTwoTag = "OverlappedChildTwo"
setContent {
Box {
with(LocalDensity.current) {
BasicText(
"Child One",
Modifier
.zIndex(1f)
.testTag(childOneTag)
.requiredSize(50.toDp())
)
BasicText(
"Child Two",
Modifier
.testTag(childTwoTag)
.requiredSize(50.toDp())
)
}
}
}
val childOneId = rule.onNodeWithTag(childOneTag).semanticsId
val childTwoId = rule.onNodeWithTag(childTwoTag).semanticsId
val overlappedChildNodeBounds = with(rule.density) {
rule.onNodeWithTag(childTwoTag).getBoundsInRoot().toRect()
}
// Act.
val overlappedChildNodeId = rule.runOnIdle {
delegate.hitTestSemanticsAt(
(overlappedChildNodeBounds.left + overlappedChildNodeBounds.right) / 2,
(overlappedChildNodeBounds.top + overlappedChildNodeBounds.bottom) / 2
)
}
// Assert.
assertThat(childOneId).isEqualTo(overlappedChildNodeId)
assertThat(childTwoId).isNotEqualTo(overlappedChildNodeId)
}
@Test
fun testSemanticsHitTest_scrolled() {
val scrollState = ScrollState(initial = 0)
var scope: CoroutineScope? = null
setContent {
val actualScope = rememberCoroutineScope()
SideEffect { scope = actualScope }
Box {
with(LocalDensity.current) {
Column(
Modifier
.size(200.toDp())
.verticalScroll(scrollState)
) {
BasicText("Before scroll", Modifier.size(200.toDp()))
BasicText("After scroll",
Modifier
.testTag(tag)
.size(200.toDp()))
}
}
}
}
assertThat(scrollState.value).isEqualTo(0)
scope!!.launch {
// Scroll to the bottom
scrollState.scrollBy(10000f)
}
rule.waitForIdle()
assertThat(scrollState.value).isGreaterThan(199)
val vitrualViewId = rule.onNodeWithTag(tag).semanticsId
val childNodeBounds = with(rule.density) {
rule.onNodeWithTag(tag).getBoundsInRoot().toRect()
}
val hitTestedId = delegate.hitTestSemanticsAt(
(childNodeBounds.left + childNodeBounds.right) / 2,
(childNodeBounds.top + childNodeBounds.bottom) / 2
)
assertThat(vitrualViewId).isEqualTo(hitTestedId)
}
@OptIn(ExperimentalComposeUiApi::class)
@Test
fun testSemanticsHitTest_invisibleToUserSemantics() {
// Arrange.
setContent {
Box(
Modifier
.size(100.dp)
.clickable {}
.testTag(tag)
.semantics { invisibleToUser() }) {
BasicText("")
}
}
val bounds = with(rule.density) { rule.onNodeWithTag(tag).getBoundsInRoot().toRect() }
// Act.
val hitNodeId = rule.runOnIdle {
delegate.hitTestSemanticsAt(
bounds.left + bounds.width / 2,
bounds.top + bounds.height / 2
)
}
// Assert.
rule.runOnIdle { assertThat(hitNodeId).isEqualTo(InvalidId) }
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
fun viewInteropIsInvisibleToUser() {
setContent {
AndroidView({ TextView(it).apply { text = "Test"; isScreenReaderFocusable = true } })
}
Espresso
.onView(instanceOf(TextView::class.java))
.check(matches(isDisplayed()))
.check { view, exception ->
val viewParent = view.parent
if (viewParent !is View) {
throw exception
}
val delegate = viewParent.accessibilityDelegate
if (viewParent.accessibilityDelegate == null) {
throw exception
}
val info = AccessibilityNodeInfo()
delegate.onInitializeAccessibilityNodeInfo(view, info)
// This is expected to be false, unlike
// AndroidViewTest.androidViewAccessibilityDelegate, because this test suite sets
// `accessibilityForceEnabledForTesting` to true.
if (info.isVisibleToUser) {
throw exception
}
}
}
@Test
fun testSemanticsHitTest_transparentNode() {
// Arrange.
setContent {
Box(
Modifier
.alpha(0f)
.size(100.dp)
.clickable {}
.testTag(tag)) {
BasicText("")
}
}
val bounds = with(rule.density) { rule.onNodeWithTag(tag).getBoundsInRoot().toRect() }
// Act.
val hitNodeId = rule.runOnIdle {
delegate.hitTestSemanticsAt(
bounds.left + bounds.width / 2,
bounds.top + bounds.height / 2
)
}
// Assert.
rule.runOnIdle { assertThat(hitNodeId).isEqualTo(InvalidId) }
}
@Test
fun testSemanticsHitTest_clearAndSet() {
// Arrange.
val outertag = "outerbox"
val innertag = "innerbox"
setContent {
Box(
Modifier
.size(100.dp)
.clickable {}
.testTag(outertag)
.clearAndSetSemantics {}) {
Box(
Modifier
.size(100.dp)
.clickable {}
.testTag(innertag)) {
BasicText("")
}
}
}
val outerNodeId = rule.onNodeWithTag(outertag).semanticsId
val bounds = with(rule.density) {
rule.onNodeWithTag(innertag, true).getBoundsInRoot().toRect()
}
// Act.
val hitNodeId = rule.runOnIdle {
delegate.hitTestSemanticsAt(
bounds.left + bounds.width / 2,
bounds.top + bounds.height / 2
)
}
// Assert.
rule.runOnIdle { assertThat(outerNodeId).isEqualTo(hitNodeId) }
}
@Test
@SdkSuppress(maxSdkVersion = Build.VERSION_CODES.P)
fun testViewInterop_findViewByAccessibilityId() {
setContent {
Column {
AndroidView(
{ context ->
LinearLayout(context).apply {
addView(TextView(context).apply { text = "Text1" })
addView(TextView(context).apply { text = "Text2" })
}
},
Modifier.testTag(tag)
)
BasicText("text")
}
}
val getViewRootImplMethod = View::class.java.getDeclaredMethod("getViewRootImpl")
getViewRootImplMethod.isAccessible = true
val rootView = getViewRootImplMethod.invoke(container)
val forName = Class::class.java.getMethod("forName", String::class.java)
val getDeclaredMethod = Class::class.java.getMethod(
"getDeclaredMethod",
String::class.java,
arrayOf<Class<*>>()::class.java
)
val viewRootImplClass = forName.invoke(null, "android.view.ViewRootImpl") as Class<*>
val getAccessibilityInteractionControllerMethod = getDeclaredMethod.invoke(
viewRootImplClass,
"getAccessibilityInteractionController",
arrayOf<Class<*>>()
) as Method
getAccessibilityInteractionControllerMethod.isAccessible = true
val accessibilityInteractionController =
getAccessibilityInteractionControllerMethod.invoke(rootView)
val accessibilityInteractionControllerClass =
forName.invoke(null, "android.view.AccessibilityInteractionController") as Class<*>
val findViewByAccessibilityIdMethod =
getDeclaredMethod.invoke(
accessibilityInteractionControllerClass,
"findViewByAccessibilityId",
arrayOf<Class<*>>(Int::class.java)
) as Method
findViewByAccessibilityIdMethod.isAccessible = true
val androidView = rule.onNodeWithTag(tag)
.fetchSemanticsNode("can't find node with tag $tag")
val viewGroup = androidComposeView.androidViewsHandler
.layoutNodeToHolder[androidView.layoutNode]!!.view as ViewGroup
val getAccessibilityViewIdMethod = View::class.java
.getDeclaredMethod("getAccessibilityViewId")
getAccessibilityViewIdMethod.isAccessible = true
val textTwo = viewGroup.getChildAt(1)
val textViewTwoId = getAccessibilityViewIdMethod.invoke(textTwo)
val foundView = findViewByAccessibilityIdMethod.invoke(
accessibilityInteractionController,
textViewTwoId
)
assertThat(foundView).isNotNull()
assertThat(textTwo).isEqualTo(foundView)
}
@Test
fun testViewInterop_viewChildExists() {
// Arrange.
val buttonText = "button text"
setContent {
Column(Modifier.testTag(tag)) {
AndroidView(::Button) {
it.text = buttonText
it.setOnClickListener {}
}
BasicText("text")
}
}
val colSemanticsNode = rule.onNodeWithTag(tag)
.fetchSemanticsNode("can't find node with tag $tag")
val colAccessibilityNode = createAccessibilityNodeInfo(colSemanticsNode.id)
// Act.
val buttonHolder = rule.runOnIdle {
androidComposeView.androidViewsHandler
.layoutNodeToHolder[colSemanticsNode.replacedChildren[0].layoutNode]
}
checkNotNull(buttonHolder)
// Assert.
rule.runOnIdle {
assertThat(colAccessibilityNode.childCount).isEqualTo(2)
assertThat(colSemanticsNode.replacedChildren.size).isEqualTo(2)
assertThat(buttonHolder.importantForAccessibility)
.isEqualTo(ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES)
assertThat((buttonHolder.getChildAt(0) as Button).text)
.isEqualTo(buttonText)
}
}
@Test
fun testViewInterop_hoverEnterExit() {
val colTag = "ColTag"
val textTag = "TextTag"
val buttonText = "button text"
setContent {
Column(Modifier.testTag(colTag)) {
AndroidView(::Button) {
it.text = buttonText
it.setOnClickListener {}
}
BasicText(text = "text", modifier = Modifier.testTag(textTag))
}
}
val colSemanticsNode = rule.onNodeWithTag(colTag)
.fetchSemanticsNode("can't find node with tag $colTag")
rule.runOnUiThread {
val bounds = colSemanticsNode.replacedChildren[0].boundsInRoot
val hoverEnter = createHoverMotionEvent(
action = ACTION_HOVER_ENTER,
x = (bounds.left + bounds.right) / 2f,
y = (bounds.top + bounds.bottom) / 2f
)
assertThat(androidComposeView.dispatchHoverEvent(hoverEnter)).isTrue()
assertThat(delegate.hoveredVirtualViewId).isEqualTo(InvalidId)
}
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.runOnIdle {
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(ArgumentMatcher { it.eventType == TYPE_VIEW_HOVER_ENTER })
)
}
val virtualViewId = rule.onNodeWithTag(textTag).semanticsId
val bounds = with(rule.density) { rule.onNodeWithTag(textTag).getBoundsInRoot().toRect() }
rule.runOnUiThread {
val hoverEnter = createHoverMotionEvent(
action = ACTION_HOVER_MOVE,
x = (bounds.left + bounds.right) / 2,
y = (bounds.top + bounds.bottom) / 2
)
assertThat(androidComposeView.dispatchHoverEvent(hoverEnter)).isTrue()
assertThat(delegate.hoveredVirtualViewId).isEqualTo(virtualViewId)
}
// verify hover exit accessibility event is sent from the previously hovered view
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.runOnIdle {
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(ArgumentMatcher { it.eventType == TYPE_VIEW_HOVER_EXIT })
)
}
}
@Test
fun testViewInterop_dualHoverEnterExit() {
val colTag = "ColTag"
val textTag = "TextTag"
val buttonText = "button text"
val events = mutableListOf<PointerEvent>()
setContent {
Column(
Modifier
.testTag(colTag)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
event.changes[0].consume()
events += event
}
}
}
) {
AndroidView(::Button) {
it.text = buttonText
it.setOnClickListener {}
}
BasicText(text = "text", modifier = Modifier.testTag(textTag))
}
}
val colSemanticsNode = rule.onNodeWithTag(colTag)
.fetchSemanticsNode("can't find node with tag $colTag")
rule.runOnUiThread {
val bounds = colSemanticsNode.replacedChildren[0].boundsInRoot
val hoverEnter = createHoverMotionEvent(
action = ACTION_HOVER_ENTER,
x = (bounds.left + bounds.right) / 2f,
y = (bounds.top + bounds.bottom) / 2f
)
assertThat(androidComposeView.dispatchHoverEvent(hoverEnter)).isTrue()
assertThat(delegate.hoveredVirtualViewId).isEqualTo(InvalidId)
// Assert that the hover event has also been dispatched
assertThat(events).hasSize(1)
// and that the hover event is an enter event
assertHoverEvent(events[0], isEnter = true)
}
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.runOnIdle {
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(ArgumentMatcher { it.eventType == TYPE_VIEW_HOVER_ENTER })
)
}
}
private fun assertHoverEvent(
event: PointerEvent,
isEnter: Boolean = false,
isExit: Boolean = false
) {
assertThat(event.changes).hasSize(1)
val change = event.changes[0]
assertThat(change.pressed).isFalse()
assertThat(change.previousPressed).isFalse()
val expectedHoverType = when {
isEnter -> PointerEventType.Enter
isExit -> PointerEventType.Exit
else -> PointerEventType.Move
}
assertThat(event.type).isEqualTo(expectedHoverType)
}
private fun createHoverMotionEvent(action: Int, x: Float, y: Float): MotionEvent {
val pointerProperties = MotionEvent.PointerProperties().apply {
toolType = MotionEvent.TOOL_TYPE_FINGER
}
val pointerCoords = MotionEvent.PointerCoords().also {
it.x = x
it.y = y
}
return MotionEvent.obtain(
0L /* downTime */,
0L /* eventTime */,
action,
1 /* pointerCount */,
arrayOf(pointerProperties),
arrayOf(pointerCoords),
0 /* metaState */,
0 /* buttonState */,
0f /* xPrecision */,
0f /* yPrecision */,
0 /* deviceId */,
0 /* edgeFlags */,
InputDevice.SOURCE_TOUCHSCREEN,
0 /* flags */
)
}
@Test
fun testAccessibilityNodeInfoTreePruned_completelyCovered() {
// Arrange.
val parentTag = "ParentForOverlappedChildren"
val childOneTag = "OverlappedChildOne"
val childTwoTag = "OverlappedChildTwo"
setContent {
Box(Modifier.testTag(parentTag)) {
with(LocalDensity.current) {
BasicText(
"Child One",
Modifier
.zIndex(1f)
.testTag(childOneTag)
.requiredSize(50.toDp())
)
BasicText(
"Child Two",
Modifier
.testTag(childTwoTag)
.requiredSize(50.toDp())
)
}
}
}
val parentNodeId = rule.onNodeWithTag(parentTag).semanticsId
val overlappedChildOneNodeId = rule.onNodeWithTag(childOneTag).semanticsId
val overlappedChildTwoNodeId = rule.onNodeWithTag(childTwoTag).semanticsId
// Assert.
rule.runOnIdle {
assertThat(createAccessibilityNodeInfo(parentNodeId).childCount).isEqualTo(1)
assertThat(createAccessibilityNodeInfo(overlappedChildOneNodeId).text.toString())
.isEqualTo("Child One")
assertThat(provider.createAccessibilityNodeInfo(overlappedChildTwoNodeId)).isNull()
}
}
@Test
fun testAccessibilityNodeInfoTreePruned_partiallyCovered() {
// Arrange.
val parentTag = "parent"
val density = Density(2f)
setContent {
CompositionLocalProvider(LocalDensity provides density) {
Box(Modifier.testTag(parentTag)) {
with(LocalDensity.current) {
BasicText(
"Child One",
Modifier
.zIndex(1f)
.requiredSize(100.toDp())
)
BasicText(
"Child Two",
Modifier.requiredSize(200.toDp(), 100.toDp())
)
}
}
}
}
val parentNodeId = rule.onNodeWithTag(parentTag).semanticsId
val childTwoId = rule.onNodeWithText("Child Two").semanticsId
val childTwoBounds = Rect()
// Act.
rule.waitForIdle()
val parentInfo = createAccessibilityNodeInfo(parentNodeId)
createAccessibilityNodeInfo(childTwoId).getBoundsInScreen(childTwoBounds)
// Assert.
rule.runOnIdle {
assertThat(parentInfo.childCount).isEqualTo(2)
assertThat(childTwoBounds.height()).isEqualTo(100)
assertThat(childTwoBounds.width()).isEqualTo(100)
}
}
@Test
fun testPaneAppear() {
var isPaneVisible by mutableStateOf(false)
val paneTestTitle by mutableStateOf("pane title")
setContent {
if (isPaneVisible) {
Box(
Modifier
.testTag(tag)
.semantics { paneTitle = paneTestTitle }
) {}
}
}
rule.onNodeWithTag(tag).assertDoesNotExist()
isPaneVisible = true
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
val paneId = rule.onNodeWithTag(tag)
.assert(expectValue(SemanticsProperties.PaneTitle, "pane title"))
.assertIsDisplayed()
.semanticsId
rule.runOnIdle {
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == paneId &&
it.eventType == TYPE_WINDOW_STATE_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_PANE_APPEARED
}
)
)
}
}
@Test
fun testPaneTitleChange() {
var isPaneVisible by mutableStateOf(false)
var paneTestTitle by mutableStateOf("pane title")
setContent {
if (isPaneVisible) {
Box(
Modifier
.testTag(tag)
.semantics { paneTitle = paneTestTitle }
) {}
}
}
rule.onNodeWithTag(tag).assertDoesNotExist()
isPaneVisible = true
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.onNodeWithTag(tag)
.assert(expectValue(SemanticsProperties.PaneTitle, "pane title"))
.assertIsDisplayed()
paneTestTitle = "new pane title"
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
val paneId = rule.onNodeWithTag(tag)
.assert(expectValue(SemanticsProperties.PaneTitle, "new pane title"))
.semanticsId
rule.runOnIdle {
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
getAccessibilityEventSourceSemanticsNodeId(it) == paneId &&
it.eventType == TYPE_WINDOW_STATE_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_PANE_TITLE
}
)
)
}
}
@Test
fun testPaneDisappear() {
var isPaneVisible by mutableStateOf(false)
val paneTestTitle by mutableStateOf("pane title")
setContent {
if (isPaneVisible) {
Box(
Modifier
.testTag(tag)
.semantics { paneTitle = paneTestTitle }
) {}
}
}
rule.onNodeWithTag(tag).assertDoesNotExist()
isPaneVisible = true
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.onNodeWithTag(tag)
.assert(expectValue(SemanticsProperties.PaneTitle, "pane title"))
.assertIsDisplayed()
isPaneVisible = false
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.onNodeWithTag(tag).assertDoesNotExist()
rule.runOnIdle {
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
it.eventType == TYPE_WINDOW_STATE_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_PANE_DISAPPEARED
}
)
)
}
}
@Test
fun testMultiPanesDisappear() {
val firstPaneTag = "Pane 1"
val secondPaneTag = "Pane 2"
var isPaneVisible by mutableStateOf(false)
val firstPaneTestTitle by mutableStateOf("first pane title")
val secondPaneTestTitle by mutableStateOf("second pane title")
setContent {
if (isPaneVisible) {
Column {
with(LocalDensity.current) {
Box(
Modifier
.size(100.toDp())
.testTag(firstPaneTag)
.semantics { paneTitle = firstPaneTestTitle }
) {}
Box(
Modifier
.size(100.toDp())
.testTag(secondPaneTag)
.semantics { paneTitle = secondPaneTestTitle }
) {}
}
}
}
}
rule.onNodeWithTag(firstPaneTag).assertDoesNotExist()
rule.onNodeWithTag(secondPaneTag).assertDoesNotExist()
isPaneVisible = true
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.onNodeWithTag(firstPaneTag)
.assert(expectValue(SemanticsProperties.PaneTitle, "first pane title"))
.assertIsDisplayed()
rule.onNodeWithTag(secondPaneTag)
.assert(expectValue(SemanticsProperties.PaneTitle, "second pane title"))
.assertIsDisplayed()
isPaneVisible = false
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.onNodeWithTag(firstPaneTag).assertDoesNotExist()
rule.onNodeWithTag(secondPaneTag).assertDoesNotExist()
rule.runOnIdle {
verify(container, times(2)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
it.eventType == TYPE_WINDOW_STATE_CHANGED &&
it.contentChangeTypes == CONTENT_CHANGE_TYPE_PANE_DISAPPEARED
}
)
)
}
}
@Test
fun testEventForPasswordTextField() {
// Arrange.
setContent {
BasicTextField(
modifier = Modifier.testTag(tag),
value = "value",
onValueChange = {},
visualTransformation = PasswordVisualTransformation()
)
}
// Act.
rule.onNodeWithTag(tag).performSemanticsAction(SetText) {
it(AnnotatedString("new value"))
}
// Assert.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.runOnIdle {
verify(container, atLeastOnce()).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(ArgumentMatcher { it.isPassword })
)
}
}
@Test
fun testLayerParamChange_setCorrectBounds_syntaxOne() {
var scale by mutableStateOf(1f)
setContent {
// testTag must not be on the same node with graphicsLayer, otherwise we will have
// semantics change notification.
with(LocalDensity.current) {
Box(
Modifier
.graphicsLayer(scaleX = scale, scaleY = scale)
.requiredSize(300.toDp())
) {
Box(
Modifier
.matchParentSize()
.testTag("node"))
}
}
}
val virtualViewId = rule.onNodeWithTag("node").semanticsId
@Suppress("DEPRECATION") var info: AccessibilityNodeInfo = AccessibilityNodeInfo.obtain()
rule.runOnUiThread {
info = createAccessibilityNodeInfo(virtualViewId)
}
val rect = Rect()
info.getBoundsInScreen(rect)
assertThat(rect.width()).isEqualTo(300)
assertThat(rect.height()).isEqualTo(300)
scale = 0.5f
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
@Suppress("DEPRECATION") info.recycle()
rule.runOnIdle {
info = createAccessibilityNodeInfo(virtualViewId)
}
info.getBoundsInScreen(rect)
assertThat(rect.width()).isEqualTo(150)
assertThat(rect.height()).isEqualTo(150)
}
@Test
fun testLayerParamChange_setCorrectBounds_syntaxTwo() {
var scale by mutableStateOf(1f)
setContent {
// testTag must not be on the same node with graphicsLayer, otherwise we will have
// semantics change notification.
with(LocalDensity.current) {
Box(
Modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.requiredSize(300.toDp())
) {
Box(
Modifier
.matchParentSize()
.testTag(tag))
}
}
}
val virtualViewId = rule.onNodeWithTag(tag).semanticsId
@Suppress("DEPRECATION") var info: AccessibilityNodeInfo = AccessibilityNodeInfo.obtain()
rule.runOnUiThread {
info = createAccessibilityNodeInfo(virtualViewId)
}
val rect = Rect()
info.getBoundsInScreen(rect)
assertThat(rect.width()).isEqualTo(300)
assertThat(rect.height()).isEqualTo(300)
scale = 0.5f
@Suppress("DEPRECATION") info.recycle()
rule.runOnIdle {
info = createAccessibilityNodeInfo(virtualViewId)
}
info.getBoundsInScreen(rect)
assertThat(rect.width()).isEqualTo(150)
assertThat(rect.height()).isEqualTo(150)
}
@Test
fun testDialog_setCorrectBounds() {
var dialogComposeView: AndroidComposeView? = null
setContent {
Dialog(onDismissRequest = {}) {
dialogComposeView = LocalView.current as AndroidComposeView
delegate = ViewCompat.getAccessibilityDelegate(dialogComposeView!!) as
AndroidComposeViewAccessibilityDelegateCompat
provider = delegate.getAccessibilityNodeProvider(dialogComposeView!!).provider
as AccessibilityNodeProvider
with(LocalDensity.current) {
Box(Modifier.size(300.toDp())) {
BasicText(
text = "text",
modifier = Modifier
.offset(100.toDp(), 100.toDp())
.fillMaxSize()
)
}
}
}
}
val virtualViewId = rule.onNodeWithText("text").semanticsId
@Suppress("DEPRECATION") var info: AccessibilityNodeInfo = AccessibilityNodeInfo.obtain()
rule.runOnUiThread {
info = createAccessibilityNodeInfo(virtualViewId)
}
val viewPosition = intArrayOf(0, 0)
dialogComposeView!!.getLocationOnScreen(viewPosition)
val offset = 100
val size = 200
val textPositionOnScreenX = viewPosition[0] + offset
val textPositionOnScreenY = viewPosition[1] + offset
val textRect = Rect()
info.getBoundsInScreen(textRect)
assertThat(textRect)
.isEqualTo(
Rect(
textPositionOnScreenX,
textPositionOnScreenY,
textPositionOnScreenX + size,
textPositionOnScreenY + size
)
)
}
@Test
@OptIn(ExperimentalComposeUiApi::class)
fun testTestTagsAsResourceId() {
// Arrange.
val tag1 = "box1"
val tag2 = "box2"
val tag3 = "box3"
val tag4 = "box4"
val tag5 = "box5"
val tag6 = "box6"
val tag7 = "box7"
setContent {
with(LocalDensity.current) {
Column {
Box(
Modifier
.size(100.toDp())
.testTag(tag1))
Box(Modifier.semantics { testTagsAsResourceId = true }) {
Box(
Modifier
.size(100.toDp())
.testTag(tag2))
}
Box(Modifier.semantics { testTagsAsResourceId = false }) {
Box(
Modifier
.size(100.toDp())
.testTag(tag3))
}
Box(Modifier.semantics { testTagsAsResourceId = true }) {
Box(Modifier.semantics { testTagsAsResourceId = false }) {
Box(
Modifier
.size(100.toDp())
.testTag(tag4))
}
}
Box(Modifier.semantics { testTagsAsResourceId = false }) {
Box(Modifier.semantics { testTagsAsResourceId = true }) {
Box(
Modifier
.size(100.toDp())
.testTag(tag5))
}
}
Box(Modifier.semantics(true) { testTagsAsResourceId = true }) {
Box(Modifier.semantics { testTagsAsResourceId = false }) {
Box(
Modifier
.size(100.toDp())
.testTag(tag6))
}
}
Box(Modifier.semantics(true) { testTagsAsResourceId = false }) {
Box(Modifier.semantics { testTagsAsResourceId = true }) {
Box(
Modifier
.size(100.toDp())
.testTag(tag7))
}
}
}
}
}
val box1Id = rule.onNodeWithTag(tag1).semanticsId
val box2Id = rule.onNodeWithTag(tag2).semanticsId
val box3Id = rule.onNodeWithTag(tag3).semanticsId
val box4Id = rule.onNodeWithTag(tag4).semanticsId
val box5Id = rule.onNodeWithTag(tag5).semanticsId
val box6Id = rule.onNodeWithTag(tag6, true).semanticsId
val box7Id = rule.onNodeWithTag(tag7, true).semanticsId
// Act.
rule.waitForIdle()
val info1 = createAccessibilityNodeInfo(box1Id)
val info2 = createAccessibilityNodeInfo(box2Id)
val info3 = createAccessibilityNodeInfo(box3Id)
val info4 = createAccessibilityNodeInfo(box4Id)
val info5 = createAccessibilityNodeInfo(box5Id)
val info6 = createAccessibilityNodeInfo(box6Id)
val info7 = createAccessibilityNodeInfo(box7Id)
// Assert.
rule.runOnIdle {
assertThat(info1.viewIdResourceName).isNull()
assertThat(info2.viewIdResourceName).isEqualTo(tag2)
assertThat(info3.viewIdResourceName).isNull()
assertThat(info4.viewIdResourceName).isNull()
assertThat(info5.viewIdResourceName).isEqualTo(tag5)
assertThat(info6.viewIdResourceName).isNull()
assertThat(info7.viewIdResourceName).isEqualTo(tag7)
}
}
@Test
fun testContentDescription_notMergingDescendants_withOwnContentDescription() {
// Arrange.
setContent {
Column(
Modifier
.semantics { contentDescription = "Column" }
.testTag(tag)) {
with(LocalDensity.current) {
BasicText("Text")
Box(
Modifier
.size(100.toDp())
.semantics { contentDescription = "Box" })
}
}
}
val virtualId = rule.onNodeWithTag(tag).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualId) }
// Assert.
rule.runOnIdle { assertThat(info.contentDescription).isEqualTo("Column") }
}
@Test
fun testContentDescription_notMergingDescendants_withoutOwnContentDescription() {
// Arrange.
setContent {
Column(
Modifier
.semantics {}
.testTag(tag)) {
BasicText("Text")
with(LocalDensity.current) {
Box(
Modifier
.size(100.toDp())
.semantics { contentDescription = "Box" })
}
}
}
val virtualViewId = rule.onNodeWithTag(tag).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle { assertThat(info.contentDescription).isNull() }
}
@Test
fun testContentDescription_singleNode_notMergingDescendants() {
// Arrange.
setContent {
with(LocalDensity.current) {
Box(
Modifier
.size(100.toDp())
.testTag(tag)
.semantics { contentDescription = "Box" }
)
}
}
val virtualViewId = rule.onNodeWithTag(tag).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle { assertThat(info.contentDescription).isEqualTo("Box") }
}
@Test
fun testContentDescription_singleNode_mergingDescendants() {
// Arrange.
setContent {
with(LocalDensity.current) {
Box(
Modifier
.size(100.toDp())
.testTag(tag)
.semantics(true) { contentDescription = "Box" }
)
}
}
val virtualViewId = rule.onNodeWithTag(tag).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle { assertThat(info.contentDescription).isEqualTo("Box") }
}
@Test
fun testContentDescription_replacingSemanticsNode() {
// Arrange.
setContent {
with(LocalDensity.current) {
Column(
Modifier
.size(100.toDp())
.testTag(tag)
.clearAndSetSemantics { contentDescription = "Replacing description" }
) {
Box(
Modifier
.size(100.toDp())
.semantics { contentDescription = "Box one" })
Box(
Modifier
.size(100.toDp())
.semantics(true) { contentDescription = "Box two" }
)
}
}
}
val virtualViewId = rule.onNodeWithTag(tag).semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle { assertThat(info.contentDescription).isEqualTo("Replacing description") }
}
@Test
fun testRole_doesNotMerge() {
// Arrange.
setContent {
Row(
Modifier
.semantics(true) {}
.testTag("Row")) {
with(LocalDensity.current) {
Box(
Modifier
.size(100.toDp())
.semantics { role = Role.Button })
Box(
Modifier
.size(100.toDp())
.semantics { role = Role.Image })
}
}
}
val virtualViewId = rule.onNodeWithTag("Row").semanticsId
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle { assertThat(info.className).isEqualTo(ClassName) }
}
@Test
fun testReportedBounds_clickableNode_includesPadding(): Unit = with(rule.density) {
// Arrange.
val size = 100.dp.roundToPx()
setContent {
with(LocalDensity.current) {
Column {
Box(
Modifier
.testTag("tag")
.clickable {}
.size(size.toDp())
.padding(10.toDp())
.semantics {
contentDescription = "Button"
}
)
}
}
}
val virtualViewId = rule.onNodeWithTag("tag").semanticsId
// Act.
val accessibilityNodeInfo = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle {
val rect = Rect()
accessibilityNodeInfo.getBoundsInScreen(rect)
val resultWidth = rect.right - rect.left
val resultHeight = rect.bottom - rect.top
assertThat(resultWidth).isEqualTo(size)
assertThat(resultHeight).isEqualTo(size)
}
}
@Test
fun testReportedBounds_clickableNode_excludesPadding(): Unit = with(rule.density) {
// Arrange.
val size = 100.dp.roundToPx()
val density = Density(2f)
setContent {
CompositionLocalProvider(LocalDensity provides density) {
Column {
with(density) {
Box(
Modifier
.testTag("tag")
.semantics { contentDescription = "Test" }
.size(size.toDp())
.padding(10.toDp())
.clickable {}
)
}
}
}
}
val virtualViewId = rule.onNodeWithTag("tag").semanticsId
// Act.
val accessibilityNodeInfo = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle {
val rect = Rect()
accessibilityNodeInfo.getBoundsInScreen(rect)
val resultWidth = rect.right - rect.left
val resultHeight = rect.bottom - rect.top
assertThat(resultWidth).isEqualTo(size - 20)
assertThat(resultHeight).isEqualTo(size - 20)
}
}
@Test
fun testReportedBounds_withClearAndSetSemantics() {
// Arrange.
val size = 100
setContent {
with(LocalDensity.current) {
Column {
Box(
Modifier
.testTag("tag")
.size(size.toDp())
.padding(10.toDp())
.clearAndSetSemantics {}
.clickable {}
)
}
}
}
val virtualViewId = rule.onNodeWithTag("tag").semanticsId
// Act.
val accessibilityNodeInfo = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle {
val rect = Rect()
accessibilityNodeInfo.getBoundsInScreen(rect)
val resultWidth = rect.right - rect.left
val resultHeight = rect.bottom - rect.top
assertThat(resultWidth).isEqualTo(size)
assertThat(resultHeight).isEqualTo(size)
}
}
@Test
fun testReportedBounds_withTwoClickable_outermostWins(): Unit = with(rule.density) {
// Arrange.
val size = 100.dp.roundToPx()
setContent {
with(LocalDensity.current) {
Column {
Box(
Modifier
.testTag(tag)
.clickable {}
.size(size.toDp())
.padding(10.toDp())
.clickable {}
)
}
}
}
val virtualViewId = rule.onNodeWithTag(tag).semanticsId
// Act.
val accessibilityNodeInfo = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle {
val rect = Rect()
accessibilityNodeInfo.getBoundsInScreen(rect)
val resultWidth = rect.right - rect.left
val resultHeight = rect.bottom - rect.top
assertThat(resultWidth).isEqualTo(size)
assertThat(resultHeight).isEqualTo(size)
}
}
@Test
fun testReportedBounds_outerMostSemanticsUsed() {
// Arrange.
val size = 100
setContent {
with(LocalDensity.current) {
Column {
Box(
Modifier
.testTag(tag)
.semantics { contentDescription = "Test1" }
.size(size.toDp())
.padding(10.toDp())
.semantics { contentDescription = "Test2" }
)
}
}
}
val virtualViewId = rule.onNodeWithTag(tag).semanticsId
// Act.
val accessibilityNodeInfo = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle {
val rect = Rect()
accessibilityNodeInfo.getBoundsInScreen(rect)
val resultWidth = rect.right - rect.left
val resultHeight = rect.bottom - rect.top
assertThat(resultWidth).isEqualTo(size)
assertThat(resultHeight).isEqualTo(size)
}
}
@Test
fun testReportedBounds_withOffset() {
// Arrange.
val size = 100
val offset = 10
val density = Density(1f)
setContent {
CompositionLocalProvider(LocalDensity provides density) {
with(LocalDensity.current) {
Column {
Box(
Modifier
.size(size.toDp())
.offset(offset.toDp(), offset.toDp())
.testTag(tag)
.semantics { contentDescription = "Test" }
)
}
}
}
}
val virtualViewId = rule.onNodeWithTag(tag).semanticsId
// Act.
val accessibilityNodeInfo = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle {
val rect = Rect()
accessibilityNodeInfo.getBoundsInScreen(rect)
val resultWidth = rect.right - rect.left
val resultHeight = rect.bottom - rect.top
val resultInLocalCoords = androidComposeView.screenToLocal(rect.toComposeRect().topLeft)
assertThat(resultWidth).isEqualTo(size)
assertThat(resultHeight).isEqualTo(size)
assertThat(resultInLocalCoords.x).isWithin(0.001f).of(10f)
assertThat(resultInLocalCoords.y).isWithin(0.001f).of(10f)
}
}
@Test
fun testSemanticsNodePositionAndBounds_doesNotThrow_whenLayoutNodeNotAttached() {
// Assert.
var emitNode by mutableStateOf(true)
setContent {
if (emitNode) {
with(LocalDensity.current) {
Box(
Modifier
.size(100.toDp())
.testTag(tag))
}
}
}
val semanticNode = rule.onNodeWithTag(tag).fetchSemanticsNode()
// Act.
rule.runOnIdle { emitNode = false }
// Assert.
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
rule.runOnIdle {
with(semanticNode) {
assertThat(positionInRoot).isEqualTo(Offset.Zero)
assertThat(positionInWindow).isEqualTo(Offset.Zero)
assertThat(boundsInRoot).isEqualTo(androidx.compose.ui.geometry.Rect.Zero)
assertThat(boundsInWindow).isEqualTo(androidx.compose.ui.geometry.Rect.Zero)
}
}
}
@Test
fun testSemanticsSort_doesNotThrow_whenCoordinatorNotAttached() {
// Arrange.
setContent {
with(LocalDensity.current) {
Box(
Modifier
.size(100.toDp())
.testTag("parent")) {
Box(
Modifier
.size(100.toDp())
.testTag("child"))
}
}
}
val parent = rule.onNodeWithTag("parent").fetchSemanticsNode()
val child = rule.onNodeWithTag("child").fetchSemanticsNode()
// Act.
rule.runOnIdle {
child.layoutNode.innerCoordinator.onRelease()
}
// Assert.
rule.runOnIdle {
assertThat(parent.unmergedChildren(true).size).isEqualTo(1)
assertThat(child.unmergedChildren(true).size).isEqualTo(0)
}
}
@Test
fun testSemanticsSort_doesNotThrow_whenCoordinatorNotAttached_compare() {
// Arrange.
setContent {
with(LocalDensity.current) {
Box(
Modifier
.size(100.toDp())
.testTag("parent")) {
Box(
Modifier
.size(100.toDp())
.testTag("child1")) {
Box(
Modifier
.size(50.toDp())
.testTag("grandChild1"))
}
Box(
Modifier
.size(100.toDp())
.testTag("child2")) {
Box(
Modifier
.size(50.toDp())
.testTag("grandChild2"))
}
}
}
}
val parent = rule.onNodeWithTag("parent").fetchSemanticsNode()
val grandChild1 = rule.onNodeWithTag("grandChild1").fetchSemanticsNode()
val grandChild2 = rule.onNodeWithTag("grandChild2").fetchSemanticsNode()
// Act.
rule.runOnIdle {
grandChild1.layoutNode.innerCoordinator.onRelease()
grandChild2.layoutNode.innerCoordinator.onRelease()
}
// Assert.
rule.runOnIdle {
assertThat(parent.unmergedChildren(true).size).isEqualTo(2)
}
}
@Test
fun testFakeNodeCreated_forContentDescriptionSemantics() {
// Arrange.
setContent {
Column(
Modifier
.semantics(true) { contentDescription = "Test" }
.testTag(tag)
) {
BasicText("Text")
with(LocalDensity.current) {
Box(
Modifier
.size(100.toDp())
.semantics { contentDescription = "Hello" })
}
}
}
val columnNode = rule.onNodeWithTag(tag, true).fetchSemanticsNode()
// Act.
val firstChild = rule.runOnIdle { columnNode.replacedChildren.first() }
// Assert.
rule.runOnIdle {
assertThat(firstChild.isFake).isTrue()
assertThat(firstChild.unmergedConfig[ContentDescription].first()).isEqualTo("Test")
}
}
@Test
fun testFakeNode_createdForButton() {
// Arrange.
setContent {
Column(
Modifier
.clickable(role = Role.Button) {}
.testTag(tag)) {
BasicText("Text")
}
}
val buttonNode = rule.onNodeWithTag(tag, true).fetchSemanticsNode()
// Act.
val lastChild = rule.runOnIdle { buttonNode.replacedChildren.lastOrNull() }
// Assert.
rule.runOnIdle {
assertThat(lastChild?.isFake).isTrue()
assertThat(lastChild?.unmergedConfig?.getOrNull(SemanticsProperties.Role))
.isEqualTo(Role.Button)
}
}
@Test
fun testFakeNode_notCreatedForButton_whenNoChildren() {
// Arrange.
setContent {
with(LocalDensity.current) {
Box(
Modifier
.size(100.toDp())
.clickable(role = Role.Button) {}
.testTag("button"))
}
}
val buttonNode = rule.onNodeWithTag("button").fetchSemanticsNode()
assertThat(buttonNode.unmergedChildren().any { it.isFake }).isFalse()
// Act.
val info = rule.runOnIdle { createAccessibilityNodeInfo(buttonNode.id) }
// Assert.
rule.runOnIdle { assertThat(info.className).isEqualTo("android.widget.Button") }
}
@Test
fun testFakeNode_reportParentBoundsAsFakeNodeBounds() {
// Arrange.
val density = Density(2f)
setContent {
CompositionLocalProvider(LocalDensity provides density) {
with(density) {
Box(
Modifier
.size(100.toDp())
.clickable(role = Role.Button) {}
.testTag(tag)) {
BasicText("Example")
}
}
}
}
// Button node
val parentNode = rule.onNodeWithTag(tag, useUnmergedTree = true).fetchSemanticsNode()
val parentBounds = Rect()
createAccessibilityNodeInfo(parentNode.id).getBoundsInScreen(parentBounds)
// Button role fake node
val fakeRoleNode = parentNode.unmergedChildren(includeFakeNodes = true).last()
val fakeRoleNodeBounds = Rect()
createAccessibilityNodeInfo(fakeRoleNode.id).getBoundsInScreen(fakeRoleNodeBounds)
assertThat(fakeRoleNodeBounds).isEqualTo(parentBounds)
}
@Test
fun testContentDescription_withFakeNode_mergedCorrectly() {
// Arrange.
setContent {
Column(
Modifier
.testTag(tag)
.semantics(true) { contentDescription = "Hello" }
) {
Box(Modifier.semantics { contentDescription = "World" })
}
}
// Assert.
rule.onNodeWithTag(tag).assertContentDescriptionEquals("Hello", "World")
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.P)
fun testScreenReaderFocusable_notSet_whenAncestorMergesDescendants() {
// Arrange.
setContent {
Column(Modifier.semantics(true) { }) {
BasicText("test", Modifier.testTag(tag))
}
}
val virtualViewId = rule.onNodeWithTag(tag, useUnmergedTree = true).semanticsId
// Act.
val childInfo = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle { assertThat(childInfo.isScreenReaderFocusable).isFalse() }
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.P)
fun testScreenReaderFocusable_set_whenAncestorDoesNotMerge() {
// Arrange.
setContent {
Column(Modifier.semantics(false) { }) {
BasicText("test", Modifier.testTag(tag))
}
}
val virtualViewId = rule.onNodeWithTag(tag, useUnmergedTree = true).semanticsId
// Act.
val childInfo = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle { assertThat(childInfo.isScreenReaderFocusable).isTrue() }
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.P)
fun testScreenReaderFocusable_notSet_whenChildNotSpeakable() {
// Arrange.
setContent {
Column(Modifier.semantics(false) { }) {
Box(
Modifier
.testTag(tag)
.size(100.dp))
}
}
val virtualViewId = rule.onNodeWithTag(tag, useUnmergedTree = true).semanticsId
// Act.
val childInfo = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle { assertThat(childInfo.isScreenReaderFocusable).isFalse() }
}
@Test
fun testImageRole_notSet_whenAncestorMergesDescendants() {
// Arrange.
setContent {
Column(Modifier.semantics(true) { }) {
Image(ImageBitmap(100, 100), "Image", Modifier.testTag(tag))
}
}
val virtualViewId = rule.onNodeWithTag(tag, true).semanticsId
// Act.
val imageInfo = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle { assertThat(imageInfo.className).isEqualTo(ClassName) }
}
@Test
fun testImageRole_set_whenAncestorDoesNotMerge() {
// Arrange.
setContent {
Column(Modifier.semantics { isEnabled() }) {
Image(ImageBitmap(100, 100), "Image", Modifier.testTag(tag))
}
}
val virtualViewId = rule.onNodeWithTag(tag, true).semanticsId
// Act.
val imageInfo = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle { assertThat(imageInfo.className).isEqualTo("android.widget.ImageView") }
}
@Test
fun testImageRole_set_whenImageItseldMergesDescendants() {
// Arrange.
setContent {
Column(Modifier.semantics(true) {}) {
Image(
ImageBitmap(100, 100),
"Image",
Modifier
.testTag(tag)
.semantics(true) { /* imitate clickable node */ }
)
}
}
val virtualViewId = rule.onNodeWithTag(tag, true).semanticsId
// Act.
val imageInfo = rule.runOnIdle { createAccessibilityNodeInfo(virtualViewId) }
// Assert.
rule.runOnIdle { assertThat(imageInfo.className).isEqualTo("android.widget.ImageView") }
}
@Test
fun testScrollableContainer_scrollViewClassNotSet_whenCollectionInfo() {
// Arrange.
val tagColumn = "lazy column"
val tagRow = "scrollable row"
setContent {
LazyColumn(Modifier.testTag(tagColumn)) {
item {
Row(
Modifier
.testTag(tagRow)
.scrollable(rememberScrollState(), Orientation.Horizontal)
) {
BasicText("test")
}
}
}
}
val columnId = rule.onNodeWithTag(tagColumn).semanticsId
val rowId = rule.onNodeWithTag(tagRow).semanticsId
// Act.
rule.waitForIdle()
val columnInfo = createAccessibilityNodeInfo(columnId)
val rowInfo = createAccessibilityNodeInfo(rowId)
// Assert.
rule.runOnIdle {
assertThat(columnInfo.className).isNotEqualTo("android.widget.ScrollView")
assertThat(rowInfo.className).isNotEqualTo("android.widget.HorizontalScrollView")
}
}
@Test
fun testTransparentNode_withAlphaModifier_notAccessible() {
// Arrange.
setContent {
Column(Modifier.testTag("parent")) {
val modifier = Modifier.size(100.dp)
Box(
Modifier
.alpha(0f)
) {
Box(
modifier
.semantics {
testTag = "child1"
contentDescription = "test"
}
)
}
Box(
Modifier
.alpha(0f)
.then(modifier)
.semantics {
testTag = "child2"
contentDescription = "test"
}
)
Box(
Modifier
.alpha(0f)
.semantics {
testTag = "child3"
contentDescription = "test"
}
.then(modifier)
)
Box(
modifier
.alpha(0f)
.semantics {
testTag = "child4"
contentDescription = "test"
}
)
Box(
Modifier
.size(100.dp)
.alpha(0f)
.shadow(2.dp)
.semantics {
testTag = "child5"
contentDescription = "test"
}
)
}
}
val parentId = rule.onNodeWithTag("parent").semanticsId
val child1Id = rule.onNodeWithTag("child1").semanticsId
val child2Id = rule.onNodeWithTag("child2").semanticsId
val child3Id = rule.onNodeWithTag("child3").semanticsId
val child4Id = rule.onNodeWithTag("child4").semanticsId
val child5Id = rule.onNodeWithTag("child5").semanticsId
// Act.
rule.waitForIdle()
val parent = createAccessibilityNodeInfo(parentId)
val child1 = createAccessibilityNodeInfo(child1Id)
val child2 = createAccessibilityNodeInfo(child2Id)
val child3 = createAccessibilityNodeInfo(child3Id)
val child4 = createAccessibilityNodeInfo(child4Id)
val child5 = createAccessibilityNodeInfo(child5Id)
// Assert.
rule.runOnIdle {
assertThat(parent.childCount).isEqualTo(5)
assertThat(child1.isVisibleToUser).isFalse()
assertThat(child2.isVisibleToUser).isFalse()
assertThat(child3.isVisibleToUser).isFalse()
assertThat(child4.isVisibleToUser).isFalse()
assertThat(child5.isVisibleToUser).isFalse()
}
}
@Test
fun testVisibleNode_withAlphaModifier_accessible() {
// Arrange.
setContent {
Column(Modifier.testTag("parent")) {
val modifier = Modifier.size(100.dp)
Box(
Modifier
.semantics {
testTag = "child1"
contentDescription = "test"
}
.then(modifier)
.alpha(0f)
)
Box(
Modifier
.semantics {
testTag = "child2"
contentDescription = "test"
}
.alpha(0f)
.then(modifier)
)
Box(
modifier
.semantics {
testTag = "child3"
contentDescription = "test"
}
.alpha(0f)
)
}
}
val parentId = rule.onNodeWithTag("parent").semanticsId
val child1Id = rule.onNodeWithTag("child1").semanticsId
val child2Id = rule.onNodeWithTag("child2").semanticsId
val child3Id = rule.onNodeWithTag("child3").semanticsId
// Act.
rule.waitForIdle()
val parent = createAccessibilityNodeInfo(parentId)
val child1 = createAccessibilityNodeInfo(child1Id)
val child2 = createAccessibilityNodeInfo(child2Id)
val child3 = createAccessibilityNodeInfo(child3Id)
// Assert.
rule.runOnIdle {
assertThat(parent.childCount).isEqualTo(3)
assertThat(child1.isVisibleToUser).isTrue()
assertThat(child2.isVisibleToUser).isTrue()
assertThat(child3.isVisibleToUser).isTrue()
}
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.P)
fun progressSemantics_mergesSemantics_forTalkback() {
// Arrange.
setContent {
Box(
Modifier
.progressSemantics(0.5f)
.testTag("box")) {
BasicText("test", Modifier.testTag("child"))
}
}
val boxId = rule.onNodeWithTag("box", useUnmergedTree = true).semanticsId
val textId = rule.onNodeWithTag("child", useUnmergedTree = true).semanticsId
// Act.
rule.waitForIdle()
val info = createAccessibilityNodeInfo(boxId)
val childInfo = createAccessibilityNodeInfo(textId)
// Assert.
rule.runOnIdle {
assertThat(info.isScreenReaderFocusable).isTrue()
assertThat(childInfo.isScreenReaderFocusable).isFalse()
}
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.P)
fun indeterminateProgressSemantics_mergesSemantics_forTalkback() {
// Arrange.
setContent {
Box(
Modifier
.progressSemantics()
.testTag("box")) {
BasicText("test", Modifier.testTag("child"))
}
}
val boxId = rule.onNodeWithTag("box", useUnmergedTree = true).semanticsId
val textId = rule.onNodeWithTag("child", useUnmergedTree = true).semanticsId
// Act.
rule.waitForIdle()
val info = createAccessibilityNodeInfo(boxId)
val childInfo = createAccessibilityNodeInfo(textId)
// Assert.
rule.runOnIdle {
assertThat(info.isScreenReaderFocusable).isTrue()
assertThat(childInfo.isScreenReaderFocusable).isFalse()
}
}
@Test
fun accessibilityStateChangeListenerRemoved_onDetach() {
// Arrange.
delegate.accessibilityForceEnabledForTesting = false
rule.runOnIdle { assertThat(androidComposeView.isAttachedToWindow).isTrue() }
// Act.
rule.runOnUiThread { container.removeView(androidComposeView) }
// Assert.
rule.runOnIdle {
// This test implies that the listener was removed
// and the enabled services were set to empty.
assertThat(androidComposeView.isAttachedToWindow).isFalse()
assertThat(delegate.isEnabledForAccessibility).isFalse()
}
}
@Test
fun touchExplorationChangeListenerRemoved_onDetach() {
// Arrange.
delegate.accessibilityForceEnabledForTesting = false
rule.runOnIdle { assertThat(androidComposeView.isAttachedToWindow).isTrue() }
// Act.
rule.runOnUiThread { container.removeView(androidComposeView) }
// Assert.
rule.runOnIdle {
// This test implies that the listener was removed
// and the enabled services were set to empty.
assertThat(androidComposeView.isAttachedToWindow).isFalse()
assertThat(delegate.isEnabledForAccessibility).isFalse()
}
}
@Test
fun isEnabled_returnsFalse_whenUIAutomatorIsTheOnlyEnabledService() {
// Arrange.
delegate.accessibilityForceEnabledForTesting = false
// Assert.
rule.runOnIdle {
// This test implies that UIAutomator is enabled and is the only enabled a11y service
assertThat(accessibilityManager.isEnabled).isTrue()
assertThat(delegate.isEnabledForAccessibility).isFalse()
}
}
@Test
fun canScroll_returnsFalse_whenAccessedOutsideOfMainThread() {
setContent {
Box(
Modifier.semantics(mergeDescendants = true) { }
) {
Column(
Modifier
.size(50.dp)
.verticalScroll(rememberScrollState())
) {
repeat(10) {
Box(Modifier.size(30.dp))
}
}
}
}
rule.runOnIdle {
androidComposeView.dispatchTouchEvent(
createHoverMotionEvent(MotionEvent.ACTION_DOWN, 10f, 10f)
)
assertThat(androidComposeView.canScrollVertically(1)).isTrue()
}
assertThat(androidComposeView.canScrollVertically(1)).isFalse()
}
private fun getAccessibilityEventSourceSemanticsNodeId(
event: android.view.accessibility.AccessibilityEvent
): Int = Class
.forName("android.view.accessibility.AccessibilityRecord")
.getDeclaredMethod("getSourceNodeId").run {
isAccessible = true
invoke(event) as Long shr 32
}.toInt()
private fun createMovementGranularityCharacterArgs(): Bundle {
return Bundle().apply {
this.putInt(
AccessibilityNodeInfoCompat.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT,
MOVEMENT_GRANULARITY_CHARACTER
)
this.putBoolean(
AccessibilityNodeInfoCompat.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN,
false
)
}
}
private fun createAccessibilityNodeInfo(virtualViewId: Int): AccessibilityNodeInfo {
return checkNotNull(provider.createAccessibilityNodeInfo(virtualViewId)) {
"Could not find view with id = $virtualViewId"
}
}
private fun setContent(content: @Composable () -> Unit) {
rule.mainClock.autoAdvance = false
container.setContent(content)
rule.mainClock.advanceTimeBy(accessibilityEventLoopIntervalMs)
clearInvocations(container)
}
}
/**
* A simple test layout that does the bare minimum required to lay out an arbitrary number of
* children reasonably. Useful for Semantics hierarchy testing
*/
@Composable
private fun SimpleTestLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
if (measurables.isEmpty()) {
layout(constraints.minWidth, constraints.minHeight) {}
} else {
val placeables = measurables.map {
it.measure(constraints)
}
val (width, height) = with(placeables) {
Pair(
max(
maxByOrNull { it.width }?.width ?: 0,
constraints.minWidth
),
max(
maxByOrNull { it.height }?.height ?: 0,
constraints.minHeight
)
)
}
layout(width, height) {
for (placeable in placeables) {
placeable.placeRelative(0, 0)
}
}
}
}
}
/**
* A simple SubComposeLayout which lays [contentOne] at [positionOne] and lays [contentTwo] at
* [positionTwo]. [contentOne] is placed first and [contentTwo] is placed second. Therefore, the
* semantics node for [contentOne] is before semantics node for [contentTwo] in
* [SemanticsNode.children].
*/
@Composable
private fun SimpleSubcomposeLayout(
modifier: Modifier = Modifier,
contentOne: @Composable () -> Unit,
positionOne: Offset,
contentTwo: @Composable () -> Unit,
positionTwo: Offset
) {
SubcomposeLayout(modifier) { constraints ->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
layout(layoutWidth, layoutHeight) {
val placeablesOne = subcompose(TestSlot.First, contentOne).fastMap {
it.measure(looseConstraints)
}
val placeablesTwo = subcompose(TestSlot.Second, contentTwo).fastMap {
it.measure(looseConstraints)
}
// Placing to control drawing order to match default elevation of each placeable
placeablesOne.fastForEach {
it.place(positionOne.x.toInt(), positionOne.y.toInt())
}
placeablesTwo.fastForEach {
it.place(positionTwo.x.toInt(), positionTwo.y.toInt())
}
}
}
}
/**
* A simple layout which lays the first placeable in a top bar position, the last placeable in a
* bottom bar position, and all the content in between.
*/
@Composable
fun ScaffoldedSubcomposeLayout(
modifier: Modifier = Modifier,
topBar: @Composable () -> Unit,
content: @Composable () -> Unit,
bottomBar: @Composable () -> Unit
) {
var yPosition = 0
SubcomposeLayout(modifier) { constraints ->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
layout(layoutWidth, layoutHeight) {
val topPlaceables = subcompose(ScaffoldedSlots.Top, topBar).fastMap {
it.measure(looseConstraints)
}
val contentPlaceables = subcompose(ScaffoldedSlots.Content, content).fastMap {
it.measure(looseConstraints)
}
val bottomPlaceables = subcompose(ScaffoldedSlots.Bottom, bottomBar).fastMap {
it.measure(looseConstraints)
}
topPlaceables.fastForEach {
it.place(0, yPosition)
yPosition += it.height
}
contentPlaceables.fastForEach {
it.place(0, yPosition)
yPosition += it.height
}
bottomPlaceables.fastForEach {
it.place(0, yPosition)
yPosition += it.height
}
}
}
}
private enum class TestSlot { First, Second }
private enum class ScaffoldedSlots { Top, Content, Bottom }
// TODO(b/272068594): Add api to fetch the semantics id from SemanticsNodeInteraction directly.
private val SemanticsNodeInteraction.semanticsId: Int get() = fetchSemanticsNode().id
// TODO(b/304359126): Move this to AccessibilityEventCompat and use it wherever we use obtain().
private fun AccessibilityEvent(): android.view.accessibility.AccessibilityEvent {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
android.view.accessibility.AccessibilityEvent()
} else {
@Suppress("DEPRECATION")
android.view.accessibility.AccessibilityEvent.obtain()
}.apply {
packageName = "androidx.compose.ui.test"
className = "android.view.View"
isEnabled = true
}
}