Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 15:13:40 +03:00
parent fda7f64fd9
commit 32632f7309
2 changed files with 49 additions and 132 deletions

View file

@ -126,24 +126,13 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
}
/**
* Switches to the previous/next wallet in the pager.
*
* The pager only accepts horizontal swipes while the collapsing balance header is fully
* expanded and pinned to the top (`canPagerScroll = heightOffset == 0`). A *partially* collapsed
* header still shows the wallet card, yet the horizontal swipe is a silent no-op so a visible
* card is not a reliable "ready to page" signal. We therefore try the swipe, and whenever the
* wallet identity doesn't change we expand the header and retry, so a no-op swipe can't pass
* unnoticed. Identity is title+balance rather than just the name: a still-restoring wallet (hot
* wallet) shows "Restoring…" instead of a title, but always shows a balance.
* 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.
*/
@OptIn(ExperimentalTestApi::class)
fun swipeToAdjacentWallet(toPrevious: Boolean) {
val before = displayedWalletIdentity()
repeat(times = WALLET_SWITCH_ATTEMPTS) {
// Swipe first. The pager only pages while the header is pinned to the top; if it already
// is (the common case), a swipe-down here would instead trigger pull-to-refresh and
// un-pin it, breaking the horizontal swipe — so we only expand *after* a swipe that
// didn't page (i.e. the header was collapsed).
swipeCurrentPage(toPrevious)
val now = displayedWalletIdentity()
if (now != null && now != before) return
@ -152,85 +141,57 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
error("Wallet did not switch from '$before' after $WALLET_SWITCH_ATTEMPTS attempts")
}
/** Expands the collapsing balance header (pins the balance to the top) so the pager can page. */
@OptIn(ExperimentalTestApi::class)
// 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() {
onScreenPageNode(withTestTag(MainScreenTestTags.SCREEN_CONTAINER))?.performTouchInput {
onScreenPage()?.performTouchInput {
swipeDown(startY = visibleSize.height * 0.3f, endY = visibleSize.height * 0.8f)
}
}
/**
* Pages to the previous/next wallet by swiping horizontally on the on-screen token list.
* The list is a vertical scroller, so it passes the horizontal drag up to the HorizontalPager
* instead of consuming it (a per-page balance card may consume horizontal gestures itself).
*/
@OptIn(ExperimentalTestApi::class)
private fun swipeCurrentPage(toPrevious: Boolean) {
onScreenPageNode(withTestTag(MainScreenTestTags.SCREEN_CONTAINER))?.performTouchInput {
if (toPrevious) swipeRight() else swipeLeft()
}
onScreenPage()?.performTouchInput { if (toPrevious) swipeRight() else swipeLeft() }
}
/**
* A best-effort identity of the on-screen wallet: its title combined with its balance. Works in
* every sync state a still-restoring wallet has no [MainScreenTestTags.CARD_TITLE] but always
* shows a [MainScreenTestTags.WALLET_BALANCE] so the value reliably differs across a swap.
*/
// Identity = title + balance: a still-restoring wallet has no CARD_TITLE but always a WALLET_BALANCE.
private fun displayedWalletIdentity(): String? {
val page = onScreenPageRect() ?: return null
val title = firstTextInPage(page, MainScreenTestTags.CARD_TITLE)
val balance = firstTextInPage(page, MainScreenTestTags.WALLET_BALANCE)
val title = onScreenPageChild(withTestTag(MainScreenTestTags.CARD_TITLE))?.firstText()
val balance = onScreenPageChild(withTestTag(MainScreenTestTags.WALLET_BALANCE))?.firstText()
return listOfNotNull(title, balance).joinToString(separator = "|").ifBlank { null }
}
/**
* Horizontal bounds of the on-screen wallet page, taken from its token list. SCREEN_CONTAINER is
* reliably one-per-page and full-width (unlike WALLET_LIST_ITEM, which also tags a multi-page
* wrapper), so it is the trustworthy anchor for "which page is on-screen".
* 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 onScreenPageRect(): Rect? =
onScreenPageNode(withTestTag(MainScreenTestTags.SCREEN_CONTAINER))?.fetchSemanticsNode()?.boundsInRoot
/** First text of the node tagged [tag] whose centre lies within the on-screen [page]. */
private fun firstTextInPage(page: Rect, tag: String): String? {
val nodes = semanticsProvider.onAllNodes(withTestTag(tag), useUnmergedTree = true)
for (i in 0 until nodes.fetchSemanticsNodes().size) {
val text = runCatching {
val bounds = nodes[i].fetchSemanticsNode().boundsInRoot
// Skip zero-size nodes: off-screen pager pages collapse to bounds (0,0,0,0), whose
// centre (0,0) would otherwise pass the "centre within page" test (page.left is 0)
// and leak a stale wallet's text — the exact cause of undetected wallet switches.
if (bounds.width > 0f && bounds.height > 0f &&
bounds.center.x >= page.left && bounds.center.x < page.right
) {
nodes[i].fetchSemanticsNode().firstTextOrNull()
} else {
null
}
}.getOrNull()
if (text != null) return text
}
return null
private fun onScreenPage(): SemanticsNodeInteraction? =
firstNodeMatching(withTestTag(MainScreenTestTags.SCREEN_CONTAINER), useUnmergedTree = false) {
abs(it.left) < it.width / 2f
}
/**
* The full-width pager-page node matching [matcher] that is currently centred on-screen.
*
* The wallet pager keeps adjacent pages composed (beyondViewportPageCount=1) at ±pageWidth, so
* `onAllNodes` returns nodes from off-screen pages too. `assertIsDisplayed` is unreliable here
* (off-screen pages are placed, not clipped away), so we select by geometry: the on-screen page
* is the only full-width node whose left edge is within half a page of x=0.
*/
private fun onScreenPageNode(matcher: SemanticsMatcher): SemanticsNodeInteraction? {
val nodes = semanticsProvider.onAllNodes(matcher)
for (i in 0 until nodes.fetchSemanticsNodes().size) {
val node = nodes[i]
val onScreen = runCatching {
val bounds = node.fetchSemanticsNode().boundsInRoot
abs(bounds.left) < bounds.width / 2f
}.getOrDefault(false)
if (onScreen) return node
/** 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
}
@ -482,49 +443,21 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
useUnmergedTree = true
}
/**
* Collapses the header, scrolls to and clicks the 'Add & manage' button on the wallet page
* that is actually on-screen.
*
* The wallet pager keeps the adjacent page composed (beyondViewportPageCount=1) at ±pageWidth,
* so both pages' MAIN_SCREEN_CONTAINER / button nodes are in the tree at once. We select the
* on-screen page by geometry (see [onScreenPageNode]) and click only the button within it.
*/
/** Collapses the header, scrolls to and clicks the 'Add & manage' button on the on-screen wallet page. */
@OptIn(ExperimentalTestApi::class)
fun clickDisplayedAddAndManageButton() {
val container = onScreenPageNode(withTestTag(MainScreenTestTags.SCREEN_CONTAINER))
?: error("No on-screen wallet page found")
// Best-effort: collapse the header and scroll the button into view. The button is a footer
// outside the scrollable list, so performScrollToNode can throw — harmless when it's already
// visible, but it must not abort the click below.
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 page = container.fetchSemanticsNode().boundsInRoot
val buttons = semanticsProvider.onAllNodes(
withTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON),
useUnmergedTree = true,
)
for (i in 0 until buttons.fetchSemanticsNodes().size) {
val clicked = runCatching {
val bounds = buttons[i].fetchSemanticsNode().boundsInRoot
// Skip zero-size (off-screen page) buttons and require the centre within the page.
if (bounds.width > 0f && bounds.height > 0f &&
bounds.center.x >= page.left && bounds.center.x < page.right
) {
buttons[i].performClick()
true
} else {
false
}
}.getOrDefault(false)
if (clicked) return
}
error("'Add & manage' button is not displayed on the current wallet page")
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 {

View file

@ -98,15 +98,8 @@ class OrganizeTokensPageObject(private val semanticsProvider: SemanticsNodeInter
}
}
/**
* Reads displayed token titles from the 'Organize tokens' screen in visual order
* (top-to-bottom, then left-to-right). Mirrors [MainScreenPageObject.getDisplayedTokenTitles]
* so the two lists can be compared directly.
*
* Titles are read from the [TokenElementsTestTags.TOKEN_TITLE] nodes rather than the outer
* TOKEN_LIST_ITEM: the item container here does not merge its descendants, so the title Text
* (nested inside the title Row) never surfaces on the item node's semantics.
*/
// 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
@ -130,18 +123,9 @@ class OrganizeTokensPageObject(private val semanticsProvider: SemanticsNodeInter
}
}
/**
* Drag-and-drops the [source] token onto the [destination] token's slot to reorder the list,
* the Android analog of iOS `press(forDuration:thenDragTo:)`.
*
* The list is a reorderable LazyColumn (sh.calvin.reorderable) driven from each row's drag
* handle ([OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE]). We synthesize the gesture manually:
* press down on the source handle, hold briefly, then move in small steps to the destination
* row's centre and lift a single `swipe` won't engage the reorder detector.
*
* NOTE: reordering is only permitted **within the same network group** (`isValidDropTarget`),
* so both tokens must belong to the same group for the drop to take effect.
*/
// 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()