diff --git a/sample-app/src/androidTest/java/com/atiurin/sampleapp/pages/ComposeListPage.kt b/sample-app/src/androidTest/java/com/atiurin/sampleapp/pages/ComposeListPage.kt index 7e9e6a0c..59fba3a1 100644 --- a/sample-app/src/androidTest/java/com/atiurin/sampleapp/pages/ComposeListPage.kt +++ b/sample-app/src/androidTest/java/com/atiurin/sampleapp/pages/ComposeListPage.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.test.hasAnyDescendant import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText +import com.atiurin.sampleapp.compose.ListItemPositionPropertyKey import com.atiurin.sampleapp.compose.contactNameTestTag import com.atiurin.sampleapp.compose.contactStatusTestTag import com.atiurin.sampleapp.compose.contactsListContentDesc @@ -14,16 +15,23 @@ import com.atiurin.ultron.core.compose.list.composeList import com.atiurin.ultron.page.Page object ComposeListPage : Page() { - val lazyList = composeList(hasContentDescription(contactsListContentDesc)) + val lazyList = composeList( + listMatcher = hasContentDescription(contactsListContentDesc), + positionPropertyKey = ListItemPositionPropertyKey + ) fun assertContactStatus(contact: Contact) = apply { - getContactItemById(contact).status.assertTextEquals(contact.status) + getContactItemByTestTag(contact).status.assertTextEquals(contact.status) } + fun getItemByPosition(position: Int): ComposeFriendListItem { + return lazyList.getItem(position) + } + fun getFirstVisibleItem(): ComposeFriendListItem = lazyList.getFirstVisibleItem() fun getItemByIndex(index: Int): ComposeFriendListItem = lazyList.getVisibleItem(index) - fun getContactItemById(contact: Contact): ComposeFriendListItem = lazyList.getItem(hasTestTag(getContactItemTestTagById(contact))) + fun getContactItemByTestTag(contact: Contact): ComposeFriendListItem = lazyList.getItem(hasTestTag(getContactItemTestTagById(contact))) fun getContactItemByName(contact: Contact): ComposeFriendListItem = lazyList.getItem(hasAnyDescendant(hasText(contact.name) and hasTestTag(contactNameTestTag))) - class ComposeFriendListItem : UltronComposeListItem(){ + class ComposeFriendListItem : UltronComposeListItem() { val name by lazy { getChild(hasTestTag(contactNameTestTag)) } val status by lazy { getChild(hasTestTag(contactStatusTestTag)) } } diff --git a/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/compose/ComposeListTest.kt b/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/compose/ComposeListTest.kt index 1352ed76..8dbfdbce 100644 --- a/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/compose/ComposeListTest.kt +++ b/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/compose/ComposeListTest.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.test.* import com.atiurin.sampleapp.activity.ComposeListActivity import com.atiurin.sampleapp.compose.* import com.atiurin.sampleapp.data.repositories.CONTACTS +import com.atiurin.sampleapp.data.repositories.ContactRepositoty import com.atiurin.sampleapp.framework.utils.AssertUtils import com.atiurin.sampleapp.pages.ComposeListPage import com.atiurin.sampleapp.pages.ComposeSecondPage @@ -27,10 +28,11 @@ import org.junit.Test class ComposeListTest: BaseTest() { @get:Rule val composeRule = createUltronComposeRule() - val listWithMergedTree = composeList(hasTestTag(contactsListTestTag), false) - val listPage = ComposeListPage - val notExistedList = composeList(hasTestTag("askjhsalk jdhas dlqk ")) - val emptyListTestTag = "emptyList" + + private val listWithMergedTree = composeList(hasTestTag(contactsListTestTag), false) + private val listPage = ComposeListPage + private val notExistedList = composeList(hasTestTag("askjhsalk jdhas dlqk ")) + private val emptyListTestTag = "emptyList" @Test fun item_existItem() { @@ -131,7 +133,17 @@ class ComposeListTest: BaseTest() { fun getItem_ByTestTag_assertNameAndStatusOfContact() { val index = 20 val contact = CONTACTS[index] - listPage.getContactItemById(contact).apply { + listPage.getContactItemByTestTag(contact).apply { + name.assertTextEquals(contact.name) + status.assertTextContains(contact.status) + } + } + + @Test + fun getItem_ByMatcher_assertNameAndStatusOfContact() { + val index = 20 + val contact = CONTACTS[index] + listPage.getContactItemByName(contact).apply { name.assertTextEquals(contact.name) status.assertTextContains(contact.status) } @@ -254,6 +266,38 @@ class ComposeListTest: BaseTest() { AssertUtils.assertException { listWithMergedTree.withTimeout(1000).assertVisibleItemsCount(100) } } + @Test + fun itemByPosition_propertyConfiguredTest(){ + val index = 20 + val contact = CONTACTS[index] + val item = listPage.lazyList.item(20).assertIsDisplayed() + item.assertMatches(hasTestTag(getContactItemTestTagById(contact))) + } + + @Test + fun getItemByPosition_propertyConfiguredTest(){ + val index = 20 + val contact = CONTACTS[index] + listPage.getItemByPosition(index).apply { + name.assertTextEquals(contact.name) + status.assertTextEquals(contact.status) + assertIsDisplayed() + } + } + + @Test + fun assertItemDoesNotExistWithSearch_NotExistedItem(){ + listWithMergedTree.assertItemDoesNotExist(hasText("NOT EXISTED TeXT")) + } + + @Test + fun assertItemDoesNotExistWithSearch_ExistedItem(){ + val contact = ContactRepositoty.getLast() + AssertUtils.assertException { + listWithMergedTree.withTimeout(2000).assertItemDoesNotExist(hasText(contact.name)) + } + } + private fun setEmptyListContent() { composeRule.setContent { LazyColumn( diff --git a/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/compose/ComposeListWithPositionTestTagTest.kt b/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/compose/ComposeListWithPositionTestTagTest.kt index 9db80e77..ea0d23b3 100644 --- a/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/compose/ComposeListWithPositionTestTagTest.kt +++ b/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/compose/ComposeListWithPositionTestTagTest.kt @@ -3,9 +3,12 @@ package com.atiurin.sampleapp.tests.compose import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasTestTag import com.atiurin.sampleapp.activity.ComposeListWithPositionTestTagActivity +import com.atiurin.sampleapp.compose.ListItemPositionPropertyKey import com.atiurin.sampleapp.compose.contactsListContentDesc import com.atiurin.sampleapp.compose.getContactItemTestTagByPosition import com.atiurin.sampleapp.data.repositories.CONTACTS +import com.atiurin.sampleapp.framework.utils.AssertUtils +import com.atiurin.sampleapp.pages.ComposeListPage import com.atiurin.ultron.core.compose.createUltronComposeRule import com.atiurin.ultron.core.compose.list.composeList import org.junit.Rule @@ -15,6 +18,7 @@ class ComposeListWithPositionTestTagTest { @get:Rule val composeRule = createUltronComposeRule() val list = composeList(hasContentDescription(contactsListContentDesc), false) + val composeListWithProperty = composeList(hasContentDescription(contactsListContentDesc), false, ListItemPositionPropertyKey) @Test fun itemOutOfVisibleScope() { @@ -34,4 +38,29 @@ class ComposeListWithPositionTestTagTest { .assertTextContains(contact.name) .assertTextContains(contact.status) } + @Test + fun itemByPosition_propertyNOTConfiguredInTest(){ + AssertUtils.assertException { + list.item(20).assertIsDisplayed() + } + } + + @Test + fun itemByPosition_propertyNOTConfiguredInApplication(){ + AssertUtils.assertException { + composeListWithProperty.withTimeout(1000).item(20).assertIsDisplayed() + } + } + + @Test + fun getItemByPosition_propertyNOTConfiguredInTest(){ + AssertUtils.assertException { list.getItem(20).assertIsDisplayed() } + } + + @Test + fun getItemByPosition_propertyNOTConfiguredInApplication(){ + AssertUtils.assertException { + composeListWithProperty.withTimeout(1000).getItem(20).assertIsDisplayed() + } + } } \ No newline at end of file diff --git a/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/uiautomator/UltronUiObject2ActionsTest.kt b/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/uiautomator/UltronUiObject2ActionsTest.kt index 6ec089f1..73f9c974 100644 --- a/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/uiautomator/UltronUiObject2ActionsTest.kt +++ b/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/uiautomator/UltronUiObject2ActionsTest.kt @@ -350,7 +350,6 @@ class UltronUiObject2ActionsTest : UiElementsTest() { @FlakyTest @Test fun swipeUpTest() { - page.eventStatus.hasText(getTargetString(R.string.button_text)) page.editTextContentDesc.replaceText("some text") page.swipableImageView.withAssertion { page.eventStatus.withTimeout(300).textContains(UiElementsActivity.Event.SWIPE_UP.name) @@ -361,8 +360,6 @@ class UltronUiObject2ActionsTest : UiElementsTest() { @FlakyTest @Test fun swipeDownTest() { -// Thread.sleep(2000) - page.eventStatus.hasText(getTargetString(R.string.button_text)) page.editTextContentDesc.replaceText("some text") page.swipableImageView.withAssertion { page.eventStatus.withTimeout(300).textContains(UiElementsActivity.Event.SWIPE_DOWN.name) @@ -372,7 +369,6 @@ class UltronUiObject2ActionsTest : UiElementsTest() { @Test @FlakyTest fun swipeRightTest() { - page.eventStatus.hasText(getTargetString(R.string.button_text)) page.editTextContentDesc.replaceText("some text") page.swipableImageView.withAssertion { page.eventStatus.withTimeout(300) @@ -383,7 +379,6 @@ class UltronUiObject2ActionsTest : UiElementsTest() { @Test @FlakyTest fun swipeLeftTest() { - page.eventStatus.hasText(getTargetString(R.string.button_text)) page.editTextContentDesc.replaceText("some text") page.swipableImageView.withAssertion { page.eventStatus.withTimeout(300).textContains(UiElementsActivity.Event.SWIPE_LEFT.name) diff --git a/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/uiautomator/UltronUiObjectActionsTest.kt b/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/uiautomator/UltronUiObjectActionsTest.kt index 001a980b..adf71c0a 100644 --- a/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/uiautomator/UltronUiObjectActionsTest.kt +++ b/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/uiautomator/UltronUiObjectActionsTest.kt @@ -144,8 +144,9 @@ class UltronUiObjectActionsTest: UiElementsTest() { //longClick @Test fun longClick_onLongClickable() { - page.button.exists().longClick() - page.eventStatus.textContains(getTargetString(R.string.button_event_long_click)) + page.button.exists().withAssertion { + page.eventStatus.textContains(getTargetString(R.string.button_event_long_click)) + }.longClick() } @Test @@ -199,7 +200,6 @@ class UltronUiObjectActionsTest: UiElementsTest() { //swipe @Test fun swipeUpTest(){ - page.eventStatus.hasText(getTargetString(R.string.button_text)) page.swipableImageView.withAssertion { page.eventStatus.withTimeout(300).textContains(UiElementsActivity.Event.SWIPE_UP.name) }.swipeUp(40) @@ -216,7 +216,6 @@ class UltronUiObjectActionsTest: UiElementsTest() { @Test fun swipeRightTest(){ - page.eventStatus.hasText(getTargetString(R.string.button_text)) page.swipableImageView.withAssertion { page.eventStatus.withTimeout(300).textContains(UiElementsActivity.Event.SWIPE_RIGHT.name) }.swipeRight(40) @@ -224,7 +223,6 @@ class UltronUiObjectActionsTest: UiElementsTest() { @Test fun swipeLeftTest(){ - page.eventStatus.hasText(getTargetString(R.string.button_text)) page.swipableImageView.withAssertion { page.eventStatus.withTimeout(300).textContains(UiElementsActivity.Event.SWIPE_LEFT.name) }.swipeLeft(40) diff --git a/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/uiautomator/UltronUiObjectAssertionsTest.kt b/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/uiautomator/UltronUiObjectAssertionsTest.kt index adedac52..537ca63b 100644 --- a/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/uiautomator/UltronUiObjectAssertionsTest.kt +++ b/sample-app/src/androidTest/java/com/atiurin/sampleapp/tests/uiautomator/UltronUiObjectAssertionsTest.kt @@ -309,8 +309,9 @@ class UltronUiObjectAssertionsTest: UiElementsTest() { //isNotFocusable @Test fun isNotFocusable_ofNotFocusable(){ - page.checkBoxFocusable.click() - page.button.isNotFocusable() + page.checkBoxFocusable.withAssertion { + page.button.isNotFocusable() + }.click() } @Test diff --git a/sample-app/src/main/java/com/atiurin/sampleapp/activity/ComposeListActivity.kt b/sample-app/src/main/java/com/atiurin/sampleapp/activity/ComposeListActivity.kt index c356ee8a..7608a1d4 100644 --- a/sample-app/src/main/java/com/atiurin/sampleapp/activity/ComposeListActivity.kt +++ b/sample-app/src/main/java/com/atiurin/sampleapp/activity/ComposeListActivity.kt @@ -1,38 +1,25 @@ package com.atiurin.sampleapp.activity -import android.content.Context -import android.content.Intent import android.os.Bundle import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.Divider +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.* import androidx.compose.ui.unit.ExperimentalUnitApi -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.TextUnitType -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat import androidx.lifecycle.Observer import com.atiurin.sampleapp.async.GetContacts import com.atiurin.sampleapp.async.UseCase import com.atiurin.sampleapp.compose.ContactsList import com.atiurin.sampleapp.compose.LoadingAnimation import com.atiurin.sampleapp.compose.getContactItemTestTagById +import com.atiurin.sampleapp.compose.listItemPosition import com.atiurin.sampleapp.data.entities.Contact import com.atiurin.sampleapp.data.repositories.ContactRepositoty import com.atiurin.sampleapp.data.viewmodel.ContactsViewModel @@ -60,8 +47,11 @@ class ComposeListActivity : ComponentActivity() { setContent { Column { ContactsList( - contacts = ContactRepositoty.all(), this@ComposeListActivity - ) { contact, _ -> getContactItemTestTagById(contact) } + contacts = ContactRepositoty.all(), + context = this@ComposeListActivity, + testTagProvider = { contact, _ -> getContactItemTestTagById(contact) }, + modifierProvider = { position -> Modifier.listItemPosition(position) } + ) } } } diff --git a/sample-app/src/main/java/com/atiurin/sampleapp/activity/ComposeListWithPositionTestTagActivity.kt b/sample-app/src/main/java/com/atiurin/sampleapp/activity/ComposeListWithPositionTestTagActivity.kt index 50b0fa38..ede40ff8 100644 --- a/sample-app/src/main/java/com/atiurin/sampleapp/activity/ComposeListWithPositionTestTagActivity.kt +++ b/sample-app/src/main/java/com/atiurin/sampleapp/activity/ComposeListWithPositionTestTagActivity.kt @@ -18,7 +18,9 @@ import com.atiurin.sampleapp.async.GetContacts import com.atiurin.sampleapp.async.UseCase import com.atiurin.sampleapp.compose.ContactsList import com.atiurin.sampleapp.compose.LoadingAnimation +import com.atiurin.sampleapp.compose.getContactItemTestTagById import com.atiurin.sampleapp.compose.getContactItemTestTagByPosition +import com.atiurin.sampleapp.compose.listItemPosition import com.atiurin.sampleapp.data.entities.Contact import com.atiurin.sampleapp.data.repositories.ContactRepositoty import com.atiurin.sampleapp.data.viewmodel.ContactsViewModel @@ -46,14 +48,18 @@ class ComposeListWithPositionTestTagActivity: ComponentActivity() { setContent { Column { ContactsList( - contacts = ContactRepositoty.all(), this@ComposeListWithPositionTestTagActivity, false - ) { _, position -> getContactItemTestTagByPosition(position) } + contacts = ContactRepositoty.all(), + context = this@ComposeListWithPositionTestTagActivity, + addStickyHeader = false, + testTagProvider = { _, position -> getContactItemTestTagByPosition(position) }, + modifierProvider = { _ -> Modifier } + ) } } } model.contacts.observe(this, contactsObserver) GlobalScope.async { - GetContacts()( + GetContacts(0)( UseCase.None, onSuccess = { model.contacts.value = it }, onFailure = { Toast.makeText(this@ComposeListWithPositionTestTagActivity, "Failed to load contacts", Toast.LENGTH_LONG).show() } diff --git a/sample-app/src/main/java/com/atiurin/sampleapp/compose/ContacsList.kt b/sample-app/src/main/java/com/atiurin/sampleapp/compose/ContacsList.kt index d86c3283..d2ab19e6 100644 --- a/sample-app/src/main/java/com/atiurin/sampleapp/compose/ContacsList.kt +++ b/sample-app/src/main/java/com/atiurin/sampleapp/compose/ContacsList.kt @@ -6,9 +6,15 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Divider @@ -18,13 +24,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.modifier.modifierLocalOf import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.Role.Companion.Image +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag @@ -41,11 +46,18 @@ const val contactNameTestTag = "nameTestTag" const val contactStatusTestTag = "statusTestTag" const val contactsListContentDesc = "contacts list" const val contactsListTestTag = "contactsListTestTag" + @ExperimentalMaterialApi @ExperimentalUnitApi @OptIn(ExperimentalFoundationApi::class) @Composable -fun ContactsList(contacts: List, context: Context, addStickyHeader: Boolean = true, testTagProvider: (Contact, Int) -> String) { +fun ContactsList( + contacts: List, + context: Context, + addStickyHeader: Boolean = true, + testTagProvider: (Contact, Int) -> String, + modifierProvider: (Int) -> Modifier, +) { var selectedItem = remember { mutableStateOf("") } @@ -58,14 +70,14 @@ fun ContactsList(contacts: List, context: Context, addStickyHeader: Boo testTag = contactsListTestTag } ) { - if (addStickyHeader){ - stickyHeader (key = "header"){ + if (addStickyHeader) { + stickyHeader(key = "header") { Text(text = "Lazy column header", modifier = Modifier.semantics { testTag = contactsListHeaderTag }) } } itemsIndexed(contacts, key = { _, c -> c.name }) { index, contact -> Column( - modifier = Modifier + modifier = modifierProvider.invoke(index) .then(Modifier.clickable { selectedItem.value = contact.name val intent = Intent(context, ComposeSecondActivity::class.java) @@ -103,4 +115,11 @@ fun ContactsList(contacts: List, context: Context, addStickyHeader: Boo } fun getContactItemTestTagById(contact: Contact) = "contactId=${contact.id}" -fun getContactItemTestTagByPosition(position: Int) = "position=$position" \ No newline at end of file +fun getContactItemTestTagByPosition(position: Int) = "position=$position" + +// configure position matching for lazy list +val ListItemPositionPropertyKey = SemanticsPropertyKey("ListItemPosition") +var SemanticsPropertyReceiver.listItemPosition by ListItemPositionPropertyKey +fun Modifier.listItemPosition(position: Int): Modifier { + return semantics { listItemPosition = position } +} diff --git a/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/config/UltronComposeConfig.kt b/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/config/UltronComposeConfig.kt index a6f4d55a..8855b9ce 100644 --- a/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/config/UltronComposeConfig.kt +++ b/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/config/UltronComposeConfig.kt @@ -1,10 +1,17 @@ package com.atiurin.ultron.core.compose.config +import com.atiurin.ultron.core.common.Operation +import com.atiurin.ultron.core.common.OperationResult +import com.atiurin.ultron.core.common.OperationResultAnalyzer +import com.atiurin.ultron.core.common.UltronDefaultOperationResultAnalyzer import com.atiurin.ultron.core.compose.operation.ComposeOperationResult +import com.atiurin.ultron.core.compose.operation.ComposeOperationType import com.atiurin.ultron.core.compose.operation.UltronComposeOperation -import com.atiurin.ultron.core.common.* import com.atiurin.ultron.core.compose.operation.UltronComposeOperationLifecycle -import com.atiurin.ultron.exceptions.* +import com.atiurin.ultron.core.config.UltronConfig +import com.atiurin.ultron.exceptions.UltronAssertionException +import com.atiurin.ultron.exceptions.UltronException +import com.atiurin.ultron.exceptions.UltronWrapperException import com.atiurin.ultron.listeners.LogLifecycleListener import com.atiurin.ultron.listeners.UltronLifecycleListener import com.atiurin.ultron.log.UltronLog @@ -17,10 +24,13 @@ object UltronComposeConfig { @Deprecated("Use [UltronComposeConfig.params.operationPollingTimeoutMs]") var COMPOSE_OPERATION_POLLING_TIMEOUT = params.operationPollingTimeoutMs + @Deprecated("Use [UltronComposeConfig.params.lazyColumnOperationTimeoutMs]") var LAZY_COLUMN_OPERATIONS_TIMEOUT = params.lazyColumnOperationTimeoutMs + @Deprecated("Use [UltronComposeConfig.params.lazyColumnItemSearchLimit]") var LAZY_COLUMN_ITEM_SEARCH_LIMIT = params.lazyColumnItemSearchLimit + @Deprecated("Use [UltronComposeConfig.params.operationTimeoutMs]") var OPERATION_TIMEOUT = params.operationTimeoutMs @@ -47,17 +57,18 @@ object UltronComposeConfig { UltronException::class.java, ) - fun addListener(listener: UltronLifecycleListener){ + fun addListener(listener: UltronLifecycleListener) { UltronLog.info("Add UltronComposeOperationLifecycle listener ${listener.javaClass.simpleName}") UltronComposeOperationLifecycle.addListener(listener) } private fun modify() { addListener(LogLifecycleListener()) + UltronConfig.operationsExcludedFromListeners.addAll(listOf(ComposeOperationType.GET_LIST_ITEM, ComposeOperationType.GET_LIST_ITEM_CHILD)) UltronLog.info("UltronComposeConfig applied with params $params}") } - fun applyRecommended(){ + fun applyRecommended() { params = UltronComposeConfigParams() modify() } diff --git a/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/list/PositionComposeItemExecutor.kt b/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/list/PositionComposeItemExecutor.kt new file mode 100644 index 00000000..cc981399 --- /dev/null +++ b/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/list/PositionComposeItemExecutor.kt @@ -0,0 +1,24 @@ +package com.atiurin.ultron.core.compose.list + +import androidx.compose.ui.test.SemanticsMatcher +import com.atiurin.ultron.core.compose.nodeinteraction.UltronComposeSemanticsNodeInteraction +import com.atiurin.ultron.exceptions.UltronException + +class PositionComposeItemExecutor ( + val ultronComposeList: UltronComposeList, + val position: Int +) : ComposeItemExecutor { + private val positionKey = ultronComposeList.positionPropertyKey + ?: throw UltronException("[positionPropertyKey] parameter is not specified for Compose List") + private val positionMatcher = SemanticsMatcher.expectValue(positionKey, position) + + override fun scrollToItem(offset: Int) { + ultronComposeList.scrollToNode(positionMatcher) + } + override fun getItemInteraction() : UltronComposeSemanticsNodeInteraction { + return ultronComposeList.onItem(positionMatcher) + } + override fun getItemChildInteraction(childMatcher: SemanticsMatcher): UltronComposeSemanticsNodeInteraction { + return ultronComposeList.onItemChild(positionMatcher, childMatcher) + } +} \ No newline at end of file diff --git a/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/list/UltronComposeList.kt b/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/list/UltronComposeList.kt index 02d86573..ac1c8c6d 100644 --- a/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/list/UltronComposeList.kt +++ b/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/list/UltronComposeList.kt @@ -1,30 +1,38 @@ package com.atiurin.ultron.core.compose.list import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.onChildAt import androidx.compose.ui.test.onChildren -import androidx.compose.ui.test.performScrollToIndex -import androidx.compose.ui.test.performScrollToKey import androidx.compose.ui.test.performScrollToNode import com.atiurin.ultron.core.common.options.ContentDescriptionContainsOption import com.atiurin.ultron.core.compose.config.UltronComposeConfig import com.atiurin.ultron.core.compose.nodeinteraction.UltronComposeSemanticsNodeInteraction -import com.atiurin.ultron.core.espresso.recyclerview.UltronRecyclerViewItem +import com.atiurin.ultron.core.compose.operation.ComposeOperationType +import com.atiurin.ultron.core.compose.operation.UltronComposeOperationParams +import com.atiurin.ultron.exceptions.UltronAssertionException import com.atiurin.ultron.exceptions.UltronException import com.atiurin.ultron.utils.AssertUtils +import org.junit.Assert class UltronComposeList( val listMatcher: SemanticsMatcher, var useUnmergedTree: Boolean = true, + var positionPropertyKey: SemanticsPropertyKey? = null, private val itemSearchLimit: Int = UltronComposeConfig.params.lazyColumnItemSearchLimit, private var operationTimeoutMs: Long = UltronComposeConfig.params.lazyColumnOperationTimeoutMs ) { - open fun withTimeout(timeoutMs: Long) = - UltronComposeList(listMatcher, useUnmergedTree, this.itemSearchLimit, operationTimeoutMs = timeoutMs) + UltronComposeList( + listMatcher = listMatcher, + useUnmergedTree = useUnmergedTree, + positionPropertyKey = positionPropertyKey, + itemSearchLimit = itemSearchLimit, + operationTimeoutMs = timeoutMs + ) /** * @return current [UltronComposeList] operations timeout @@ -32,40 +40,75 @@ class UltronComposeList( fun getOperationTimeout() = operationTimeoutMs fun item(matcher: SemanticsMatcher) = UltronComposeListItem(this, matcher) + fun item(position: Int): UltronComposeListItem { + if (positionPropertyKey == null) { + throw UltronException( + """ + |[positionPropertyKey] parameter is not specified for Compose List + |Configure it by using [composeList(.., positionPropertyKey = ListItemPositionPropertyKey)] + """.trimMargin() + ) + } + return UltronComposeListItem(this, position, true) + } + fun firstItem(): UltronComposeListItem = item(0) + + /** + * This method works properly before any scroll of target list + * After scroll the positions of children inside the list are changed. + */ fun visibleItem(index: Int) = UltronComposeListItem(this, index) + + /** + * This method works properly before any scroll of target list + * After scroll the positions of children inside the list are changed. + */ fun firstVisibleItem() = visibleItem(0) - fun lastVisibleItem() = visibleItem(getMatcher().perform { it.fetchSemanticsNode().children.lastIndex }) - - /** @return [UltronRecyclerViewItem] subclass instance matches [matcher] - * - * Note: never add inner modifier to [T] class - * - * Note: [T] class should have a constructor without parameters, eg - * - * class SomeRecyclerViewItem : UltronRecyclerViewItem(){...} - * */ - inline fun getItem( - matcher: SemanticsMatcher - ): T { + + /** + * This method works properly before any scroll of target list + * After scroll the positions of children inside the list are changed. + */ + fun lastVisibleItem() = visibleItem(getInteraction().execute { it.fetchSemanticsNode().children.lastIndex }) + + + inline fun getItem(matcher: SemanticsMatcher): T { return UltronComposeListItem.getInstance(this, matcher) } - /** @return [UltronRecyclerViewItem] subclass instance at position [index] - * - * Note: never add inner modifier to [T] class - * - * Note: [T] class should have a constructor without parameters, eg - * - * class SomeRecyclerViewItem : UltronRecyclerViewItem(){...} - * */ - inline fun getVisibleItem( - index: Int - ): T { + inline fun getItem(position: Int): T { + if (positionPropertyKey == null) { + throw UltronException( + """ + |[positionPropertyKey] parameter is not specified for Compose List + |Configure it by using [composeList(.., positionPropertyKey = ListItemPositionPropertyKey)] + """.trimMargin() + ) + } + return UltronComposeListItem.getInstance(this, position, true) + } + + inline fun getFirstItem(): T = getItem(0) + + /** + * This method works properly before any scroll of target list + * After scroll the positions of children inside the list are changed. + */ + inline fun getVisibleItem(index: Int): T { return UltronComposeListItem.getInstance(this, index) } + /** + * This method works properly before any scroll of target list + * After scroll the positions of children inside the list are changed. + * */ inline fun getFirstVisibleItem(): T = getVisibleItem(0) - inline fun getLastVisibleItem(): T = getVisibleItem(getMatcher().execute { it.fetchSemanticsNode().children.lastIndex }) + + /** + * This method works properly before any scroll of target list + * After scroll the positions of children inside the list are changed. + * */ + inline fun getLastVisibleItem(): T = getVisibleItem(getInteraction().execute { it.fetchSemanticsNode().children.lastIndex }) /** * Provide a scope with references to list SemanticsNode and SemanticsNodeInteraction. @@ -81,45 +124,81 @@ class UltronComposeList( * @return SemanticsNodeInteraction for list item */ fun onItem(matcher: SemanticsMatcher) = UltronComposeSemanticsNodeInteraction( - getMatcher().execute { listInteraction -> + getInteraction().execute( + UltronComposeOperationParams( + operationName = "Get item '${matcher.description}' in list '${getInteraction().elementInfo.name}'", + operationDescription = "Get Compose list item with matcher '${matcher.description}' in list '${getInteraction().elementInfo.name}'", + operationType = ComposeOperationType.GET_LIST_ITEM + ) + ) { listInteraction -> listInteraction.performScrollToNode(matcher).onChildren().filterToOne(matcher) } ) fun onItemChild(itemMatcher: SemanticsMatcher, childMatcher: SemanticsMatcher): UltronComposeSemanticsNodeInteraction = - UltronComposeSemanticsNodeInteraction(UltronComposeSemanticsNodeInteraction(listMatcher, true) - .execute { listInteraction -> - listInteraction.performScrollToNode(itemMatcher) - .onChildren().filterToOne(itemMatcher) - .onChildren().filterToOne(childMatcher) - } + UltronComposeSemanticsNodeInteraction( + UltronComposeSemanticsNodeInteraction(listMatcher, true) + .execute( + UltronComposeOperationParams( + operationName = "Get item '${itemMatcher.description}' child '${childMatcher.description}' in list '${getInteraction().elementInfo.name}'", + operationDescription = "Get Compose list item '${itemMatcher.description}' child '${childMatcher.description}' in list '${getInteraction().elementInfo.name}'", + operationType = ComposeOperationType.GET_LIST_ITEM_CHILD + ) + ) { listInteraction -> + listInteraction.performScrollToNode(itemMatcher) + .onChildren().filterToOne(itemMatcher) + .onChildren().filterToOne(childMatcher) + } ) fun visibleChild(childMatcher: SemanticsMatcher) = UltronComposeSemanticsNodeInteraction( - getMatcher().execute { listInteraction -> + getInteraction().execute( + UltronComposeOperationParams( + operationName = "Get child '${childMatcher.description}' of list '${getInteraction().elementInfo.name}'", + operationDescription = "Get Compose list child '${childMatcher.description}' of list '${getInteraction().elementInfo.name}'", + operationType = ComposeOperationType.GET_LIST_ITEM + ) + ) { listInteraction -> listInteraction.onChildren().filterToOne(childMatcher) } ) fun onVisibleItemChild(index: Int, childMatcher: SemanticsMatcher) = UltronComposeSemanticsNodeInteraction( - getMatcher().execute { listInteraction -> + getInteraction().execute( + UltronComposeOperationParams( + operationName = "Get child '${childMatcher.description}' of visible item at index $index in list '${getInteraction().elementInfo.name}'", + operationDescription = "Get Compose list child '${childMatcher.description}' of visible item at index $index in list '${getInteraction().elementInfo.name}'", + operationType = ComposeOperationType.GET_LIST_ITEM_CHILD + ) + ) { listInteraction -> listInteraction.onChildAt(index).onChildren().filterToOne(childMatcher) } ) fun onVisibleItem(index: Int) = UltronComposeSemanticsNodeInteraction( - getMatcher().execute { listInteraction -> + getInteraction().execute( + UltronComposeOperationParams( + operationName = "Get visible item at index $index in list '${getInteraction().elementInfo.name}'", + operationDescription = "Get Compose list visible item at index $index in list '${getInteraction().elementInfo.name}'", + operationType = ComposeOperationType.GET_LIST_ITEM + ) + ) { listInteraction -> val visibleItemsList = listInteraction.fetchSemanticsNode().children if (index > visibleItemsList.size) { throw UltronException( """ |Item index ($index) is out of visible items (${visibleItemsList.size}). - |It's impossible to get the reference to item by index after scroll. You have 2 variants: + |It's impossible to get the reference to item by index after scroll. You have 3 variants: |1. [Preferred one] Use another method to receive list item with matcher UltronComposeList.item(matcher: SemanticsMatcher) | In case you still wanna scroll to item by position in list: | - Add testTag for items in LazyColumn definition like 'itemsIndexed(items){ index, index -> .. Modifier.testTag("position=`$`index") }' | - Use matcher in test to get item 'list.item(hasTestTag("position=`$`index"))' - |2. Scroll to index by using UltronComposeList.scrollToIndex(index: Int) and use SemanticsMatcher to find item. + |2. [A good way also] Use another method to receive list item with position UltronComposeList.item(position: Int) + | To use this method you have to: + | - Configure custom Position SemanticsProperty for compose list items in application code + | - Setup [positionPropertyKey] parameter for composeList(..) in test code + | - Read documentation for details. + |3. Scroll to index by using UltronComposeList.scrollToIndex(index: Int) and use SemanticsMatcher to find item. """.trimMargin() ) } @@ -127,22 +206,18 @@ class UltronComposeList( } ) - fun scrollToNode(itemMatcher: SemanticsMatcher) = apply { - getMatcher().perform { listInteraction -> - listInteraction.performScrollToNode(itemMatcher) - } - } - - fun scrollToIndex(index: Int) = apply { getMatcher().perform { it.performScrollToIndex(index) } } - fun scrollToKey(key: Any) = apply { getMatcher().perform { it.performScrollToKey(key) } } - fun assertIsDisplayed() = apply { getMatcher().withTimeout(getOperationTimeout()).assertIsDisplayed() } - fun assertIsNotDisplayed() = apply { getMatcher().withTimeout(getOperationTimeout()).assertIsNotDisplayed() } - fun assertExists() = apply { getMatcher().withTimeout(getOperationTimeout()).assertExists() } - fun assertDoesNotExist() = apply { getMatcher().withTimeout(getOperationTimeout()).assertDoesNotExist() } - fun assertContentDescriptionEquals(vararg expected: String) = apply { getMatcher().withTimeout(getOperationTimeout()).assertContentDescriptionEquals(*expected) } + fun scrollToNode(itemMatcher: SemanticsMatcher) = apply { getInteraction().scrollToNode(itemMatcher) } + fun scrollToIndex(index: Int) = apply { getInteraction().scrollToIndex(index) } + fun scrollToKey(key: Any) = apply { getInteraction().scrollToKey(key) } + fun assertIsDisplayed() = apply { getInteraction().withTimeout(getOperationTimeout()).assertIsDisplayed() } + fun assertIsNotDisplayed() = apply { getInteraction().withTimeout(getOperationTimeout()).assertIsNotDisplayed() } + fun assertExists() = apply { getInteraction().withTimeout(getOperationTimeout()).assertExists() } + fun assertDoesNotExist() = apply { getInteraction().withTimeout(getOperationTimeout()).assertDoesNotExist() } + fun assertContentDescriptionEquals(vararg expected: String) = apply { getInteraction().withTimeout(getOperationTimeout()).assertContentDescriptionEquals(*expected) } fun assertContentDescriptionContains(expected: String, option: ContentDescriptionContainsOption? = null) = - apply { getMatcher().withTimeout(getOperationTimeout()).assertContentDescriptionContains(expected, option) } + apply { getInteraction().withTimeout(getOperationTimeout()).assertContentDescriptionContains(expected, option) } + fun assertMatches(matcher: SemanticsMatcher) = apply { getInteraction().withTimeout(getOperationTimeout()).assertMatches(matcher) } fun assertNotEmpty() = apply { AssertUtils.assertTrue( { getVisibleItemsCount() > 0 }, getOperationTimeout(), @@ -164,8 +239,35 @@ class UltronComposeList( ) } - fun getVisibleItemsCount(): Int = getMatcher().execute { it.fetchSemanticsNode().children.size } - fun getMatcher() = UltronComposeSemanticsNodeInteraction(listMatcher, useUnmergedTree) + /** + * Asserts whether an item exists in the list or not. + * If the item doesn't exist, the operation is considered successful immediately. + * If the item exists, the operation waits for a specified timeout ([operationTimeoutMs]) for the item to disappear. + * Otherwise, an exception will be thrown. + */ + fun assertItemDoesNotExist(itemMatcher: SemanticsMatcher) { + getInteraction().withTimeout(getOperationTimeout()).perform( + params = UltronComposeOperationParams( + operationName = "Assert item ${itemMatcher.description} doesn't exist in list ${getInteraction().elementInfo.name}", + operationDescription = "Assert item ${itemMatcher.description} doesn't exist in list ${getInteraction().elementInfo.name} during ${getOperationTimeout()}", + operationType = ComposeOperationType.ASSERT_LIST_ITEM_DOES_NOT_EXIST + ) + ) { + runCatching { it.performScrollToNode(itemMatcher) } + .onSuccess { throw UltronAssertionException("Item '${itemMatcher.description}' exists in list '${listMatcher.description}'") } + .onFailure { e -> e.message?.let { message -> Assert.assertTrue(message.contains("No node found that matches")) } } + } + } + + fun getVisibleItemsCount(): Int = getInteraction().execute { it.fetchSemanticsNode().children.size } + fun getInteraction() = UltronComposeSemanticsNodeInteraction(listMatcher, useUnmergedTree) + + @Deprecated("Use getInteraction() instead", ReplaceWith("getInteraction()")) + fun getMatcher() = getInteraction() } -fun composeList(listMatcher: SemanticsMatcher, useUnmergedTree: Boolean = true) = UltronComposeList(listMatcher, useUnmergedTree) +fun composeList( + listMatcher: SemanticsMatcher, + useUnmergedTree: Boolean = true, + positionPropertyKey: SemanticsPropertyKey? = null +) = UltronComposeList(listMatcher, useUnmergedTree, positionPropertyKey) diff --git a/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/list/UltronComposeListItem.kt b/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/list/UltronComposeListItem.kt index 4e3d9659..94be42d3 100644 --- a/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/list/UltronComposeListItem.kt +++ b/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/list/UltronComposeListItem.kt @@ -10,12 +10,45 @@ import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.text.TextRange import androidx.compose.ui.unit.Dp -import com.atiurin.ultron.core.common.options.* +import com.atiurin.ultron.core.common.options.ClickOption +import com.atiurin.ultron.core.common.options.ContentDescriptionContainsOption +import com.atiurin.ultron.core.common.options.DoubleClickOption +import com.atiurin.ultron.core.common.options.LongClickOption +import com.atiurin.ultron.core.common.options.PerformCustomBlockOption +import com.atiurin.ultron.core.common.options.TextContainsOption +import com.atiurin.ultron.core.common.options.TextEqualsOption +import com.atiurin.ultron.core.compose.nodeinteraction.UltronComposeSemanticsNodeInteraction +import com.atiurin.ultron.core.compose.nodeinteraction.click +import com.atiurin.ultron.core.compose.nodeinteraction.clickBottomCenter +import com.atiurin.ultron.core.compose.nodeinteraction.clickBottomLeft +import com.atiurin.ultron.core.compose.nodeinteraction.clickBottomRight +import com.atiurin.ultron.core.compose.nodeinteraction.clickCenterLeft +import com.atiurin.ultron.core.compose.nodeinteraction.clickCenterRight +import com.atiurin.ultron.core.compose.nodeinteraction.clickTopCenter +import com.atiurin.ultron.core.compose.nodeinteraction.clickTopLeft +import com.atiurin.ultron.core.compose.nodeinteraction.clickTopRight +import com.atiurin.ultron.core.compose.nodeinteraction.doubleClick +import com.atiurin.ultron.core.compose.nodeinteraction.doubleClickBottomCenter +import com.atiurin.ultron.core.compose.nodeinteraction.doubleClickBottomLeft +import com.atiurin.ultron.core.compose.nodeinteraction.doubleClickBottomRight +import com.atiurin.ultron.core.compose.nodeinteraction.doubleClickCenterLeft +import com.atiurin.ultron.core.compose.nodeinteraction.doubleClickCenterRight +import com.atiurin.ultron.core.compose.nodeinteraction.doubleClickTopCenter +import com.atiurin.ultron.core.compose.nodeinteraction.doubleClickTopLeft +import com.atiurin.ultron.core.compose.nodeinteraction.doubleClickTopRight +import com.atiurin.ultron.core.compose.nodeinteraction.longClick +import com.atiurin.ultron.core.compose.nodeinteraction.longClickBottomCenter +import com.atiurin.ultron.core.compose.nodeinteraction.longClickBottomLeft +import com.atiurin.ultron.core.compose.nodeinteraction.longClickBottomRight +import com.atiurin.ultron.core.compose.nodeinteraction.longClickCenterLeft +import com.atiurin.ultron.core.compose.nodeinteraction.longClickCenterRight +import com.atiurin.ultron.core.compose.nodeinteraction.longClickTopCenter +import com.atiurin.ultron.core.compose.nodeinteraction.longClickTopLeft +import com.atiurin.ultron.core.compose.nodeinteraction.longClickTopRight import com.atiurin.ultron.core.compose.operation.ComposeOperationResult import com.atiurin.ultron.core.compose.operation.UltronComposeOperation -import com.atiurin.ultron.core.compose.option.ComposeSwipeOption -import com.atiurin.ultron.core.compose.nodeinteraction.* import com.atiurin.ultron.core.compose.operation.UltronComposeOperationParams +import com.atiurin.ultron.core.compose.option.ComposeSwipeOption import com.atiurin.ultron.exceptions.UltronException open class UltronComposeListItem { @@ -25,8 +58,8 @@ open class UltronComposeListItem { setExecutor(ultronComposeList, itemMatcher) } - constructor(ultronComposeList: UltronComposeList, index: Int) { - setExecutor(ultronComposeList, index) + constructor(ultronComposeList: UltronComposeList, index: Int, isPositionPropertyConfigured: Boolean = false) { + setExecutor(ultronComposeList, index, isPositionPropertyConfigured) } /** @@ -39,8 +72,12 @@ open class UltronComposeListItem { this.executor = MatcherComposeItemExecutor(ultronComposeList, itemMatcher) } - fun setExecutor(ultronComposeList: UltronComposeList, index: Int) { - this.executor = IndexComposeItemExecutor(ultronComposeList, index) + fun setExecutor(ultronComposeList: UltronComposeList, index: Int, isPositionPropertyConfigured: Boolean = false) { + if (isPositionPropertyConfigured) { + this.executor = PositionComposeItemExecutor(ultronComposeList, index) + } else { + this.executor = IndexComposeItemExecutor(ultronComposeList, index) + } } fun withTimeout(timeoutMs: Long) = getItemUltronComposeInteraction().withTimeout(timeoutMs) @@ -111,7 +148,7 @@ open class UltronComposeListItem { ) fun perform( block: (SemanticsNodeInteraction) -> T - ) : T = getItemUltronComposeInteraction().execute(null, block) + ): T = getItemUltronComposeInteraction().execute(null, block) fun execute( params: UltronComposeOperationParams? = null, block: (SemanticsNodeInteraction) -> T @@ -120,7 +157,6 @@ open class UltronComposeListItem { fun assertIsDisplayed() = apply { getItemUltronComposeInteraction().assertIsDisplayed() } fun assertIsNotDisplayed() = apply { getItemUltronComposeInteraction().assertIsNotDisplayed() } fun assertExists() = apply { getItemUltronComposeInteraction().assertExists() } - fun assertDoesNotExist() = apply { getItemUltronComposeInteraction().assertDoesNotExist() } fun assertIsEnabled() = apply { getItemUltronComposeInteraction().assertIsEnabled() } fun assertIsNotEnabled() = apply { getItemUltronComposeInteraction().assertIsNotEnabled() } fun assertIsFocused() = apply { getItemUltronComposeInteraction().assertIsFocused() } @@ -164,10 +200,11 @@ open class UltronComposeListItem { inline fun getInstance( ultronComposeList: UltronComposeList, - position: Int + position: Int, + isPositionPropertyConfigured: Boolean = false ): T { val item = createUltronComposeListItemInstance() - item.setExecutor(ultronComposeList, position) + item.setExecutor(ultronComposeList, position, isPositionPropertyConfigured) return item } diff --git a/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/nodeinteraction/UltronComposeSemanticsNodeInteraction.kt b/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/nodeinteraction/UltronComposeSemanticsNodeInteraction.kt index 7bda2b7a..58e351d3 100644 --- a/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/nodeinteraction/UltronComposeSemanticsNodeInteraction.kt +++ b/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/nodeinteraction/UltronComposeSemanticsNodeInteraction.kt @@ -188,16 +188,16 @@ open class UltronComposeSemanticsNodeInteraction constructor( fun scrollToIndex(index: Int) = apply { executeOperation( operationBlock = { semanticsNodeInteraction.performScrollToIndex(index) }, - name = "ScrollToIndex on '${elementInfo.name}'", + name = "ScrollToIndex '$index' on '${elementInfo.name}'", type = SCROLL_TO_INDEX, description = "Compose scrollToIndex $index on '${elementInfo.name} during $timeoutMs ms", ) } - fun scrollToKey(key: String) = apply { + fun scrollToKey(key: Any) = apply { executeOperation( operationBlock = { semanticsNodeInteraction.performScrollToKey(key) }, - name = "ScrollToIndex on '${elementInfo.name}'", + name = "ScrollToKey '$key' on '${elementInfo.name}'", type = SCROLL_TO_KEY, description = "Compose scrollToKey '$key' on '${elementInfo.name} during $timeoutMs ms", ) @@ -206,7 +206,7 @@ open class UltronComposeSemanticsNodeInteraction constructor( fun scrollToNode(matcher: SemanticsMatcher) = apply { executeOperation( operationBlock = { semanticsNodeInteraction.performScrollToNode(matcher) }, - name = "ScrollToNode on '${elementInfo.name}'", + name = "ScrollToNode '${matcher.description}' on '${elementInfo.name}'", type = SCROLL_TO_NODE, description = "Compose scrollToNode with matcher '${matcher.description}' on '${elementInfo.name} during $timeoutMs ms", ) diff --git a/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/operation/ComposeOperationType.kt b/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/operation/ComposeOperationType.kt index c3892950..69019173 100644 --- a/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/operation/ComposeOperationType.kt +++ b/ultron-compose/src/main/java/com/atiurin/ultron/core/compose/operation/ComposeOperationType.kt @@ -24,5 +24,6 @@ enum class ComposeOperationType : UltronOperationType { VALUE_EQUALS, PROGRESS_BAR_RANGE_EQUALS, ASSERT_MATCHES, - CAPTURE_IMAGE + CAPTURE_IMAGE, + GET_LIST_ITEM, GET_LIST_ITEM_CHILD, ASSERT_LIST_ITEM_DOES_NOT_EXIST } \ No newline at end of file diff --git a/ultron/src/main/java/com/atiurin/ultron/core/espresso/recyclerview/RecyclerViewItemMatchingExecutor.kt b/ultron/src/main/java/com/atiurin/ultron/core/espresso/recyclerview/RecyclerViewItemMatchingExecutor.kt index 171a925a..b664d8cc 100644 --- a/ultron/src/main/java/com/atiurin/ultron/core/espresso/recyclerview/RecyclerViewItemMatchingExecutor.kt +++ b/ultron/src/main/java/com/atiurin/ultron/core/espresso/recyclerview/RecyclerViewItemMatchingExecutor.kt @@ -9,7 +9,7 @@ class RecyclerViewItemMatchingExecutor( private val itemViewMatcher: Matcher ) : RecyclerViewItemExecutor { override fun scrollToItem(offset: Int) { - ultronRecyclerView.scrollToIem(itemViewMatcher, offset = offset) + ultronRecyclerView.scrollToItem(itemViewMatcher, offset = offset) } override fun getItemMatcher(): Matcher { diff --git a/ultron/src/main/java/com/atiurin/ultron/core/espresso/recyclerview/RecyclerViewItemPositionalExecutor.kt b/ultron/src/main/java/com/atiurin/ultron/core/espresso/recyclerview/RecyclerViewItemPositionalExecutor.kt index d3d37386..1ab9701a 100644 --- a/ultron/src/main/java/com/atiurin/ultron/core/espresso/recyclerview/RecyclerViewItemPositionalExecutor.kt +++ b/ultron/src/main/java/com/atiurin/ultron/core/espresso/recyclerview/RecyclerViewItemPositionalExecutor.kt @@ -5,6 +5,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.contrib.RecyclerViewActions import com.atiurin.ultron.exceptions.UltronOperationException import com.atiurin.ultron.extensions.perform +import com.atiurin.ultron.extensions.withTimeout import org.hamcrest.Matcher class RecyclerViewItemPositionalExecutor( @@ -18,20 +19,7 @@ class RecyclerViewItemPositionalExecutor( } override fun scrollToItem(offset: Int) { - ultronRecyclerView.assertHasItemAtPosition(position) - val itemCount = ultronRecyclerView.getSize() - val positionToScroll = position + offset - val finalPositionToScroll = when { - positionToScroll in 1 until itemCount -> positionToScroll - positionToScroll >= itemCount -> itemCount - 1 - else -> 0 - } - ultronRecyclerView.recyclerViewMatcher.perform( - viewAction = RecyclerViewActions.scrollToPosition( - finalPositionToScroll - ), - description = "RecyclerViewActions scrollToPosition $position with offset = $offset" - ) + ultronRecyclerView.scrollToItem(position, offset) } override fun getItemMatcher(): Matcher { diff --git a/ultron/src/main/java/com/atiurin/ultron/core/espresso/recyclerview/UltronRecyclerView.kt b/ultron/src/main/java/com/atiurin/ultron/core/espresso/recyclerview/UltronRecyclerView.kt index 535e93bd..d9f023f3 100644 --- a/ultron/src/main/java/com/atiurin/ultron/core/espresso/recyclerview/UltronRecyclerView.kt +++ b/ultron/src/main/java/com/atiurin/ultron/core/espresso/recyclerview/UltronRecyclerView.kt @@ -7,6 +7,7 @@ import androidx.annotation.IntegerRes import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.BoundedMatcher import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.util.TreeIterables @@ -385,13 +386,34 @@ open class UltronRecyclerView( open fun assertMatches(matcher: Matcher) = apply { recyclerViewMatcher.withTimeout(getTimeout()).assertMatches(matcher) } - fun scrollToIem(itemMatcher: Matcher, searchLimit: Int = this.itemSearchLimit, offset: Int = 0) = apply { + + fun scrollToItem(itemMatcher: Matcher, searchLimit: Int = this.itemSearchLimit, offset: Int = 0) = apply { recyclerViewMatcher.withTimeout(getTimeout()).perform( viewAction = RecyclerViewScrollAction(itemMatcher, searchLimit, offset), description = "Scroll RecyclerView '$recyclerViewMatcher' to item = '$itemMatcher' with searchLimit = $searchLimit and offset = $offset" ) } + @Deprecated("Use scrollToItem(itemMatcher, searchLimit, offset)") + fun scrollToIem(itemMatcher: Matcher, searchLimit: Int = this.itemSearchLimit, offset: Int = 0) = scrollToItem(itemMatcher, searchLimit, offset) + + fun scrollToItem(position: Int, offset: Int = 0){ + assertHasItemAtPosition(position) + val itemCount = getSize() + val positionToScroll = position + offset + val finalPositionToScroll = when { + positionToScroll in 1 until itemCount -> positionToScroll + positionToScroll >= itemCount -> itemCount - 1 + else -> 0 + } + recyclerViewMatcher.withTimeout(getTimeout()).perform( + viewAction = RecyclerViewActions.scrollToPosition( + finalPositionToScroll + ), + description = "RecyclerViewActions scrollToPosition $position with offset = $offset" + ) + } + /** set timeout for operations with RecyclerView. * Note: it doesn't modify [loadTimeoutMs] (waiting a RecyclerView to be loaded) * */ diff --git a/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronAssertionBlockException.kt b/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronAssertionBlockException.kt index 38a14747..444bfbab 100644 --- a/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronAssertionBlockException.kt +++ b/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronAssertionBlockException.kt @@ -1,3 +1,3 @@ package com.atiurin.ultron.exceptions -class UltronAssertionBlockException(override val message: String) : RuntimeException(message) \ No newline at end of file +class UltronAssertionBlockException(override val message: String) : AssertionError(message) \ No newline at end of file diff --git a/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronAssertionException.kt b/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronAssertionException.kt index 705446be..d514a462 100644 --- a/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronAssertionException.kt +++ b/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronAssertionException.kt @@ -1,3 +1,3 @@ package com.atiurin.ultron.exceptions -class UltronAssertionException(override val message: String) : RuntimeException(message) \ No newline at end of file +class UltronAssertionException(override val message: String) : AssertionError(message) \ No newline at end of file diff --git a/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronOperationException.kt b/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronOperationException.kt index f3848d70..909b2db1 100644 --- a/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronOperationException.kt +++ b/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronOperationException.kt @@ -1,6 +1,9 @@ package com.atiurin.ultron.exceptions -class UltronOperationException : RuntimeException { +import android.annotation.SuppressLint + +class UltronOperationException : AssertionError { constructor(message: String) : super(message) + @SuppressLint("NewApi") constructor(message: String, cause: Throwable) : super(message, cause) } \ No newline at end of file diff --git a/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronUiAutomatorException.kt b/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronUiAutomatorException.kt index e486509c..767e9b92 100644 --- a/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronUiAutomatorException.kt +++ b/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronUiAutomatorException.kt @@ -1,3 +1,3 @@ package com.atiurin.ultron.exceptions -class UltronUiAutomatorException(override val message: String) : RuntimeException(message) \ No newline at end of file +class UltronUiAutomatorException(override val message: String) : AssertionError(message) \ No newline at end of file diff --git a/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronWrapperException.kt b/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronWrapperException.kt index 2509d76a..08d532a2 100644 --- a/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronWrapperException.kt +++ b/ultron/src/main/java/com/atiurin/ultron/exceptions/UltronWrapperException.kt @@ -1,12 +1,12 @@ package com.atiurin.ultron.exceptions -class UltronWrapperException : RuntimeException { +class UltronWrapperException : AssertionError { constructor(message: String) : super(message) constructor(message: String, cause: Throwable) : super( "$message${ if (cause is UltronWrapperException || cause is UltronOperationException) "" - else "\nOriginal error ${cause::class.qualifiedName}: ${cause.message}" + else "\nOriginal error - ${cause::class.qualifiedName}: ${cause.message}" }" ) } \ No newline at end of file