| /* |
| * 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.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 |
| import android.view.accessibility.AccessibilityNodeInfo |
| import android.view.accessibility.AccessibilityNodeInfo.ACTION_CLEAR_FOCUS |
| import android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK |
| import android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS |
| import android.view.accessibility.AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY |
| import android.view.accessibility.AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY |
| import android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_SELECTION |
| import android.view.accessibility.AccessibilityNodeProvider |
| import android.view.accessibility.AccessibilityRecord |
| import android.widget.Button |
| import android.widget.LinearLayout |
| import android.widget.TextView |
| import androidx.compose.foundation.ExperimentalFoundationApi |
| import androidx.compose.foundation.Image |
| import androidx.compose.foundation.ScrollState |
| import androidx.compose.foundation.clickable |
| import androidx.compose.foundation.gestures.Orientation |
| import androidx.compose.foundation.focusable |
| import androidx.compose.foundation.gestures.scrollBy |
| import androidx.compose.foundation.gestures.scrollable |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.Column |
| 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.LazyRow |
| import androidx.compose.foundation.lazy.LazyListState |
| 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.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.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.toAndroidRect |
| import androidx.compose.ui.graphics.graphicsLayer |
| 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.LocalDensity |
| import androidx.compose.ui.platform.LocalView |
| import androidx.compose.ui.platform.getAllUncoveredSemanticsNodesToMap |
| import androidx.compose.ui.platform.testTag |
| import androidx.compose.ui.semantics.Role |
| import androidx.compose.ui.semantics.SemanticsActions |
| import androidx.compose.ui.semantics.SemanticsNode |
| import androidx.compose.ui.semantics.SemanticsProperties |
| 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.paneTitle |
| import androidx.compose.ui.semantics.role |
| import androidx.compose.ui.semantics.semantics |
| import androidx.compose.ui.semantics.stateDescription |
| import androidx.compose.ui.semantics.textSelectionRange |
| import androidx.compose.ui.test.SemanticsMatcher |
| 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.isEnabled |
| import androidx.compose.ui.test.junit4.createAndroidComposeRule |
| 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.dp |
| import androidx.compose.ui.viewinterop.AndroidView |
| import androidx.compose.ui.window.Dialog |
| import androidx.core.view.ViewCompat |
| import androidx.core.view.accessibility.AccessibilityNodeInfoCompat |
| 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 com.nhaarman.mockitokotlin2.argThat |
| import com.nhaarman.mockitokotlin2.atLeastOnce |
| import com.nhaarman.mockitokotlin2.doReturn |
| import com.nhaarman.mockitokotlin2.eq |
| import com.nhaarman.mockitokotlin2.spy |
| import com.nhaarman.mockitokotlin2.times |
| import com.nhaarman.mockitokotlin2.verify |
| import kotlinx.coroutines.CoroutineScope |
| import kotlinx.coroutines.launch |
| import org.junit.Assert.assertEquals |
| import org.junit.Assert.assertFalse |
| import org.junit.Assert.assertNotEquals |
| import org.junit.Assert.assertNotNull |
| import org.junit.Assert.assertNull |
| import org.junit.Assert.assertTrue |
| 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.internal.matchers.apachecommons.ReflectionEquals |
| import java.lang.reflect.Method |
| |
| @LargeTest |
| @RunWith(AndroidJUnit4::class) |
| @OptIn(ExperimentalFoundationApi::class) |
| class AndroidAccessibilityTest { |
| @get:Rule |
| val rule = createAndroidComposeRule<TestActivity>() |
| |
| private lateinit var androidComposeView: AndroidComposeView |
| private lateinit var container: OpenComposeView |
| private lateinit var delegate: AndroidComposeViewAccessibilityDelegateCompat |
| private lateinit var provider: AccessibilityNodeProvider |
| |
| private val argument = ArgumentCaptor.forClass(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() { |
| val tag = "Toggleable" |
| container.setContent { |
| var checked by remember { mutableStateOf(true) } |
| Box( |
| Modifier |
| .toggleable(value = checked, onValueChange = { checked = it }) |
| .testTag(tag) |
| ) { |
| BasicText("ToggleableText") |
| } |
| } |
| |
| val toggleableNode = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(toggleableNode.id) |
| assertEquals("android.view.View", accessibilityNodeInfo.className) |
| assertTrue(accessibilityNodeInfo.isClickable) |
| assertTrue(accessibilityNodeInfo.isVisibleToUser) |
| assertTrue(accessibilityNodeInfo.isCheckable) |
| assertTrue(accessibilityNodeInfo.isChecked) |
| assertTrue( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction(ACTION_CLICK, "toggle") |
| ) |
| ) |
| } |
| |
| @Test |
| fun testCreateAccessibilityNodeInfo_forSwitch() { |
| val tag = "Toggleable" |
| container.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 accessibilityNodeInfo = provider.createAccessibilityNodeInfo(toggleableNode.id) |
| |
| // We temporary send Switch role as a separate fake node |
| val switchRoleNode = toggleableNode.replacedChildren.last() |
| val switchRoleNodeInfo = provider.createAccessibilityNodeInfo(switchRoleNode.id) |
| assertEquals("android.widget.Switch", switchRoleNodeInfo.className) |
| |
| val stateDescription = when { |
| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { |
| accessibilityNodeInfo.stateDescription |
| } |
| Build.VERSION.SDK_INT >= 19 -> { |
| accessibilityNodeInfo.extras.getCharSequence( |
| "androidx.view.accessibility.AccessibilityNodeInfoCompat.STATE_DESCRIPTION_KEY" |
| ) |
| } |
| else -> { |
| null |
| } |
| } |
| assertEquals("On", stateDescription) |
| assertTrue(accessibilityNodeInfo.isClickable) |
| assertTrue(accessibilityNodeInfo.isVisibleToUser) |
| assertTrue( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction(ACTION_CLICK, null) |
| ) |
| ) |
| } |
| |
| @Test |
| fun testCreateAccessibilityNodeInfo_forSelectable() { |
| val tag = "Selectable" |
| container.setContent { |
| Box(Modifier.selectable(selected = true, onClick = {}).testTag(tag)) { |
| BasicText("Text") |
| } |
| } |
| |
| val toggleableNode = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(toggleableNode.id) |
| assertEquals("android.view.View", accessibilityNodeInfo.className) |
| val stateDescription = when { |
| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { |
| accessibilityNodeInfo.stateDescription |
| } |
| Build.VERSION.SDK_INT >= 19 -> { |
| accessibilityNodeInfo.extras.getCharSequence( |
| "androidx.view.accessibility.AccessibilityNodeInfoCompat.STATE_DESCRIPTION_KEY" |
| ) |
| } |
| else -> { |
| null |
| } |
| } |
| assertEquals("Selected", stateDescription) |
| assertFalse(accessibilityNodeInfo.isClickable) |
| assertTrue(accessibilityNodeInfo.isVisibleToUser) |
| assertTrue(accessibilityNodeInfo.isCheckable) |
| assertFalse( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction(ACTION_CLICK, null) |
| ) |
| ) |
| } |
| |
| @Test |
| fun testCreateAccessibilityNodeInfo_forTab() { |
| val tag = "Selectable" |
| container.setContent { |
| Box(Modifier.selectable(selected = true, onClick = {}, role = Role.Tab).testTag(tag)) { |
| BasicText("Text") |
| } |
| } |
| |
| val toggleableNode = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(toggleableNode.id) |
| assertEquals("android.view.View", accessibilityNodeInfo.className) |
| val stateDescription = when { |
| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { |
| accessibilityNodeInfo.stateDescription |
| } |
| Build.VERSION.SDK_INT >= 19 -> { |
| accessibilityNodeInfo.extras.getCharSequence( |
| "androidx.view.accessibility.AccessibilityNodeInfoCompat.STATE_DESCRIPTION_KEY" |
| ) |
| } |
| else -> { |
| null |
| } |
| } |
| assertNull(stateDescription) |
| assertFalse(accessibilityNodeInfo.isClickable) |
| assertTrue(accessibilityNodeInfo.isVisibleToUser) |
| assertTrue(accessibilityNodeInfo.isSelected) |
| assertFalse( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction(ACTION_CLICK, null) |
| ) |
| ) |
| } |
| |
| @Test |
| fun testCreateAccessibilityNodeInfo_progressIndicator_determinate() { |
| val tag = "progress" |
| container.setContent { |
| Box(Modifier.progressSemantics(0.5f).testTag(tag)) { |
| BasicText("Text") |
| } |
| } |
| |
| val node = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(node.id) |
| assertEquals("android.widget.ProgressBar", accessibilityNodeInfo.className) |
| val stateDescription = when { |
| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { |
| accessibilityNodeInfo.stateDescription |
| } |
| Build.VERSION.SDK_INT >= 19 -> { |
| accessibilityNodeInfo.extras.getCharSequence( |
| "androidx.view.accessibility.AccessibilityNodeInfoCompat.STATE_DESCRIPTION_KEY" |
| ) |
| } |
| else -> { |
| null |
| } |
| } |
| assertEquals("50 percent.", stateDescription) |
| assertEquals( |
| AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_FLOAT, |
| accessibilityNodeInfo.rangeInfo.getType() |
| ) |
| assertEquals(0.5f, accessibilityNodeInfo.rangeInfo.getCurrent()) |
| assertEquals(0f, accessibilityNodeInfo.rangeInfo.getMin()) |
| assertEquals(1f, accessibilityNodeInfo.rangeInfo.getMax()) |
| } |
| |
| @Test |
| fun testCreateAccessibilityNodeInfo_progressIndicator_determinate_indeterminate() { |
| val tag = "progress" |
| container.setContent { |
| Box( |
| Modifier |
| .progressSemantics() |
| .testTag(tag) |
| ) { |
| BasicText("Text") |
| } |
| } |
| |
| val toggleableNode = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(toggleableNode.id) |
| assertEquals("android.widget.ProgressBar", accessibilityNodeInfo.className) |
| val stateDescription = when { |
| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { |
| accessibilityNodeInfo.stateDescription |
| } |
| Build.VERSION.SDK_INT >= 19 -> { |
| accessibilityNodeInfo.extras.getCharSequence( |
| "androidx.view.accessibility.AccessibilityNodeInfoCompat.STATE_DESCRIPTION_KEY" |
| ) |
| } |
| else -> { |
| null |
| } |
| } |
| assertEquals("In progress", stateDescription) |
| assertNull(accessibilityNodeInfo.rangeInfo) |
| } |
| |
| @Test |
| fun testCreateAccessibilityNodeInfo_forTextField() { |
| val tag = "TextField" |
| container.setContent { |
| var value by remember { mutableStateOf(TextFieldValue("hello")) } |
| BasicTextField( |
| modifier = Modifier.testTag(tag), |
| value = value, |
| onValueChange = { value = it } |
| ) |
| } |
| |
| val textFieldNode = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(textFieldNode.id) |
| |
| assertEquals("android.widget.EditText", accessibilityNodeInfo.className) |
| assertEquals("hello", accessibilityNodeInfo.text.toString()) |
| assertTrue(accessibilityNodeInfo.isFocusable) |
| assertFalse(accessibilityNodeInfo.isFocused) |
| assertTrue(accessibilityNodeInfo.isEditable) |
| assertTrue(accessibilityNodeInfo.isVisibleToUser) |
| assertTrue( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction(ACTION_CLICK, null) |
| ) |
| ) |
| assertTrue( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction(ACTION_SET_SELECTION, null) |
| ) |
| ) |
| assertTrue( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction(ACTION_NEXT_AT_MOVEMENT_GRANULARITY, null) |
| ) |
| ) |
| assertTrue( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction( |
| ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, null |
| ) |
| ) |
| ) |
| assertTrue( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction(ACTION_FOCUS, null) |
| ) |
| ) |
| assertFalse( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction(ACTION_CLEAR_FOCUS, null) |
| ) |
| ) |
| if (Build.VERSION.SDK_INT >= 26) { |
| assertEquals( |
| listOf(AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY), |
| accessibilityNodeInfo.availableExtraData |
| ) |
| } |
| } |
| |
| @Test |
| fun testCreateAccessibilityNodeInfo_forFocusable_notFocused() { |
| val tag = "node" |
| container.setContent { |
| Box(Modifier.testTag(tag).focusable()) { |
| BasicText("focusable") |
| } |
| } |
| |
| val focusableNode = rule.onNodeWithTag(tag) |
| .assert(SemanticsMatcher.expectValue(SemanticsProperties.Focused, false)) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(focusableNode.id) |
| assertTrue( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction(ACTION_FOCUS, null) |
| ) |
| ) |
| assertFalse( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction(ACTION_CLEAR_FOCUS, null) |
| ) |
| ) |
| accessibilityNodeInfo.recycle() |
| } |
| |
| @Test |
| fun testCreateAccessibilityNodeInfo_forFocusable_focused() { |
| val tag = "node" |
| val focusRequester = FocusRequester() |
| container.setContent { |
| Box(Modifier.testTag(tag).focusRequester(focusRequester).focusable()) { |
| BasicText("focusable") |
| } |
| } |
| rule.runOnIdle { focusRequester.requestFocus() } |
| |
| val focusableNode = rule.onNodeWithTag(tag) |
| .assert(SemanticsMatcher.expectValue(SemanticsProperties.Focused, true)) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(focusableNode.id) |
| assertFalse( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction(ACTION_FOCUS, null) |
| ) |
| ) |
| assertTrue( |
| accessibilityNodeInfo.actionList.contains( |
| AccessibilityNodeInfo.AccessibilityAction(ACTION_CLEAR_FOCUS, null) |
| ) |
| ) |
| accessibilityNodeInfo.recycle() |
| } |
| |
| @Test |
| fun testPerformAction_showOnScreen() { |
| rule.mainClock.autoAdvance = false |
| |
| val scrollState = ScrollState(initial = 0) |
| val target1Tag = "target1" |
| val target2Tag = "target2" |
| container.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())) |
| } |
| } |
| } |
| } |
| |
| waitForSubtreeEventToSend() |
| assertThat(scrollState.value).isEqualTo(0) |
| |
| val showOnScreen = android.R.id.accessibilityActionShowOnScreen |
| val targetNode1 = rule.onNodeWithTag(target1Tag) |
| .fetchSemanticsNode("couldn't find node with tag $target1Tag") |
| rule.runOnUiThread { |
| assertTrue(provider.performAction(targetNode1.id, showOnScreen, null)) |
| } |
| rule.mainClock.advanceTimeBy(5000) |
| assertThat(scrollState.value).isGreaterThan(99) |
| |
| val targetNode2 = rule.onNodeWithTag(target2Tag) |
| .fetchSemanticsNode("couldn't find node with tag $target2Tag") |
| rule.runOnUiThread { |
| assertTrue(provider.performAction(targetNode2.id, showOnScreen, null)) |
| } |
| 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" |
| container.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())) |
| } |
| } |
| } |
| } |
| } |
| |
| waitForSubtreeEventToSend() |
| assertThat(lazyState.firstVisibleItemScrollOffset).isEqualTo(0) |
| |
| val showOnScreen = android.R.id.accessibilityActionShowOnScreen |
| val targetNode1 = rule.onNodeWithTag(target1Tag) |
| .fetchSemanticsNode("couldn't find node with tag $target1Tag") |
| rule.runOnUiThread { |
| assertTrue(provider.performAction(targetNode1.id, showOnScreen, null)) |
| } |
| rule.mainClock.advanceTimeBy(5000) |
| assertThat(lazyState.firstVisibleItemIndex).isEqualTo(0) |
| assertThat(lazyState.firstVisibleItemScrollOffset).isGreaterThan(99) |
| |
| val targetNode2 = rule.onNodeWithTag(target2Tag) |
| .fetchSemanticsNode("couldn't find node with tag $target2Tag") |
| rule.runOnUiThread { |
| assertTrue(provider.performAction(targetNode2.id, showOnScreen, null)) |
| } |
| 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" |
| container.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()) |
| ) |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| waitForSubtreeEventToSend() |
| 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 targetNode1 = rule.onNodeWithTag(target1Tag) |
| .fetchSemanticsNode("couldn't find node with tag $target1Tag") |
| rule.runOnUiThread { |
| assertTrue(provider.performAction(targetNode1.id, showOnScreen, null)) |
| } |
| rule.mainClock.advanceTimeBy(5000) |
| assertThat(lazyState.firstVisibleItemIndex).isEqualTo(0) |
| assertThat(lazyState.firstVisibleItemScrollOffset).isGreaterThan(99) |
| assertThat(parentLazyState.firstVisibleItemScrollOffset).isEqualTo(0) |
| |
| val targetNode2 = rule.onNodeWithTag(target2Tag) |
| .fetchSemanticsNode("couldn't find node with tag $target2Tag") |
| rule.runOnUiThread { |
| assertTrue(provider.performAction(targetNode2.id, showOnScreen, null)) |
| } |
| rule.mainClock.advanceTimeBy(5000) |
| assertThat(lazyState.firstVisibleItemIndex).isEqualTo(0) |
| assertThat(lazyState.firstVisibleItemScrollOffset).isEqualTo(0) |
| assertThat(parentLazyState.firstVisibleItemScrollOffset).isEqualTo(0) |
| } |
| |
| @Test |
| fun testPerformAction_focus() { |
| val tag = "node" |
| container.setContent { |
| Box(Modifier.testTag(tag).focusable()) { |
| BasicText("focusable") |
| } |
| } |
| |
| val focusableNode = rule.onNodeWithTag(tag) |
| .assert(SemanticsMatcher.expectValue(SemanticsProperties.Focused, false)) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| |
| rule.runOnUiThread { |
| assertTrue(provider.performAction(focusableNode.id, ACTION_FOCUS, null)) |
| } |
| |
| rule.onNodeWithTag(tag) |
| .assert(SemanticsMatcher.expectValue(SemanticsProperties.Focused, true)) |
| } |
| |
| @Test |
| fun testPerformAction_clearFocus() { |
| val tag = "node" |
| val focusRequester = FocusRequester() |
| container.setContent { |
| Box(Modifier.testTag(tag).focusRequester(focusRequester).focusable()) { |
| BasicText("focusable") |
| } |
| } |
| rule.runOnIdle { focusRequester.requestFocus() } |
| |
| val focusableNode = rule.onNodeWithTag(tag) |
| .assert(SemanticsMatcher.expectValue(SemanticsProperties.Focused, true)) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| |
| rule.runOnUiThread { |
| assertTrue(provider.performAction(focusableNode.id, ACTION_CLEAR_FOCUS, null)) |
| } |
| |
| rule.onNodeWithTag(tag) |
| .assert(SemanticsMatcher.expectValue(SemanticsProperties.Focused, false)) |
| } |
| |
| @Test |
| fun testPerformAction_succeedOnEnabledNodes() { |
| val tag = "Toggleable" |
| container.setContent { |
| var checked by remember { mutableStateOf(true) } |
| Box( |
| Modifier |
| .toggleable(value = checked, onValueChange = { checked = it }) |
| .testTag(tag) |
| ) { |
| BasicText("ToggleableText") |
| } |
| } |
| |
| rule.onNodeWithTag(tag) |
| .assertIsDisplayed() |
| .assertIsOn() |
| |
| waitForSubtreeEventToSend() |
| val toggleableNode = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| rule.runOnUiThread { |
| assertTrue(provider.performAction(toggleableNode.id, ACTION_CLICK, null)) |
| } |
| rule.onNodeWithTag(tag) |
| .assertIsOff() |
| } |
| |
| @Test |
| fun testPerformAction_failOnDisabledNodes() { |
| val tag = "DisabledToggleable" |
| container.setContent { |
| var checked by remember { mutableStateOf(true) } |
| Box( |
| Modifier |
| .toggleable( |
| value = checked, |
| enabled = false, |
| onValueChange = { checked = it } |
| ) |
| .testTag(tag), |
| content = { |
| BasicText("ToggleableText") |
| } |
| ) |
| } |
| |
| rule.onNodeWithTag(tag) |
| .assertIsDisplayed() |
| .assertIsOn() |
| |
| waitForSubtreeEventToSend() |
| val toggleableNode = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| rule.runOnUiThread { |
| assertFalse(provider.performAction(toggleableNode.id, ACTION_CLICK, null)) |
| } |
| rule.onNodeWithTag(tag) |
| .assertIsOn() |
| } |
| |
| @Test |
| fun testTextField_performClickAction_succeedOnEnabledNode() { |
| val tag = "TextField" |
| container.setContent { |
| BasicTextField( |
| modifier = Modifier.testTag(tag), |
| value = "value", |
| onValueChange = {} |
| ) |
| } |
| |
| val textFieldNode = rule.onNodeWithTag(tag) |
| .assertIsDisplayed() |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| |
| rule.runOnUiThread { |
| assertTrue(provider.performAction(textFieldNode.id, ACTION_CLICK, null)) |
| } |
| |
| rule.onNodeWithTag(tag) |
| .assert(SemanticsMatcher.expectValue(SemanticsProperties.Focused, true)) |
| } |
| |
| @Test |
| fun testTextField_performSetSelectionAction_succeedOnEnabledNode() { |
| val tag = "TextField" |
| var textFieldSelectionOne = false |
| container.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 textFieldNode = rule.onNodeWithTag(tag) |
| .assertIsDisplayed() |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| val argument = Bundle() |
| argument.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, 1) |
| argument.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, 1) |
| |
| rule.runOnUiThread { |
| textFieldSelectionOne = false |
| assertTrue(provider.performAction(textFieldNode.id, ACTION_SET_SELECTION, argument)) |
| } |
| rule.waitUntil(5_000) { textFieldSelectionOne } |
| |
| rule.onNodeWithTag(tag) |
| .assert( |
| SemanticsMatcher.expectValue( |
| SemanticsProperties.TextSelectionRange, |
| TextRange(1) |
| ) |
| ) |
| } |
| |
| @Test |
| fun testTextField_testFocusClearFocusAction() { |
| val tag = "TextField" |
| container.setContent { |
| BasicTextField( |
| modifier = Modifier.testTag(tag), |
| value = "value", |
| onValueChange = {} |
| ) |
| } |
| |
| val textFieldNode = rule.onNodeWithTag(tag) |
| .assert(SemanticsMatcher.expectValue(SemanticsProperties.Focused, false)) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| |
| rule.runOnUiThread { |
| assertTrue(provider.performAction(textFieldNode.id, ACTION_FOCUS, null)) |
| } |
| |
| rule.onNodeWithTag(tag) |
| .assert(SemanticsMatcher.expectValue(SemanticsProperties.Focused, true)) |
| |
| rule.runOnUiThread { |
| assertTrue(provider.performAction(textFieldNode.id, ACTION_CLEAR_FOCUS, null)) |
| } |
| |
| rule.onNodeWithTag(tag) |
| .assert(SemanticsMatcher.expectValue(SemanticsProperties.Focused, false)) |
| } |
| |
| @Test |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| fun testAddExtraDataToAccessibilityNodeInfo_notMerged() { |
| val tag = "TextField" |
| lateinit var textLayoutResult: TextLayoutResult |
| |
| container.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") |
| val info = AccessibilityNodeInfo.obtain() |
| val argument = Bundle() |
| argument.putInt(AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX, 0) |
| argument.putInt(AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH, 1) |
| provider.addExtraDataToAccessibilityNodeInfo( |
| textFieldNode.id, |
| info, |
| AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY, |
| argument |
| ) |
| val data = info.extras |
| .getParcelableArray(AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY) |
| assertEquals(1, data!!.size) |
| |
| val rectF = data[0] as RectF // result in screen coordinates |
| val expectedRectInLocalCoords = textLayoutResult.getBoundingBox(0).translate( |
| textFieldNode.positionInWindow |
| ) |
| val expectedTopLeftInScreenCoords = androidComposeView.localToScreen( |
| expectedRectInLocalCoords.toAndroidRect().topLeftToOffset() |
| ) |
| assertEquals(expectedTopLeftInScreenCoords.x, rectF.left) |
| assertEquals(expectedTopLeftInScreenCoords.y, rectF.top) |
| assertEquals(expectedRectInLocalCoords.width, rectF.width()) |
| assertEquals(expectedRectInLocalCoords.height, rectF.height()) |
| } |
| |
| @Test |
| fun sendClickedEvent_whenClick() { |
| val tag = "Clickable" |
| container.setContent { |
| Box(Modifier.clickable(onClick = {}).testTag(tag)) { |
| BasicText("Text") |
| } |
| } |
| |
| waitForSubtreeEventToSend() |
| val node = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| rule.runOnUiThread { |
| assertTrue(provider.performAction(node.id, ACTION_CLICK, null)) |
| } |
| |
| rule.runOnIdle { |
| verify(container, times(1)).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == node.id && |
| it.eventType == AccessibilityEvent.TYPE_VIEW_CLICKED |
| } |
| ) |
| ) |
| } |
| } |
| |
| @Test |
| fun sendStateChangeEvent_whenStateChange() { |
| var state by mutableStateOf("state one") |
| val tag = "State" |
| container.setContent { |
| Box( |
| Modifier |
| .semantics { stateDescription = state } |
| .testTag(tag) |
| ) { |
| BasicText("Text") |
| } |
| } |
| |
| rule.onNodeWithTag(tag) |
| .assertValueEquals("state one") |
| |
| waitForSubtreeEventToSend() |
| state = "state two" |
| |
| val node = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| |
| rule.runOnIdle { |
| verify(container, times(1)).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == node.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == |
| AccessibilityEvent.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) == node.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == |
| AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED |
| } |
| ) |
| ) |
| } |
| } |
| |
| @Test |
| fun sendStateChangeEvent_whenClickToggleable() { |
| val tag = "Toggleable" |
| container.setContent { |
| var checked by remember { mutableStateOf(true) } |
| Box( |
| Modifier.toggleable( |
| value = checked, |
| onValueChange = { checked = it } |
| ).testTag(tag) |
| ) { |
| BasicText("ToggleableText") |
| } |
| } |
| |
| rule.onNodeWithTag(tag) |
| .assertIsDisplayed() |
| .assertIsOn() |
| |
| waitForSubtreeEventToSend() |
| rule.onNodeWithTag(tag) |
| .performClick() |
| .assertIsOff() |
| |
| val toggleableNode = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| |
| rule.runOnIdle { |
| verify(container, times(1)).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == toggleableNode.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == |
| AccessibilityEvent.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) == toggleableNode.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == |
| AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED |
| } |
| ) |
| ) |
| } |
| } |
| |
| @Test |
| fun sendStateChangeEvent_whenSelectedChange() { |
| val tag = "Selectable" |
| container.setContent { |
| var selected by remember { mutableStateOf(false) } |
| Box( |
| Modifier |
| .selectable(selected = selected, onClick = { selected = true }) |
| .testTag(tag) |
| ) { |
| BasicText("Text") |
| } |
| } |
| |
| rule.onNodeWithTag(tag) |
| .assertIsDisplayed() |
| .assertIsNotSelected() |
| |
| waitForSubtreeEventToSend() |
| rule.onNodeWithTag(tag) |
| .performClick() |
| .assertIsSelected() |
| |
| val node = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| rule.runOnIdle { |
| verify(container, times(1)).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == node.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == |
| AccessibilityEvent.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) == node.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == |
| AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED |
| } |
| ) |
| ) |
| } |
| } |
| |
| @Test |
| fun sendViewSelectedEvent_whenSelectedChange_forTab() { |
| val tag = "Tab" |
| container.setContent { |
| var selected by remember { mutableStateOf(false) } |
| Box( |
| Modifier |
| .selectable(selected = selected, onClick = { selected = true }, role = Role.Tab) |
| .testTag(tag) |
| ) { |
| BasicText("Text") |
| } |
| } |
| |
| rule.onNodeWithTag(tag) |
| .assertIsDisplayed() |
| .assertIsNotSelected() |
| |
| waitForSubtreeEventToSend() |
| rule.onNodeWithTag(tag) |
| .performClick() |
| .assertIsSelected() |
| |
| val node = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| rule.runOnIdle { |
| verify(container, times(1)).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == node.id && |
| it.eventType == AccessibilityEvent.TYPE_VIEW_SELECTED && |
| it.text.size == 1 && |
| it.text[0].toString() == "Text" |
| } |
| ) |
| ) |
| } |
| } |
| |
| @Test |
| fun sendStateChangeEvent_whenRangeInfoChange() { |
| val tag = "Progress" |
| var current by mutableStateOf(0.5f) |
| container.setContent { |
| Box(Modifier.progressSemantics(current).testTag(tag)) { |
| BasicText("Text") |
| } |
| } |
| waitForSubtreeEventToSend() |
| |
| current = 0.9f |
| |
| val node = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| rule.runOnIdle { |
| verify(container, times(1)).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == node.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == |
| AccessibilityEvent.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) == node.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == |
| AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED |
| } |
| ) |
| ) |
| } |
| } |
| |
| @Test |
| fun sendTextEvents_whenSetText() { |
| val locale = LocaleList("en_US") |
| val tag = "TextField" |
| val initialText = "h" |
| val text = "hello" |
| container.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( |
| SemanticsMatcher.expectValue( |
| SemanticsProperties.EditableText, |
| AnnotatedString("H") |
| ) |
| ) |
| |
| waitForSubtreeEventToSend() |
| rule.onNodeWithTag(tag) |
| .performSemanticsAction(SemanticsActions.SetText) { it(AnnotatedString(text)) } |
| .assert( |
| SemanticsMatcher.expectValue( |
| SemanticsProperties.EditableText, |
| AnnotatedString("HELLO") |
| ) |
| ) |
| |
| val textFieldNode = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| |
| val textEvent = delegate.createEvent( |
| textFieldNode.id, |
| AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED |
| ) |
| textEvent.fromIndex = initialText.length |
| textEvent.removedCount = 0 |
| textEvent.addedCount = text.length - initialText.length |
| textEvent.beforeText = initialText.toUpperCase(locale) |
| textEvent.text.add(text.toUpperCase(locale)) |
| |
| val selectionEvent = delegate.createEvent( |
| textFieldNode.id, |
| AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED |
| ) |
| selectionEvent.fromIndex = text.length |
| selectionEvent.toIndex = text.length |
| selectionEvent.itemCount = text.length |
| selectionEvent.text.add(text.toUpperCase(locale)) |
| |
| rule.runOnIdle { |
| verify(container, atLeastOnce()).requestSendAccessibilityEvent( |
| eq(androidComposeView), argument.capture() |
| ) |
| |
| val actualTextEvent = argument.allValues.first { |
| it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED |
| } |
| assertEquals(textEvent.toString(), actualTextEvent.toString()) |
| |
| val actualSelectionEvent = argument.allValues.first { |
| it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED |
| } |
| assertEquals(selectionEvent.toString(), actualSelectionEvent.toString()) |
| } |
| } |
| |
| @Test |
| @Ignore("b/177656801") |
| fun sendSubtreeChangeEvents_whenNodeRemoved() { |
| val columnTag = "topColumn" |
| val textFieldTag = "TextFieldTag" |
| var isTextFieldVisible by mutableStateOf(true) |
| |
| container.setContent { |
| Column(Modifier.testTag(columnTag)) { |
| if (isTextFieldVisible) { |
| BasicTextField( |
| modifier = Modifier.testTag(textFieldTag), |
| value = "text", |
| onValueChange = {} |
| ) |
| } |
| } |
| } |
| |
| val parentNode = rule.onNodeWithTag(columnTag) |
| .fetchSemanticsNode("couldn't find node with tag $columnTag") |
| rule.onNodeWithTag(textFieldTag) |
| .assertExists() |
| // wait for the subtree change events from initialization to send |
| waitForSubtreeEventToSendAndVerify { |
| verify(container, atLeastOnce()).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == parentNode.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE |
| } |
| ) |
| ) |
| } |
| |
| // TextField is removed compared to setup. |
| isTextFieldVisible = false |
| |
| rule.onNodeWithTag(textFieldTag) |
| .assertDoesNotExist() |
| waitForSubtreeEventToSendAndVerify { |
| verify(container, atLeastOnce()).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == parentNode.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE |
| } |
| ) |
| ) |
| } |
| } |
| |
| @Test |
| fun selectionEventBeforeTraverseEvent_whenTraverseTextField() { |
| val tag = "TextFieldTag" |
| val text = "h" |
| container.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 textFieldNode = rule.onNodeWithTag(tag) |
| .assertIsDisplayed() |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| waitForSubtreeEventToSend() |
| rule.runOnUiThread { |
| provider.performAction( |
| textFieldNode.id, |
| AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, |
| createMovementGranularityCharacterArgs() |
| ) |
| } |
| |
| val selectionEvent = createSelectionChangedFromIndexOneToOneEvent(textFieldNode) |
| val traverseEvent = createCharacterTraverseFromIndexZeroEvent(textFieldNode) |
| rule.runOnIdle { |
| verify(container, atLeastOnce()).requestSendAccessibilityEvent( |
| eq(androidComposeView), argument.capture() |
| ) |
| val values = argument.allValues |
| val traverseEventIndex = eventIndex(values, traverseEvent) |
| val selectionEventIndex = eventIndex(values, selectionEvent) |
| assertNotEquals(-1, traverseEventIndex) |
| assertNotEquals(-1, selectionEventIndex) |
| assertTrue(traverseEventIndex > selectionEventIndex) |
| } |
| } |
| |
| @Test |
| fun selectionEventBeforeTraverseEvent_whenTraverseText() { |
| val tag = "TextTag" |
| val text = "h" |
| container.setContent { |
| BasicText(text, Modifier.testTag(tag)) |
| } |
| |
| val textNode = rule.onNodeWithTag(tag) |
| .assertIsDisplayed() |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| waitForSubtreeEventToSend() |
| rule.runOnUiThread { |
| provider.performAction( |
| textNode.id, |
| AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, |
| createMovementGranularityCharacterArgs() |
| ) |
| } |
| |
| val selectionEvent = createSelectionChangedFromIndexOneToOneEvent(textNode) |
| val traverseEvent = createCharacterTraverseFromIndexZeroEvent(textNode) |
| rule.runOnIdle { |
| verify(container, atLeastOnce()).requestSendAccessibilityEvent( |
| eq(androidComposeView), argument.capture() |
| ) |
| val values = argument.allValues |
| val traverseEventIndex = eventIndex(values, traverseEvent) |
| val selectionEventIndex = eventIndex(values, selectionEvent) |
| assertNotEquals(-1, traverseEventIndex) |
| assertNotEquals(-1, selectionEventIndex) |
| assertTrue(traverseEventIndex > selectionEventIndex) |
| } |
| } |
| |
| @Test |
| @Ignore("b/177656801") |
| fun semanticsNodeBeingMergedLayoutChange_sendThrottledSubtreeEventsForMergedSemanticsNode() { |
| val tag = "Toggleable" |
| container.setContent { |
| var checked by remember { mutableStateOf(true) } |
| Box( |
| Modifier |
| .toggleable(value = checked, onValueChange = { checked = it }) |
| .testTag(tag) |
| ) { |
| BasicText("ToggleableText") |
| Box { |
| BasicText("TextNode") |
| } |
| } |
| } |
| |
| val toggleableNode = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| val textNode = rule.onNodeWithText("TextNode", useUnmergedTree = true) |
| .fetchSemanticsNode("couldn't find node with text TextNode") |
| // wait for the subtree change events from initialization to send |
| waitForSubtreeEventToSendAndVerify { |
| verify(container, atLeastOnce()).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == toggleableNode.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE |
| } |
| ) |
| ) |
| } |
| |
| rule.runOnUiThread { |
| // Directly call onLayoutChange because this guarantees short time. |
| for (i in 1..10) { |
| delegate.onLayoutChange(textNode.layoutNode) |
| } |
| } |
| |
| waitForSubtreeEventToSendAndVerify { |
| verify(container, atLeastOnce()).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == toggleableNode.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE |
| } |
| ) |
| ) |
| } |
| } |
| |
| @Test |
| @Ignore("b/177656801") |
| fun layoutNodeWithoutSemanticsLayoutChange_sendThrottledSubtreeEventsForMergedSemanticsNode() { |
| val tag = "Toggleable" |
| container.setContent { |
| var checked by remember { mutableStateOf(true) } |
| Box( |
| Modifier |
| .toggleable(value = checked, onValueChange = { checked = it }) |
| .testTag(tag) |
| ) { |
| BasicText("ToggleableText") |
| Box { |
| BasicText("TextNode") |
| } |
| } |
| } |
| |
| val toggleableNode = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| val textNode = rule.onNodeWithText("TextNode", useUnmergedTree = true) |
| .fetchSemanticsNode("couldn't find node with text TextNode") |
| // wait for the subtree change events from initialization to send |
| waitForSubtreeEventToSendAndVerify { |
| verify(container, atLeastOnce()).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == toggleableNode.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == AccessibilityEvent.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!!) |
| } |
| } |
| |
| waitForSubtreeEventToSendAndVerify { |
| // One from initialization and one from layout changes. |
| verify(container, atLeastOnce()).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == toggleableNode.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && |
| it.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE |
| } |
| ) |
| ) |
| } |
| } |
| |
| @Test |
| fun testSemanticsHitTest() { |
| val tag = "Toggleable" |
| container.setContent { |
| var checked by remember { mutableStateOf(true) } |
| Box( |
| Modifier |
| .toggleable(value = checked, onValueChange = { checked = it }) |
| .testTag(tag) |
| ) { |
| BasicText("ToggleableText") |
| } |
| } |
| |
| val toggleableNode = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("couldn't find node with tag $tag") |
| val toggleableNodeBounds = toggleableNode.boundsInRoot |
| |
| val toggleableNodeId = delegate.hitTestSemanticsAt( |
| (toggleableNodeBounds.left + toggleableNodeBounds.right) / 2, |
| (toggleableNodeBounds.top + toggleableNodeBounds.bottom) / 2, |
| ) |
| assertEquals(toggleableNode.id, toggleableNodeId) |
| } |
| |
| @Test |
| fun testSemanticsHitTest_overlappedChildren() { |
| val childOneTag = "OverlappedChildOne" |
| val childTwoTag = "OverlappedChildTwo" |
| container.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 overlappedChildOneNode = rule.onNodeWithTag(childOneTag) |
| .fetchSemanticsNode("couldn't find node with tag $childOneTag") |
| val overlappedChildTwoNode = rule.onNodeWithTag(childTwoTag) |
| .fetchSemanticsNode("couldn't find node with tag $childTwoTag") |
| val overlappedChildNodeBounds = overlappedChildTwoNode.boundsInRoot |
| val overlappedChildNodeId = delegate.hitTestSemanticsAt( |
| (overlappedChildNodeBounds.left + overlappedChildNodeBounds.right) / 2, |
| (overlappedChildNodeBounds.top + overlappedChildNodeBounds.bottom) / 2 |
| ) |
| assertEquals(overlappedChildOneNode.id, overlappedChildNodeId) |
| assertNotEquals(overlappedChildTwoNode.id, overlappedChildNodeId) |
| } |
| |
| @Test |
| fun testSemanticsHitTest_scrolled() { |
| val scrollState = ScrollState(initial = 0) |
| val targetTag = "target" |
| var scope: CoroutineScope? = null |
| container.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(targetTag).size(200.toDp())) |
| } |
| } |
| } |
| } |
| |
| waitForSubtreeEventToSend() |
| assertThat(scrollState.value).isEqualTo(0) |
| |
| scope!!.launch { |
| // Scroll to the bottom |
| scrollState.scrollBy(10000f) |
| } |
| rule.waitForIdle() |
| |
| assertThat(scrollState.value).isGreaterThan(199) |
| |
| val childNode = rule.onNodeWithTag(targetTag) |
| .fetchSemanticsNode("couldn't find node with tag $targetTag") |
| val childNodeBounds = childNode.boundsInRoot |
| val hitTestedId = delegate.hitTestSemanticsAt( |
| (childNodeBounds.left + childNodeBounds.right) / 2, |
| (childNodeBounds.top + childNodeBounds.bottom) / 2 |
| ) |
| assertEquals(childNode.id, hitTestedId) |
| } |
| |
| @OptIn(ExperimentalComposeUiApi::class) |
| @Test |
| fun testSemanticsHitTest_invisibleToUserSemantics() { |
| val tag = "box" |
| container.setContent { |
| Box(Modifier.size(100.dp).clickable {}.testTag(tag).semantics { invisibleToUser() }) { |
| BasicText("") |
| } |
| } |
| |
| val node = rule.onNodeWithTag(tag).fetchSemanticsNode("") |
| val bounds = node.boundsInRoot |
| |
| val hitNodeId = delegate.hitTestSemanticsAt( |
| bounds.left + bounds.width / 2, |
| bounds.top + bounds.height / 2 |
| ) |
| assertEquals(InvalidId, hitNodeId) |
| } |
| |
| @Test |
| fun testSemanticsHitTest_transparentNode() { |
| val tag = "box" |
| container.setContent { |
| Box(Modifier.alpha(0f).size(100.dp).clickable {}.testTag(tag)) { |
| BasicText("") |
| } |
| } |
| |
| val node = rule.onNodeWithTag(tag).fetchSemanticsNode("") |
| val bounds = node.boundsInRoot |
| |
| val hitNodeId = delegate.hitTestSemanticsAt( |
| bounds.left + bounds.width / 2, |
| bounds.top + bounds.height / 2 |
| ) |
| assertEquals(InvalidId, hitNodeId) |
| } |
| |
| @Test |
| @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.P) |
| fun testViewInterop_findViewByAccessibilityId() { |
| val androidViewTag = "androidView" |
| container.setContent { |
| Column { |
| AndroidView( |
| { context -> |
| LinearLayout(context).apply { |
| addView(TextView(context).apply { text = "Text1" }) |
| addView(TextView(context).apply { text = "Text2" }) |
| } |
| }, |
| Modifier.testTag(androidViewTag) |
| ) |
| 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(androidViewTag) |
| .fetchSemanticsNode("can't find node with tag $androidViewTag") |
| 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 |
| ) |
| assertNotNull(foundView) |
| assertEquals(textTwo, foundView) |
| } |
| |
| @Test |
| fun testViewInterop_viewChildExists() { |
| val colTag = "ColTag" |
| val buttonText = "button text" |
| container.setContent { |
| Column(Modifier.testTag(colTag)) { |
| AndroidView(::Button) { |
| it.text = buttonText |
| it.setOnClickListener {} |
| } |
| BasicText("text") |
| } |
| } |
| |
| val colSemanticsNode = rule.onNodeWithTag(colTag) |
| .fetchSemanticsNode("can't find node with tag $colTag") |
| val colAccessibilityNode = provider.createAccessibilityNodeInfo(colSemanticsNode.id) |
| assertEquals(2, colAccessibilityNode.childCount) |
| assertEquals(2, colSemanticsNode.replacedChildren.size) |
| val buttonHolder = androidComposeView.androidViewsHandler |
| .layoutNodeToHolder[colSemanticsNode.replacedChildren[0].layoutNode] |
| assertNotNull(buttonHolder) |
| assertEquals( |
| ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES, |
| buttonHolder!!.importantForAccessibility |
| ) |
| assertEquals(buttonText, (buttonHolder.getChildAt(0) as Button).text) |
| } |
| |
| @Test |
| fun testViewInterop_hoverEnterExit() { |
| val colTag = "ColTag" |
| val textTag = "TextTag" |
| val buttonText = "button text" |
| container.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 |
| ) |
| assertTrue(androidComposeView.dispatchHoverEvent(hoverEnter)) |
| assertEquals( |
| AndroidComposeViewAccessibilityDelegateCompat.InvalidId, |
| delegate.hoveredVirtualViewId |
| ) |
| } |
| rule.runOnIdle { |
| verify(container, times(1)).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| it.eventType == AccessibilityEvent.TYPE_VIEW_HOVER_ENTER |
| } |
| ) |
| ) |
| } |
| |
| val textNode = rule.onNodeWithTag(textTag) |
| .fetchSemanticsNode("can't find node with tag $textTag") |
| rule.runOnUiThread { |
| val bounds = textNode.boundsInRoot |
| val hoverEnter = createHoverMotionEvent( |
| action = ACTION_HOVER_MOVE, |
| x = (bounds.left + bounds.right) / 2, |
| y = (bounds.top + bounds.bottom) / 2 |
| ) |
| assertTrue(androidComposeView.dispatchHoverEvent(hoverEnter)) |
| assertEquals( |
| textNode.id, |
| delegate.hoveredVirtualViewId |
| ) |
| } |
| // verify hover exit accessibility event is sent from the previously hovered view |
| rule.runOnIdle { |
| verify(container, times(1)).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| it.eventType == AccessibilityEvent.TYPE_VIEW_HOVER_EXIT |
| } |
| ) |
| ) |
| } |
| } |
| |
| 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() { |
| val parentTag = "ParentForOverlappedChildren" |
| val childOneTag = "OverlappedChildOne" |
| val childTwoTag = "OverlappedChildTwo" |
| container.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 parentNode = rule.onNodeWithTag(parentTag) |
| .fetchSemanticsNode("couldn't find node with tag $parentTag") |
| val overlappedChildOneNode = rule.onNodeWithTag(childOneTag) |
| .fetchSemanticsNode("couldn't find node with tag $childOneTag") |
| val overlappedChildTwoNode = rule.onNodeWithTag(childTwoTag) |
| .fetchSemanticsNode("couldn't find node with tag $childTwoTag") |
| assertEquals(1, provider.createAccessibilityNodeInfo(parentNode.id).childCount) |
| assertEquals( |
| "Child One", |
| provider.createAccessibilityNodeInfo(overlappedChildOneNode.id).text.toString() |
| ) |
| assertNull(provider.createAccessibilityNodeInfo(overlappedChildTwoNode.id)) |
| } |
| |
| @Test |
| fun testAccessibilityNodeInfoTreePruned_partiallyCovered() { |
| val parentTag = "parent" |
| val density = Density(2f) |
| container.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 parentNode = rule.onNodeWithTag(parentTag) |
| .fetchSemanticsNode("couldn't find node with tag $parentTag") |
| assertEquals(2, provider.createAccessibilityNodeInfo(parentNode.id).childCount) |
| |
| val childTwoNode = rule.onNodeWithText("Child Two") |
| .fetchSemanticsNode("couldn't find node with text Child Two") |
| val childTwoBounds = Rect() |
| provider.createAccessibilityNodeInfo(childTwoNode.id) |
| .getBoundsInScreen(childTwoBounds) |
| assertEquals(100, childTwoBounds.height()) |
| assertEquals(100, childTwoBounds.width()) |
| } |
| |
| @Test |
| fun testPaneAppear() { |
| val paneTag = "Pane" |
| var isPaneVisible by mutableStateOf(false) |
| val paneTestTitle by mutableStateOf("pane title") |
| |
| container.setContent { |
| if (isPaneVisible) { |
| Box( |
| Modifier |
| .testTag(paneTag) |
| .semantics { paneTitle = paneTestTitle } |
| ) {} |
| } |
| } |
| |
| rule.onNodeWithTag(paneTag).assertDoesNotExist() |
| |
| isPaneVisible = true |
| rule.onNodeWithTag(paneTag) |
| .assert( |
| SemanticsMatcher.expectValue( |
| SemanticsProperties.PaneTitle, |
| "pane title" |
| ) |
| ) |
| .assertIsDisplayed() |
| waitForSubtreeEventToSend() |
| val paneNode = rule.onNodeWithTag(paneTag).fetchSemanticsNode() |
| rule.runOnIdle { |
| verify(container, times(1)).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == paneNode.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && |
| it.contentChangeTypes == |
| AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_APPEARED |
| } |
| ) |
| ) |
| } |
| } |
| |
| @Test |
| fun testPaneTitleChange() { |
| val paneTag = "Pane" |
| var isPaneVisible by mutableStateOf(false) |
| var paneTestTitle by mutableStateOf("pane title") |
| |
| container.setContent { |
| if (isPaneVisible) { |
| Box( |
| Modifier |
| .testTag(paneTag) |
| .semantics { paneTitle = paneTestTitle } |
| ) {} |
| } |
| } |
| |
| rule.onNodeWithTag(paneTag).assertDoesNotExist() |
| |
| isPaneVisible = true |
| rule.onNodeWithTag(paneTag) |
| .assert( |
| SemanticsMatcher.expectValue( |
| SemanticsProperties.PaneTitle, |
| "pane title" |
| ) |
| ) |
| .assertIsDisplayed() |
| waitForSubtreeEventToSend() |
| |
| paneTestTitle = "new pane title" |
| rule.onNodeWithTag(paneTag) |
| .assert( |
| SemanticsMatcher.expectValue( |
| SemanticsProperties.PaneTitle, |
| "new pane title" |
| ) |
| ) |
| val paneNode = rule.onNodeWithTag(paneTag).fetchSemanticsNode() |
| rule.runOnIdle { |
| verify(container, times(1)).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| getAccessibilityEventSourceSemanticsNodeId(it) == paneNode.id && |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && |
| it.contentChangeTypes == |
| AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_TITLE |
| } |
| ) |
| ) |
| } |
| } |
| |
| @Test |
| fun testPaneDisappear() { |
| val paneTag = "Pane" |
| var isPaneVisible by mutableStateOf(false) |
| val paneTestTitle by mutableStateOf("pane title") |
| |
| container.setContent { |
| if (isPaneVisible) { |
| Box(Modifier.testTag(paneTag).semantics { paneTitle = paneTestTitle }) {} |
| } |
| } |
| |
| rule.onNodeWithTag(paneTag).assertDoesNotExist() |
| |
| isPaneVisible = true |
| rule.onNodeWithTag(paneTag) |
| .assert( |
| SemanticsMatcher.expectValue( |
| SemanticsProperties.PaneTitle, |
| "pane title" |
| ) |
| ) |
| .assertIsDisplayed() |
| waitForSubtreeEventToSend() |
| |
| isPaneVisible = false |
| rule.onNodeWithTag(paneTag).assertDoesNotExist() |
| rule.runOnIdle { |
| verify(container, times(1)).requestSendAccessibilityEvent( |
| eq(androidComposeView), |
| argThat( |
| ArgumentMatcher { |
| it.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && |
| it.contentChangeTypes == |
| AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_DISAPPEARED |
| } |
| ) |
| ) |
| } |
| } |
| |
| @Test |
| fun testEventForPasswordTextField() { |
| val tag = "TextField" |
| container.setContent { |
| BasicTextField( |
| modifier = Modifier.testTag(tag), |
| value = "value", |
| onValueChange = {}, |
| visualTransformation = PasswordVisualTransformation() |
| ) |
| } |
| |
| val textFieldNode = rule.onNodeWithTag(tag) |
| .fetchSemanticsNode("Couldn't fetch node with tag $tag") |
| val event = delegate.createEvent( |
| textFieldNode.id, |
| AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED |
| ) |
| |
| assertTrue(event.isPassword) |
| } |
| |
| @Test |
| fun testLayerParamChange_setCorrectBounds_syntaxOne() { |
| var scale by mutableStateOf(1f) |
| container.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 node = rule.onNodeWithTag("node").fetchSemanticsNode() |
| var info: AccessibilityNodeInfo = AccessibilityNodeInfo.obtain() |
| rule.runOnUiThread { |
| info = provider.createAccessibilityNodeInfo(node.id) |
| } |
| val rect = Rect() |
| info.getBoundsInScreen(rect) |
| assertEquals(300, rect.width()) |
| assertEquals(300, rect.height()) |
| |
| scale = 0.5f |
| info.recycle() |
| rule.runOnIdle { |
| info = provider.createAccessibilityNodeInfo(node.id) |
| } |
| info.getBoundsInScreen(rect) |
| assertEquals(150, rect.width()) |
| assertEquals(150, rect.height()) |
| } |
| |
| @Test |
| fun testLayerParamChange_setCorrectBounds_syntaxTwo() { |
| var scale by mutableStateOf(1f) |
| container.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 node = rule.onNodeWithTag("node").fetchSemanticsNode() |
| var info: AccessibilityNodeInfo = AccessibilityNodeInfo.obtain() |
| rule.runOnUiThread { |
| info = provider.createAccessibilityNodeInfo(node.id) |
| } |
| val rect = Rect() |
| info.getBoundsInScreen(rect) |
| assertEquals(300, rect.width()) |
| assertEquals(300, rect.height()) |
| |
| scale = 0.5f |
| info.recycle() |
| rule.runOnIdle { |
| info = provider.createAccessibilityNodeInfo(node.id) |
| } |
| info.getBoundsInScreen(rect) |
| assertEquals(150, rect.width()) |
| assertEquals(150, rect.height()) |
| } |
| |
| @Test |
| fun testDialog_setCorrectBounds() { |
| var dialogComposeView: AndroidComposeView? = null |
| container.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 textNode = rule.onNodeWithText("text").fetchSemanticsNode() |
| var info: AccessibilityNodeInfo = AccessibilityNodeInfo.obtain() |
| rule.runOnUiThread { |
| info = provider.createAccessibilityNodeInfo(textNode.id) |
| } |
| |
| 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) |
| assertEquals( |
| Rect( |
| textPositionOnScreenX, |
| textPositionOnScreenY, |
| textPositionOnScreenX + size, |
| textPositionOnScreenY + size |
| ), |
| textRect |
| ) |
| } |
| |
| @Test |
| fun testContentDescription_notMergingDescendants_withOwnContentDescription() { |
| val tag = "Column" |
| container.setContent { |
| Column(Modifier.semantics { contentDescription = "Column" }.testTag(tag)) { |
| with(LocalDensity.current) { |
| BasicText("Text") |
| Box(Modifier.size(100.toDp()).semantics { contentDescription = "Box" }) |
| } |
| } |
| } |
| |
| val node = rule.onNodeWithTag(tag).fetchSemanticsNode() |
| val info = provider.createAccessibilityNodeInfo(node.id) |
| |
| assertEquals("Column", info.contentDescription) |
| } |
| |
| @Test |
| fun testContentDescription_notMergingDescendants_withoutOwnContentDescription() { |
| val tag = "Column" |
| container.setContent { |
| Column(Modifier.semantics {}.testTag(tag)) { |
| BasicText("Text") |
| with(LocalDensity.current) { |
| Box(Modifier.size(100.toDp()).semantics { contentDescription = "Box" }) |
| } |
| } |
| } |
| |
| val node = rule.onNodeWithTag(tag).fetchSemanticsNode() |
| val info = provider.createAccessibilityNodeInfo(node.id) |
| |
| assertEquals(null, info.contentDescription) |
| } |
| |
| @Test |
| fun testContentDescription_singleNode_notMergingDescendants() { |
| val tag = "box" |
| container.setContent { |
| with(LocalDensity.current) { |
| with(LocalDensity.current) { |
| Box( |
| Modifier.size(100.toDp()) |
| .testTag(tag) |
| .semantics { contentDescription = "Box" } |
| ) |
| } |
| } |
| } |
| |
| val node = rule.onNodeWithTag(tag).fetchSemanticsNode() |
| val info = provider.createAccessibilityNodeInfo(node.id) |
| |
| assertEquals("Box", info.contentDescription) |
| } |
| |
| @Test |
| fun testContentDescription_singleNode_mergingDescendants() { |
| val tag = "box" |
| container.setContent { |
| with(LocalDensity.current) { |
| Box( |
| Modifier.size(100.toDp()).testTag(tag) |
| .semantics(true) { contentDescription = "Box" } |
| ) |
| } |
| } |
| |
| val node = rule.onNodeWithTag(tag).fetchSemanticsNode() |
| val info = provider.createAccessibilityNodeInfo(node.id) |
| |
| assertEquals("Box", info.contentDescription) |
| } |
| |
| @Test |
| fun testContentDescription_replacingSemanticsNode() { |
| val tag = "box" |
| container.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 node = rule.onNodeWithTag(tag).fetchSemanticsNode() |
| val info = provider.createAccessibilityNodeInfo(node.id) |
| |
| assertEquals("Replacing description", info.contentDescription) |
| } |
| |
| @Test |
| fun testRole_doesNotMerge() { |
| container.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 node = rule.onNodeWithTag("Row").fetchSemanticsNode() |
| val info = provider.createAccessibilityNodeInfo(node.id) |
| |
| assertEquals(AndroidComposeViewAccessibilityDelegateCompat.ClassName, info.className) |
| } |
| |
| @Test |
| fun testReportedBounds_clickableNode_includesPadding(): Unit = with(rule.density) { |
| val size = 100.dp.roundToPx() |
| container.setContent { |
| with(LocalDensity.current) { |
| Column { |
| Box( |
| Modifier |
| .testTag("tag") |
| .clickable {} |
| .size(size.toDp()) |
| .padding(10.toDp()) |
| .semantics { |
| contentDescription = "Button" |
| } |
| ) |
| } |
| } |
| } |
| |
| val node = rule.onNodeWithTag("tag").fetchSemanticsNode() |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(node.id) |
| |
| val rect = android.graphics.Rect() |
| accessibilityNodeInfo.getBoundsInScreen(rect) |
| val resultWidth = rect.right - rect.left |
| val resultHeight = rect.bottom - rect.top |
| |
| assertEquals(size, resultWidth) |
| assertEquals(size, resultHeight) |
| } |
| |
| @Test |
| fun testReportedBounds_clickableNode_excludesPadding(): Unit = with(rule.density) { |
| val size = 100.dp.roundToPx() |
| val density = Density(2f) |
| container.setContent { |
| CompositionLocalProvider(LocalDensity provides density) { |
| Column { |
| with(density) { |
| Box( |
| Modifier |
| .testTag("tag") |
| .semantics { contentDescription = "Test" } |
| .size(size.toDp()) |
| .padding(10.toDp()) |
| .clickable {} |
| ) |
| } |
| } |
| } |
| } |
| |
| val node = rule.onNodeWithTag("tag").fetchSemanticsNode() |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(node.id) |
| |
| val rect = android.graphics.Rect() |
| accessibilityNodeInfo.getBoundsInScreen(rect) |
| val resultWidth = rect.right - rect.left |
| val resultHeight = rect.bottom - rect.top |
| |
| assertEquals(size - 20, resultWidth) |
| assertEquals(size - 20, resultHeight) |
| } |
| |
| @Test |
| fun testReportedBounds_withClearAndSetSemantics() { |
| val size = 100 |
| container.setContent { |
| with(LocalDensity.current) { |
| Column { |
| Box( |
| Modifier |
| .testTag("tag") |
| .size(size.toDp()) |
| .padding(10.toDp()) |
| .clearAndSetSemantics {} |
| .clickable {} |
| ) |
| } |
| } |
| } |
| |
| val node = rule.onNodeWithTag("tag").fetchSemanticsNode() |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(node.id) |
| |
| val rect = android.graphics.Rect() |
| accessibilityNodeInfo.getBoundsInScreen(rect) |
| val resultWidth = rect.right - rect.left |
| val resultHeight = rect.bottom - rect.top |
| |
| assertEquals(size, resultWidth) |
| assertEquals(size, resultHeight) |
| } |
| |
| @Test |
| fun testReportedBounds_withTwoClickable_outermostWins(): Unit = with(rule.density) { |
| val size = 100.dp.roundToPx() |
| container.setContent { |
| with(LocalDensity.current) { |
| Column { |
| Box( |
| Modifier |
| .testTag("tag") |
| .clickable {} |
| .size(size.toDp()) |
| .padding(10.toDp()) |
| .clickable {} |
| ) |
| } |
| } |
| } |
| |
| val node = rule.onNodeWithTag("tag").fetchSemanticsNode() |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(node.id) |
| |
| val rect = android.graphics.Rect() |
| accessibilityNodeInfo.getBoundsInScreen(rect) |
| val resultWidth = rect.right - rect.left |
| val resultHeight = rect.bottom - rect.top |
| |
| assertEquals(size, resultWidth) |
| assertEquals(size, resultHeight) |
| } |
| |
| @Test |
| fun testReportedBounds_outerMostSemanticsUsed() { |
| val size = 100 |
| container.setContent { |
| with(LocalDensity.current) { |
| Column { |
| Box( |
| Modifier |
| .testTag("tag") |
| .semantics { contentDescription = "Test1" } |
| .size(size.toDp()) |
| .padding(10.toDp()) |
| .semantics { contentDescription = "Test2" } |
| ) |
| } |
| } |
| } |
| |
| val node = rule.onNodeWithTag("tag").fetchSemanticsNode() |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(node.id) |
| |
| val rect = android.graphics.Rect() |
| accessibilityNodeInfo.getBoundsInScreen(rect) |
| val resultWidth = rect.right - rect.left |
| val resultHeight = rect.bottom - rect.top |
| |
| assertEquals(size, resultWidth) |
| assertEquals(size, resultHeight) |
| } |
| |
| @Test |
| fun testReportedBounds_withOffset() { |
| val size = 100 |
| val offset = 10 |
| val density = Density(1f) |
| container.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 node = rule.onNodeWithTag("tag").fetchSemanticsNode() |
| val accessibilityNodeInfo = provider.createAccessibilityNodeInfo(node.id) |
| |
| val rect = android.graphics.Rect() |
| accessibilityNodeInfo.getBoundsInScreen(rect) |
| val resultWidth = rect.right - rect.left |
| val resultHeight = rect.bottom - rect.top |
| val resultInLocalCoords = androidComposeView.screenToLocal(rect.topLeftToOffset()) |
| |
| assertEquals(size, resultWidth) |
| assertEquals(size, resultHeight) |
| assertEquals(10f, resultInLocalCoords.x, 0.001f) |
| assertEquals(10f, resultInLocalCoords.y, 0.001f) |
| } |
| |
| @Test |
| fun testSemanticsNodePositionAndBounds_doesNotThrow_whenLayoutNodeNotAttached() { |
| var emitNode by mutableStateOf(true) |
| rule.setContent { |
| if (emitNode) { |
| with(LocalDensity.current) { |
| Box(Modifier.size(100.toDp()).testTag("tag")) |
| } |
| } |
| } |
| |
| val semanticNode = rule.onNodeWithTag("tag").fetchSemanticsNode() |
| rule.runOnIdle { |
| emitNode = false |
| } |
| |
| rule.runOnIdle { |
| assertEquals(Offset.Zero, semanticNode.positionInRoot) |
| assertEquals(Offset.Zero, semanticNode.positionInWindow) |
| assertEquals(androidx.compose.ui.geometry.Rect.Zero, semanticNode.boundsInRoot) |
| assertEquals(androidx.compose.ui.geometry.Rect.Zero, semanticNode.boundsInWindow) |
| } |
| } |
| |
| @Test |
| fun testSemanticsSort_doesNotThrow_whenLayoutNodeWrapperNotAttached() { |
| rule.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() |
| |
| rule.runOnIdle { |
| child.layoutNode.innerLayoutNodeWrapper.detach() |
| child.outerSemanticsNodeWrapper.detach() |
| } |
| |
| rule.runOnIdle { |
| assertEquals(1, parent.unmergedChildren(true).size) |
| assertEquals(0, child.unmergedChildren(true).size) |
| } |
| } |
| |
| @Test |
| fun testSemanticsSort_doesNotThrow_whenLayoutNodeWrapperNotAttached_compare() { |
| rule.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() |
| rule.runOnIdle { |
| grandChild1.layoutNode.innerLayoutNodeWrapper.detach() |
| grandChild1.outerSemanticsNodeWrapper.detach() |
| grandChild2.layoutNode.innerLayoutNodeWrapper.detach() |
| grandChild2.outerSemanticsNodeWrapper.detach() |
| } |
| |
| rule.runOnIdle { |
| assertEquals(2, parent.unmergedChildren(true).size) |
| } |
| } |
| |
| @Test |
| fun testFakeNodeCreated_forContentDescriptionSemantics() { |
| container.setContent { |
| Column( |
| Modifier |
| .semantics(true) { contentDescription = "Test" } |
| .testTag("Column") |
| ) { |
| BasicText("Text") |
| with(LocalDensity.current) { |
| Box(Modifier.size(100.toDp()).semantics { contentDescription = "Hello" }) |
| } |
| } |
| } |
| |
| val columnNode = rule.onNodeWithTag("Column", true).fetchSemanticsNode() |
| val firstChild = columnNode.replacedChildren.firstOrNull() |
| assertNotNull(firstChild) |
| assertTrue(firstChild!!.isFake) |
| assertEquals( |
| firstChild.unmergedConfig.getOrNull(SemanticsProperties.ContentDescription)!!.first(), |
| "Test" |
| ) |
| } |
| |
| @Test |
| fun testFakeNode_createdForButton() { |
| container.setContent { |
| Column(Modifier.clickable(role = Role.Button) {}.testTag("button")) { |
| BasicText("Text") |
| } |
| } |
| |
| val buttonNode = rule.onNodeWithTag("button", true).fetchSemanticsNode() |
| val lastChild = buttonNode.replacedChildren.lastOrNull() |
| assertNotNull("Button has no children", lastChild) |
| assertTrue("Last child should be fake Button role node", lastChild!!.isFake) |
| assertEquals( |
| lastChild.unmergedConfig.getOrNull(SemanticsProperties.Role), |
| Role.Button |
| ) |
| } |
| |
| @Test |
| fun testFakeNode_notCreatedForButton_whenNoChildren() { |
| container.setContent { |
| with(LocalDensity.current) { |
| Box(Modifier.size(100.toDp()).clickable(role = Role.Button) {}.testTag("button")) |
| } |
| } |
| val buttonNode = rule.onNodeWithTag("button").fetchSemanticsNode() |
| assertFalse(buttonNode.unmergedChildren().any { it.isFake }) |
| val info = provider.createAccessibilityNodeInfo(buttonNode.id) |
| assertEquals("android.widget.Button", info.className) |
| } |
| |
| @Test |
| fun testFakeNode_reportParentBoundsAsFakeNodeBounds() { |
| val density = Density(2f) |
| val tag = "button" |
| container.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() |
| provider.createAccessibilityNodeInfo(parentNode.id).getBoundsInScreen(parentBounds) |
| |
| // Button role fake node |
| val fakeRoleNode = parentNode.unmergedChildren(includeFakeNodes = true).last() |
| val fakeRoleNodeBounds = Rect() |
| provider.createAccessibilityNodeInfo(fakeRoleNode.id).getBoundsInScreen(fakeRoleNodeBounds) |
| |
| assertEquals(parentBounds, fakeRoleNodeBounds) |
| } |
| |
| @Test |
| fun testContentDescription_withFakeNode_mergedCorrectly() { |
| val testTag = "Column" |
| container.setContent { |
| Column( |
| Modifier |
| .testTag(testTag) |
| .semantics(true) { contentDescription = "Hello" } |
| ) { |
| Box(Modifier.semantics { contentDescription = "World" }) |
| } |
| } |
| |
| rule.onNodeWithTag(testTag).assertContentDescriptionEquals("Hello", "World") |
| } |
| |
| @Test |
| fun testImageRole_notSet_whenAncestorMergesDescendants() { |
| container.setContent { |
| Column(Modifier.semantics(true) { }) { |
| Image(ImageBitmap(100, 100), "Image", Modifier.testTag("image")) |
| } |
| } |
| |
| val imageNode = rule.onNodeWithTag("image", true).fetchSemanticsNode() |
| val imageInfo = provider.createAccessibilityNodeInfo(imageNode.id) |
| assertEquals(ClassName, imageInfo.className) |
| } |
| |
| @Test |
| fun testImageRole_set_whenAncestorDoesNotMerge() { |
| container.setContent { |
| Column(Modifier.semantics { isEnabled() }) { |
| Image(ImageBitmap(100, 100), "Image", Modifier.testTag("image")) |
| } |
| } |
| |
| val imageNode = rule.onNodeWithTag("image", true).fetchSemanticsNode() |
| val imageInfo = provider.createAccessibilityNodeInfo(imageNode.id) |
| assertEquals("android.widget.ImageView", imageInfo.className) |
| } |
| |
| @Test |
| fun testImageRole_set_whenImageItseldMergesDescendants() { |
| container.setContent { |
| Column(Modifier.semantics(true) {}) { |
| Image( |
| ImageBitmap(100, 100), |
| "Image", |
| Modifier.testTag("image").semantics(true) { /* imitate clickable node */ } |
| ) |
| } |
| } |
| |
| val imageNode = rule.onNodeWithTag("image", true).fetchSemanticsNode() |
| val imageInfo = provider.createAccessibilityNodeInfo(imageNode.id) |
| assertEquals("android.widget.ImageView", imageInfo.className) |
| } |
| |
| @Test |
| fun testScrollableContainer_scrollViewClassNotSet_whenCollectionInfo() { |
| val tagColumn = "lazy column" |
| val tagRow = "scrollable row" |
| container.setContent { |
| LazyColumn(Modifier.testTag(tagColumn)) { |
| item { |
| Row( |
| Modifier |
| .testTag(tagRow) |
| .scrollable(rememberScrollState(), Orientation.Horizontal) |
| ) { |
| BasicText("test") |
| } |
| } |
| } |
| } |
| |
| val columnNode = rule.onNodeWithTag(tagColumn).fetchSemanticsNode() |
| val columnInfo = provider.createAccessibilityNodeInfo(columnNode.id) |
| assertNotEquals("android.widget.ScrollView", columnInfo.className) |
| |
| val rowNode = rule.onNodeWithTag(tagRow).fetchSemanticsNode() |
| val rowInfo = provider.createAccessibilityNodeInfo(rowNode.id) |
| assertNotEquals("android.widget.HorizontalScrollView", rowInfo.className) |
| } |
| |
| @Test |
| fun testTransparentNode_withAlphaModifier_notAccessible() { |
| container.setContent { |
| Column(Modifier.testTag("tag")) { |
| val modifier = Modifier.size(100.dp) |
| Box(Modifier.alpha(0f)) { |
| Box(modifier.semantics { contentDescription = "test" }) |
| } |
| Box(Modifier.alpha(0f).then(modifier).semantics { contentDescription = "test" }) |
| Box(Modifier.alpha(0f).semantics { contentDescription = "test" }.then(modifier)) |
| Box(modifier.alpha(0f).semantics { contentDescription = "test" }) |
| Box( |
| Modifier |
| .size(100.dp) |
| .alpha(0f) |
| .shadow(2.dp) |
| .semantics { contentDescription = "test" } |
| ) |
| } |
| } |
| |
| rule.onNodeWithTag("tag").fetchSemanticsNode() |
| |
| val nodesWithContentDescr = androidComposeView.semanticsOwner |
| .getAllUncoveredSemanticsNodesToMap() |
| .filter { |
| it.value.semanticsNode.config.contains(SemanticsProperties.ContentDescription) |
| } |
| assertEquals(nodesWithContentDescr.size, 5) |
| nodesWithContentDescr.forEach { |
| val node = it.value.semanticsNode |
| val info = provider.createAccessibilityNodeInfo(node.id) |
| assertEquals(false, info.isVisibleToUser) |
| } |
| } |
| |
| @Test |
| fun testVisibleNode_withAlphaModifier_accessible() { |
| container.setContent { |
| Column(Modifier.testTag("tag")) { |
| val modifier = Modifier.size(100.dp) |
| Box(Modifier.semantics { contentDescription = "test" }.then(modifier).alpha(0f)) |
| Box(Modifier.semantics { contentDescription = "test" }.alpha(0f).then(modifier)) |
| Box(modifier.semantics { contentDescription = "test" }.alpha(0f)) |
| } |
| } |
| |
| rule.onNodeWithTag("tag").fetchSemanticsNode() |
| |
| val nodesWithContentDescr = androidComposeView.semanticsOwner |
| .getAllUncoveredSemanticsNodesToMap() |
| .filter { |
| it.value.semanticsNode.config.contains(SemanticsProperties.ContentDescription) |
| } |
| |
| assertEquals(nodesWithContentDescr.size, 3) |
| nodesWithContentDescr.forEach { |
| val node = it.value.semanticsNode |
| val info = provider.createAccessibilityNodeInfo(node.id) |
| assertEquals(true, info.isVisibleToUser) |
| } |
| } |
| |
| private fun eventIndex(list: List<AccessibilityEvent>, event: AccessibilityEvent): Int { |
| for (i in list.indices) { |
| if (ReflectionEquals(list[i], null).matches(event)) { |
| return i |
| } |
| } |
| return -1 |
| } |
| |
| private fun containsEvent(list: List<AccessibilityEvent>, event: AccessibilityEvent): Boolean { |
| return eventIndex(list, event) != -1 |
| } |
| |
| private fun getAccessibilityEventSourceSemanticsNodeId(event: AccessibilityEvent): Int { |
| val getSourceNodeIdMethod = AccessibilityRecord::class.java |
| .getDeclaredMethod("getSourceNodeId") |
| getSourceNodeIdMethod.isAccessible = true |
| return (getSourceNodeIdMethod.invoke(event) as Long shr 32).toInt() |
| } |
| |
| private fun waitForSubtreeEventToSendAndVerify(verify: () -> Unit) { |
| // TODO(aelias): Make this wait after the 100ms delay to check the second batch is also correct |
| rule.waitForIdle() |
| verify() |
| } |
| |
| private fun waitForSubtreeEventToSend() { |
| // When the subtree events are sent, we will also update our previousSemanticsNodes, |
| // which will affect our next accessibility events from semantics tree comparison. |
| rule.mainClock.advanceTimeBy(5000) |
| rule.waitForIdle() |
| } |
| |
| private fun createMovementGranularityCharacterArgs(): Bundle { |
| return Bundle().apply { |
| this.putInt( |
| AccessibilityNodeInfoCompat.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, |
| AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER |
| ) |
| this.putBoolean( |
| AccessibilityNodeInfoCompat.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, |
| false |
| ) |
| } |
| } |
| |
| private fun createSelectionChangedFromIndexOneToOneEvent( |
| textNode: SemanticsNode |
| ): AccessibilityEvent { |
| return delegate.createEvent( |
| textNode.id, |
| AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED |
| ).apply { |
| this.fromIndex = 1 |
| this.toIndex = 1 |
| getTraversedText(textNode)?.let { |
| this.itemCount = it.length |
| this.text.add(it) |
| } |
| } |
| } |
| |
| private fun createCharacterTraverseFromIndexZeroEvent( |
| textNode: SemanticsNode |
| ): AccessibilityEvent { |
| return delegate.createEvent( |
| textNode.id, |
| AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY |
| ).apply { |
| this.fromIndex = 0 |
| this.toIndex = 1 |
| this.action = AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY |
| this.movementGranularity = AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER |
| getTraversedText(textNode)?.let { this.text.add(it) } |
| } |
| } |
| |
| private fun getTraversedText(textNode: SemanticsNode): String? { |
| return ( |
| textNode.config.getOrNull(SemanticsProperties.EditableText)?.text |
| ?: textNode.config.getOrNull(SemanticsProperties.Text)?.joinToString(",") |
| ) |
| } |
| } |
| |
| private fun Rect.topLeftToOffset() = Offset(this.left.toFloat(), this.top.toFloat()) |