Updated on 2026-08-14
This commit is contained in:
parent
fda7f64fd9
commit
32632f7309
2 changed files with 49 additions and 132 deletions
|
|
@ -126,24 +126,13 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switches to the previous/next wallet in the pager.
|
* 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
|
||||||
* The pager only accepts horizontal swipes while the collapsing balance header is fully
|
* whenever the wallet identity doesn't change, expand the header and try again.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalTestApi::class)
|
|
||||||
fun swipeToAdjacentWallet(toPrevious: Boolean) {
|
fun swipeToAdjacentWallet(toPrevious: Boolean) {
|
||||||
val before = displayedWalletIdentity()
|
val before = displayedWalletIdentity()
|
||||||
repeat(times = WALLET_SWITCH_ATTEMPTS) {
|
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)
|
swipeCurrentPage(toPrevious)
|
||||||
val now = displayedWalletIdentity()
|
val now = displayedWalletIdentity()
|
||||||
if (now != null && now != before) return
|
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")
|
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. */
|
// Expand only *after* a swipe that didn't page: if the header is already pinned, a swipe-down here
|
||||||
@OptIn(ExperimentalTestApi::class)
|
// would trigger pull-to-refresh and un-pin it, breaking the horizontal swipe.
|
||||||
private fun expandCollapsingHeader() {
|
private fun expandCollapsingHeader() {
|
||||||
onScreenPageNode(withTestTag(MainScreenTestTags.SCREEN_CONTAINER))?.performTouchInput {
|
onScreenPage()?.performTouchInput {
|
||||||
swipeDown(startY = visibleSize.height * 0.3f, endY = visibleSize.height * 0.8f)
|
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) {
|
private fun swipeCurrentPage(toPrevious: Boolean) {
|
||||||
onScreenPageNode(withTestTag(MainScreenTestTags.SCREEN_CONTAINER))?.performTouchInput {
|
onScreenPage()?.performTouchInput { if (toPrevious) swipeRight() else swipeLeft() }
|
||||||
if (toPrevious) swipeRight() else swipeLeft()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Identity = title + balance: a still-restoring wallet has no CARD_TITLE but always a WALLET_BALANCE.
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
private fun displayedWalletIdentity(): String? {
|
private fun displayedWalletIdentity(): String? {
|
||||||
val page = onScreenPageRect() ?: return null
|
val title = onScreenPageChild(withTestTag(MainScreenTestTags.CARD_TITLE))?.firstText()
|
||||||
val title = firstTextInPage(page, MainScreenTestTags.CARD_TITLE)
|
val balance = onScreenPageChild(withTestTag(MainScreenTestTags.WALLET_BALANCE))?.firstText()
|
||||||
val balance = firstTextInPage(page, MainScreenTestTags.WALLET_BALANCE)
|
|
||||||
return listOfNotNull(title, balance).joinToString(separator = "|").ifBlank { null }
|
return listOfNotNull(title, balance).joinToString(separator = "|").ifBlank { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Horizontal bounds of the on-screen wallet page, taken from its token list. SCREEN_CONTAINER is
|
* The full-width pager-page container currently on-screen. The pager keeps adjacent pages composed
|
||||||
* reliably one-per-page and full-width (unlike WALLET_LIST_ITEM, which also tags a multi-page
|
* off-screen at ±pageWidth (so [assertIsDisplayed] can't tell them apart), hence selection by
|
||||||
* wrapper), so it is the trustworthy anchor for "which page is on-screen".
|
* geometry: the on-screen page is the only one whose left edge is within half a page of x=0.
|
||||||
*/
|
*/
|
||||||
private fun onScreenPageRect(): Rect? =
|
private fun onScreenPage(): SemanticsNodeInteraction? =
|
||||||
onScreenPageNode(withTestTag(MainScreenTestTags.SCREEN_CONTAINER))?.fetchSemanticsNode()?.boundsInRoot
|
firstNodeMatching(withTestTag(MainScreenTestTags.SCREEN_CONTAINER), useUnmergedTree = false) {
|
||||||
|
abs(it.left) < it.width / 2f
|
||||||
/** 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)
|
/** A node matching [matcher] whose centre lies within the on-screen page (skips zero-size off-screen copies). */
|
||||||
for (i in 0 until nodes.fetchSemanticsNodes().size) {
|
private fun onScreenPageChild(matcher: SemanticsMatcher): SemanticsNodeInteraction? {
|
||||||
val text = runCatching {
|
val page = onScreenPage()?.fetchSemanticsNode()?.boundsInRoot ?: return null
|
||||||
val bounds = nodes[i].fetchSemanticsNode().boundsInRoot
|
return firstNodeMatching(matcher) {
|
||||||
// Skip zero-size nodes: off-screen pager pages collapse to bounds (0,0,0,0), whose
|
it.width > 0f && it.height > 0f && it.center.x >= page.left && it.center.x < page.right
|
||||||
// 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 SemanticsNodeInteraction.firstText(): String? = fetchSemanticsNode().firstTextOrNull()
|
||||||
* The full-width pager-page node matching [matcher] that is currently centred on-screen.
|
|
||||||
*
|
// Single geometry primitive behind the pager helpers: the first node matching [matcher] whose
|
||||||
* The wallet pager keeps adjacent pages composed (beyondViewportPageCount=1) at ±pageWidth, so
|
// bounds satisfy [predicate]. Replaces the per-caller onAllNodes(...)[i] loops.
|
||||||
* `onAllNodes` returns nodes from off-screen pages too. `assertIsDisplayed` is unreliable here
|
private fun firstNodeMatching(
|
||||||
* (off-screen pages are placed, not clipped away), so we select by geometry: the on-screen page
|
matcher: SemanticsMatcher,
|
||||||
* is the only full-width node whose left edge is within half a page of x=0.
|
useUnmergedTree: Boolean = true,
|
||||||
*/
|
predicate: (Rect) -> Boolean,
|
||||||
private fun onScreenPageNode(matcher: SemanticsMatcher): SemanticsNodeInteraction? {
|
): SemanticsNodeInteraction? {
|
||||||
val nodes = semanticsProvider.onAllNodes(matcher)
|
val nodes = semanticsProvider.onAllNodes(matcher, useUnmergedTree = useUnmergedTree)
|
||||||
for (i in 0 until nodes.fetchSemanticsNodes().size) {
|
repeat(times = nodes.fetchSemanticsNodes().size) { index ->
|
||||||
val node = nodes[i]
|
val node = nodes[index]
|
||||||
val onScreen = runCatching {
|
val matches = runCatching { predicate(node.fetchSemanticsNode().boundsInRoot) }.getOrDefault(false)
|
||||||
val bounds = node.fetchSemanticsNode().boundsInRoot
|
if (matches) return node
|
||||||
abs(bounds.left) < bounds.width / 2f
|
|
||||||
}.getOrDefault(false)
|
|
||||||
if (onScreen) return node
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -482,49 +443,21 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
|
||||||
useUnmergedTree = true
|
useUnmergedTree = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Collapses the header, scrolls to and clicks the 'Add & manage' button on the on-screen wallet page. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalTestApi::class)
|
@OptIn(ExperimentalTestApi::class)
|
||||||
fun clickDisplayedAddAndManageButton() {
|
fun clickDisplayedAddAndManageButton() {
|
||||||
val container = onScreenPageNode(withTestTag(MainScreenTestTags.SCREEN_CONTAINER))
|
val container = onScreenPage() ?: error("No on-screen wallet page found")
|
||||||
?: error("No on-screen wallet page found")
|
// Best-effort: the button is a footer outside the scrollable list, so performScrollToNode can
|
||||||
// Best-effort: collapse the header and scroll the button into view. The button is a footer
|
// throw when it's already visible — that must not abort the click below.
|
||||||
// outside the scrollable list, so performScrollToNode can throw — harmless when it's already
|
|
||||||
// visible, but it must not abort the click below.
|
|
||||||
runCatching {
|
runCatching {
|
||||||
container.performTouchInput {
|
container.performTouchInput {
|
||||||
swipeUp(startY = visibleSize.height * 0.6f, endY = visibleSize.height * 0.1f)
|
swipeUp(startY = visibleSize.height * 0.6f, endY = visibleSize.height * 0.1f)
|
||||||
}
|
}
|
||||||
container.performScrollToNode(withTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON))
|
container.performScrollToNode(withTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON))
|
||||||
}
|
}
|
||||||
|
val button = onScreenPageChild(withTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON))
|
||||||
val page = container.fetchSemanticsNode().boundsInRoot
|
?: error("'Add & manage' button is not displayed on the current wallet page")
|
||||||
val buttons = semanticsProvider.onAllNodes(
|
button.performClick()
|
||||||
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 searchThroughMarketPlaceholder: KNode = child {
|
val searchThroughMarketPlaceholder: KNode = child {
|
||||||
|
|
|
||||||
|
|
@ -98,15 +98,8 @@ class OrganizeTokensPageObject(private val semanticsProvider: SemanticsNodeInter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Read TOKEN_TITLE nodes, not the outer TOKEN_LIST_ITEM: the item container here doesn't merge its
|
||||||
* Reads displayed token titles from the 'Organize tokens' screen in visual order
|
// descendants, so the nested title Text never surfaces on the item node's semantics.
|
||||||
* (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.
|
|
||||||
*/
|
|
||||||
fun getDisplayedTokenTitles(): List<String> =
|
fun getDisplayedTokenTitles(): List<String> =
|
||||||
semanticsProvider.onAllNodes(
|
semanticsProvider.onAllNodes(
|
||||||
withTestTag(TokenElementsTestTags.TOKEN_TITLE) and
|
withTestTag(TokenElementsTestTags.TOKEN_TITLE) and
|
||||||
|
|
@ -130,18 +123,9 @@ class OrganizeTokensPageObject(private val semanticsProvider: SemanticsNodeInter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Reorder by synthesizing a drag on the source row's handle: down, hold, step to the destination
|
||||||
* Drag-and-drops the [source] token onto the [destination] token's slot to reorder the list,
|
// centre, lift — a single `swipe` won't engage the reorder detector. Only valid within one network
|
||||||
* the Android analog of iOS `press(forDuration:thenDragTo:)`.
|
// group (isValidDropTarget), so source and destination must share a group.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
fun dragToken(source: String, destination: String) {
|
fun dragToken(source: String, destination: String) {
|
||||||
val sourceHandle = semanticsProvider.onNode(dragHandleMatcher(source), useUnmergedTree = true)
|
val sourceHandle = semanticsProvider.onNode(dragHandleMatcher(source), useUnmergedTree = true)
|
||||||
val sourceNode = sourceHandle.fetchSemanticsNode()
|
val sourceNode = sourceHandle.fetchSemanticsNode()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue