Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-16 09:08:09 +03:00
commit df1ddfaa36
7 changed files with 378 additions and 9 deletions

View file

@ -0,0 +1,34 @@
package com.tangem.common.extensions
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsNodeInteractionCollection
import androidx.compose.ui.test.assertIsDisplayed
/** Returns the first non-blank text found in this node or its subtree (unmerged tree). */
fun SemanticsNode.firstTextOrNull(): String? {
config.getOrNull(SemanticsProperties.Text)
?.firstOrNull()?.text?.takeIf { it.isNotBlank() }
?.let { return it }
children.forEach { child -> child.firstTextOrNull()?.let { return it } }
return null
}
/**
* Reads the first text of every currently displayed node in this collection, in visual order
* (top-to-bottom, then left-to-right). Non-displayed nodes and nodes without text are skipped.
*/
fun SemanticsNodeInteractionCollection.displayedTextsInVisualOrder(): List<String> {
val count = fetchSemanticsNodes().size
return (0 until count)
.mapNotNull { index ->
val interaction = get(index)
if (runCatching { interaction.assertIsDisplayed() }.isFailure) return@mapNotNull null
val node = interaction.fetchSemanticsNode()
val text = node.firstTextOrNull() ?: return@mapNotNull null
node.boundsInRoot to text
}
.sortedWith(compareBy({ it.first.top }, { it.first.left }))
.map { it.second }
}

View file

@ -1,12 +1,15 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onAddAndManageBottomSheet
import com.tangem.screens.onMainScreen
import com.tangem.screens.onOrganizeTokensScreen
import io.qameta.allure.kotlin.Allure.step
import org.junit.Assert.assertEquals
fun BaseTestCase.openOrganizeTokensScreen() {
step("Swipe to 'Add & Manage' button") {
@ -19,4 +22,30 @@ fun BaseTestCase.openOrganizeTokensScreen() {
step("Click on 'Organize tokens' button in bottom sheet") {
onAddAndManageBottomSheet { organizeTokensButton.clickWithAssertion() }
}
}
fun BaseTestCase.getMainScreenTokensOrder(): List<String> {
var tokens: List<String> = emptyList()
step("Read displayed token titles from 'Main' screen") {
awaitSuccess(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
onMainScreen { tokens = getDisplayedTokenTitles() }
require(tokens.isNotEmpty()) { "No token titles found on the main screen" }
}
}
return tokens
}
fun BaseTestCase.assertOrganizeTokensMatch(expectedTokens: List<String>) {
step("Open 'Organize tokens' bottom-sheet") {
onMainScreen { clickDisplayedAddAndManageButton() }
onAddAndManageBottomSheet { organizeTokensButton.clickWithAssertion() }
}
step("Assert 'Organize tokens' list matches the main screen order") {
onOrganizeTokensScreen {
assertEquals(expectedTokens, getDisplayedTokenTitles())
}
}
step("Return to 'Main' screen") {
onOrganizeTokensScreen { cancelButton.clickWithAssertion() }
}
}

View file

@ -45,6 +45,21 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) {
}
}
fun BaseTestCase.addNewCardWalletWithoutSync(mockContent: MockContent) {
step("Click 'More' button on TopBar") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
MockProvider.setMocks(mockContent)
step("Click on 'Add Wallet' button (scans a new hardware wallet)") {
onDetailsScreen { addWalletButton.clickWithAssertion() }
}
step("Assert 'Main' screen is displayed with the new wallet") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
runCatching { onMainScreenTopBar { moreButton.assertIsDisplayed() } }.isSuccess
}
}
}
fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) {
step("Click on token '$tokenName' on the visible wallet") {
onMainScreen { clickDisplayedToken(tokenName) }

View file

@ -1,8 +1,11 @@
package com.tangem.screens
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.*
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.displayedTextsInVisualOrder
import com.tangem.common.extensions.firstTextOrNull
import com.tangem.common.extensions.getQuantityString
import com.tangem.common.extensions.hasLazyListItemPosition
import com.tangem.common.utils.LazyListItemNode
@ -14,6 +17,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import kotlin.math.abs
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
import com.tangem.core.res.R as CoreResR
@ -121,16 +125,75 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
error("Token '$tokenName' is not displayed on the current wallet page")
}
// Adjacent pager pages stay mounted; swipe the wallet card that's actually on-screen.
/**
* Switches to the previous/next wallet in the pager. A horizontal swipe is a silent no-op unless
* the collapsing balance header is fully expanded and pinned to the top, so we retry: swipe, and
* whenever the wallet identity doesn't change, expand the header and try again.
*/
fun swipeToAdjacentWallet(toPrevious: Boolean) {
val nodes = semanticsProvider.onAllNodes(withTestTag(MainScreenTestTags.WALLET_LIST_ITEM))
for (i in 0 until nodes.fetchSemanticsNodes().size) {
val swiped = runCatching {
nodes[i].assertIsDisplayed()
nodes[i].performTouchInput { if (toPrevious) swipeRight() else swipeLeft() }
}.isSuccess
if (swiped) return
val before = displayedWalletIdentity()
repeat(times = WALLET_SWITCH_ATTEMPTS) {
swipeCurrentPage(toPrevious)
val now = displayedWalletIdentity()
if (now != null && now != before) return
expandCollapsingHeader()
}
error("Wallet did not switch from '$before' after $WALLET_SWITCH_ATTEMPTS attempts")
}
// Expand only *after* a swipe that didn't page: if the header is already pinned, a swipe-down here
// would trigger pull-to-refresh and un-pin it, breaking the horizontal swipe.
private fun expandCollapsingHeader() {
onScreenPage()?.performTouchInput {
swipeDown(startY = visibleSize.height * 0.3f, endY = visibleSize.height * 0.8f)
}
}
private fun swipeCurrentPage(toPrevious: Boolean) {
onScreenPage()?.performTouchInput { if (toPrevious) swipeRight() else swipeLeft() }
}
// Identity = title + balance: a still-restoring wallet has no CARD_TITLE but always a WALLET_BALANCE.
private fun displayedWalletIdentity(): String? {
val title = onScreenPageChild(withTestTag(MainScreenTestTags.CARD_TITLE))?.firstText()
val balance = onScreenPageChild(withTestTag(MainScreenTestTags.WALLET_BALANCE))?.firstText()
return listOfNotNull(title, balance).joinToString(separator = "|").ifBlank { null }
}
/**
* The full-width pager-page container currently on-screen. The pager keeps adjacent pages composed
* off-screen at ±pageWidth (so [assertIsDisplayed] can't tell them apart), hence selection by
* geometry: the on-screen page is the only one whose left edge is within half a page of x=0.
*/
private fun onScreenPage(): SemanticsNodeInteraction? =
firstNodeMatching(withTestTag(MainScreenTestTags.SCREEN_CONTAINER), useUnmergedTree = false) {
abs(it.left) < it.width / 2f
}
/** A node matching [matcher] whose centre lies within the on-screen page (skips zero-size off-screen copies). */
private fun onScreenPageChild(matcher: SemanticsMatcher): SemanticsNodeInteraction? {
val page = onScreenPage()?.fetchSemanticsNode()?.boundsInRoot ?: return null
return firstNodeMatching(matcher) {
it.width > 0f && it.height > 0f && it.center.x >= page.left && it.center.x < page.right
}
}
private fun SemanticsNodeInteraction.firstText(): String? = fetchSemanticsNode().firstTextOrNull()
// Single geometry primitive behind the pager helpers: the first node matching [matcher] whose
// bounds satisfy [predicate]. Replaces the per-caller onAllNodes(...)[i] loops.
private fun firstNodeMatching(
matcher: SemanticsMatcher,
useUnmergedTree: Boolean = true,
predicate: (Rect) -> Boolean,
): SemanticsNodeInteraction? {
val nodes = semanticsProvider.onAllNodes(matcher, useUnmergedTree = useUnmergedTree)
repeat(times = nodes.fetchSemanticsNodes().size) { index ->
val node = nodes[index]
val matches = runCatching { predicate(node.fetchSemanticsNode().boundsInRoot) }.getOrDefault(false)
if (matches) return node
}
return null
}
val restoringProgressText: KNode = child {
@ -380,6 +443,23 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
useUnmergedTree = true
}
/** Collapses the header, scrolls to and clicks the 'Add & manage' button on the on-screen wallet page. */
@OptIn(ExperimentalTestApi::class)
fun clickDisplayedAddAndManageButton() {
val container = onScreenPage() ?: error("No on-screen wallet page found")
// Best-effort: the button is a footer outside the scrollable list, so performScrollToNode can
// throw when it's already visible — that must not abort the click below.
runCatching {
container.performTouchInput {
swipeUp(startY = visibleSize.height * 0.6f, endY = visibleSize.height * 0.1f)
}
container.performScrollToNode(withTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON))
}
val button = onScreenPageChild(withTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON))
?: error("'Add & manage' button is not displayed on the current wallet page")
button.performClick()
}
val searchThroughMarketPlaceholder: KNode = child {
hasText(getResourceString(R.string.markets_search_title_placeholder))
useUnmergedTree = true
@ -485,6 +565,21 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
useUnmergedTree = true
}.assertIsDisplayed()
}
/**
* Token titles displayed on the current wallet page, in visual order. Reads the
* [TokenElementsTestTags.TOKEN_TITLE] rows so network group headers (which share the
* TOKEN_LIST_ITEM tag) are excluded, keeping the result symmetric with the 'Organize tokens' reader.
*/
fun getDisplayedTokenTitles(): List<String> =
semanticsProvider.onAllNodes(
withTestTag(TokenElementsTestTags.TOKEN_TITLE),
useUnmergedTree = true,
).displayedTextsInVisualOrder()
private companion object {
const val WALLET_SWITCH_ATTEMPTS = 4
}
}
internal fun BaseTestCase.onMainScreen(function: MainScreenPageObject.() -> Unit) =

View file

@ -1,8 +1,12 @@
package com.tangem.screens
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasAnyDescendant
import androidx.compose.ui.test.performTouchInput
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.displayedTextsInVisualOrder
import com.tangem.common.extensions.hasLazyListItemPosition
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.test.OrganizeTokensScreenTestTags
@ -14,12 +18,13 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasAnyAncestor as withAnyAncestor
import androidx.compose.ui.test.hasAnyChild as withAnyChild
import androidx.compose.ui.test.hasAnySibling as withAnySibling
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
class OrganizeTokensPageObject(private val semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<OrganizeTokensPageObject>(semanticsProvider = semanticsProvider) {
// region TopBar
@ -93,6 +98,15 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
}
}
// Read TOKEN_TITLE nodes, not the outer TOKEN_LIST_ITEM: the item container here doesn't merge its
// descendants, so the nested title Text never surfaces on the item node's semantics.
fun getDisplayedTokenTitles(): List<String> =
semanticsProvider.onAllNodes(
withTestTag(TokenElementsTestTags.TOKEN_TITLE) and
withAnyAncestor(withTestTag(OrganizeTokensScreenTestTags.TOKENS_LAZY_LIST)),
useUnmergedTree = true,
).displayedTextsInVisualOrder()
fun tokenDraggableButton(tokenTitle: String): KNode {
return lazyList.child {
hasTestTag(OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE)
@ -108,6 +122,47 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
)
}
}
// Reorder by synthesizing a drag on the source row's handle: down, hold, step to the destination
// centre, lift — a single `swipe` won't engage the reorder detector. Only valid within one network
// group (isValidDropTarget), so source and destination must share a group.
fun dragToken(source: String, destination: String) {
val sourceHandle = semanticsProvider.onNode(dragHandleMatcher(source), useUnmergedTree = true)
val sourceNode = sourceHandle.fetchSemanticsNode()
val destinationNode = semanticsProvider
.onNode(tokenItemMatcher(destination), useUnmergedTree = true)
.fetchSemanticsNode()
// performTouchInput coordinates are local to the source node; express both endpoints there.
val origin = sourceNode.positionInRoot
val start = sourceNode.boundsInRoot.center - origin
val end = destinationNode.boundsInRoot.center - origin
sourceHandle.performTouchInput {
down(start)
advanceEventTime(DRAG_HOLD_MS)
repeat(times = DRAG_STEPS) { step ->
moveTo(lerp(start = start, stop = end, fraction = (step + 1).toFloat() / DRAG_STEPS))
advanceEventTime(DRAG_STEP_MS)
}
up()
}
}
// The drag handle sits under an untagged wrapper inside TOKEN_NON_FIAT_BLOCK, so anchor on the
// enclosing TOKEN_LIST_ITEM (one handle + one title per item) instead of the direct parent.
private fun dragHandleMatcher(tokenTitle: String): SemanticsMatcher =
withTestTag(OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE) and
withAnyAncestor(tokenItemMatcher(tokenTitle))
private fun tokenItemMatcher(tokenTitle: String): SemanticsMatcher =
withTestTag(OrganizeTokensScreenTestTags.TOKEN_LIST_ITEM) and hasAnyDescendant(withText(tokenTitle))
private companion object {
const val DRAG_HOLD_MS = 200L
const val DRAG_STEPS = 16
const val DRAG_STEP_MS = 16L
}
}
internal fun BaseTestCase.onOrganizeTokensScreen(function: OrganizeTokensPageObject.() -> Unit) =

View file

@ -0,0 +1,86 @@
package com.tangem.tests.main
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.scenarios.addNewCardWalletWithoutSync
import com.tangem.scenarios.assertOrganizeTokensMatch
import com.tangem.scenarios.getMainScreenTokensOrder
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.switchToPreviousWallet
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onAddAndManageBottomSheet
import com.tangem.screens.onMainScreen
import com.tangem.screens.onOrganizeTokensScreen
import com.tangem.tap.domain.sdk.mocks.content.Wallet2MockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Assert.assertEquals
import org.junit.Test
@HiltAndroidTest
class OrganizeTokensTest : BaseTestCase() {
@AllureId("71")
@DisplayName("Organize tokens: Correct tokens list displaying for current wallet")
@Test
fun organizeTokensCorrectTokensListDisplaying() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Add a second card wallet") {
addNewCardWalletWithoutSync(Wallet2MockContent)
}
step("Switch to first wallet Main screen") {
switchToPreviousWallet()
}
val firstWalletTokens = getMainScreenTokensOrder()
assertOrganizeTokensMatch(firstWalletTokens)
step("Switch to second wallet Main screen") {
onMainScreen { swipeToAdjacentWallet(toPrevious = false) }
}
val secondWalletTokens = getMainScreenTokensOrder()
assertOrganizeTokensMatch(secondWalletTokens)
}
}
@AllureId("2753")
@DisplayName("Organize tokens: Tokens order changing")
@Test
fun organizeTokensOrderChanging() {
setupHooks().run{
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Organize tokens' bottom-sheet") {
onMainScreen { clickDisplayedAddAndManageButton() }
onAddAndManageBottomSheet { organizeTokensButton.clickWithAssertion() }
}
step("Drag the 3rd token onto the 2nd position and assert the new order") {
onOrganizeTokensScreen {
val sourceIndex = 2
val destinationIndex = 1
val before = getDisplayedTokenTitles()
require(before.size > sourceIndex && before.size > destinationIndex) {
"Expected at least ${maxOf(sourceIndex, destinationIndex) + 1} tokens to reorder, but got ${before.size}: $before"
}
dragToken(source = before[sourceIndex], destination = before[destinationIndex])
val expected = before.toMutableList().apply {
add(destinationIndex, removeAt(sourceIndex))
}
assertEquals(expected, getDisplayedTokenTitles())
}
}
}
}
}

View file

@ -4,12 +4,17 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.addNewCardWalletWithoutSync
import com.tangem.scenarios.getMainScreenTokensOrder
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
import com.tangem.tap.domain.sdk.mocks.content.Wallet2MockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@HiltAndroidTest
@ -48,4 +53,54 @@ class TokenListTest : BaseTestCase() {
}
}
@AllureId("177")
@DisplayName("Main: token list differs after switching to a second wallet")
@Test
fun tokenListChangedAfterSwitchingWalletTest() {
val userTokensState = "ReducedTokens"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
}
).run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
val firstWalletTokens = getMainScreenTokensOrder()
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, userTokensState)
}
step("Add a second card wallet") {
addNewCardWalletWithoutSync(Wallet2MockContent)
}
val secondWalletTokens = getMainScreenTokensOrder()
step("Assert both wallets exposed a non-empty token list") {
assertTrue(
"First wallet token list should not be empty",
firstWalletTokens.isNotEmpty(),
)
assertTrue(
"Second wallet token list should not be empty",
secondWalletTokens.isNotEmpty(),
)
}
step("Assert the two wallets show different token lists") {
assertNotEquals(
"Expected the second wallet's token list to differ from the first " +
"(first=$firstWalletTokens, second=$secondWalletTokens)",
firstWalletTokens,
secondWalletTokens,
)
}
}
}
}