tangem-app-android-audited/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt
2026-07-13 23:39:01 +03:00

548 lines
21 KiB
Kotlin

package com.tangem.scenarios
import androidx.compose.ui.test.click
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
import com.tangem.common.extensions.assertVisibility
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWhenEnabled
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.extractText
import com.tangem.common.extensions.isDisplayedSafely
import com.tangem.core.ui.R as CoreUiR
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.HotWalletAccessCodeTestTags
import com.tangem.screens.*
import com.tangem.tap.domain.sdk.mocks.MockContent
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step
import com.tangem.common.ui.R as CommonUiR
private val firstStoryIndex = 0
private val firstStoryTitle = getResourceString(CommonUiR.string.swap_story_first_title_v2)
private val firstStorySubtitle = getResourceString(CommonUiR.string.swap_story_first_subtitle_v2)
private val secondStoryIndex = 1
private val secondStoryTitle = getResourceString(CommonUiR.string.swap_story_second_title_v2)
private val secondStorySubtitle = getResourceString(CommonUiR.string.swap_story_second_subtitle_v2)
private val thirdStoryIndex = 2
private val thirdStoryTitle = getResourceString(CommonUiR.string.swap_story_third_title_v2)
private val thirdStorySubtitle = getResourceString(CommonUiR.string.swap_story_third_subtitle_v2)
private val forthStoryIndex = 3
private val forthStoryTitle = getResourceString(CommonUiR.string.swap_story_forth_title_v2)
private val forthStorySubtitle = getResourceString(CommonUiR.string.swap_story_forth_subtitle_v2)
fun BaseTestCase.openSwapScreen(
from: SwapEntryPoint,
storiesExist: Boolean = true,
) {
when (from) {
SwapEntryPoint.MainScreen -> step("Click on 'Swap' button on 'Main' screen") {
onMainScreen { swapButton.performClick() }
}
SwapEntryPoint.TokenDetails -> step("Click on 'Swap' button on 'Token details' screen") {
onTokenDetailsScreen { swapButton.clickWhenEnabled() }
}
SwapEntryPoint.MarketsTokenDetails -> step("Click on 'Swap' button on 'Markets' token details screen") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() }
}
SwapEntryPoint.TokenActionsBottomSheet -> step("Click on 'Swap' button on token actions bottom sheet") {
onTokenActionsBottomSheet { swapButton.performClick() }
}
}
if (storiesExist) {
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
}
} else {
step("Assert 'Stories' screen is not displayed") {
onSwapStoriesScreen { container.assertDoesNotExist() }
}
}
step("Assert 'Swap' screen title is displayed") {
onSwapTokenScreen { title.assertIsDisplayed() }
}
}
fun BaseTestCase.checkStoriesContent(
storyIndex: Int,
storyTitle: String,
storySubtitle: String,
) {
step("Assert 'Close' button is displayed") {
onSwapStoriesScreen { closeButton.assertIsDisplayed() }
}
step("Assert progress bar item №${storyIndex + 1} is displayed") {
onSwapStoriesScreen { progressBarItem(storyIndex).assertIsDisplayed() }
}
step("Assert story title is $storyTitle") {
onSwapStoriesScreen { title.assertTextContains(storyTitle) }
}
step("Assert story subtitle is $storySubtitle") {
onSwapStoriesScreen { subtitle.assertTextContains(storySubtitle) }
}
}
fun BaseTestCase.checkStoriesChanges() {
step("Check title and subtitle for story №${firstStoryIndex + 1}") {
checkStoriesContent(
storyIndex = firstStoryIndex,
storyTitle = firstStoryTitle,
storySubtitle = firstStorySubtitle
)
}
step("Click on right side") {
onSwapStoriesScreen { container.performTouchInput { click(centerRight) } }
}
step("Check title and subtitle for story №${secondStoryIndex + 1}") {
checkStoriesContent(
storyIndex = secondStoryIndex,
storyTitle = secondStoryTitle,
storySubtitle = secondStorySubtitle
)
}
step("Click on right side") {
onSwapStoriesScreen { container.performTouchInput { click(centerRight) } }
}
step("Check title and subtitle for story №${thirdStoryIndex + 1}") {
checkStoriesContent(
storyIndex = thirdStoryIndex,
storyTitle = thirdStoryTitle,
storySubtitle = thirdStorySubtitle
)
}
step("Click on right side") {
onSwapStoriesScreen { container.performTouchInput { click(centerRight) } }
}
step("Check title and subtitle for story №${forthStoryIndex + 1}") {
checkStoriesContent(
storyIndex = forthStoryIndex,
storyTitle = forthStoryTitle,
storySubtitle = forthStorySubtitle
)
}
step("Click on left side") {
onSwapStoriesScreen { container.performTouchInput { click(centerLeft) } }
}
step("Check title and subtitle for story №${thirdStoryIndex + 1}") {
checkStoriesContent(
storyIndex = thirdStoryIndex,
storyTitle = thirdStoryTitle,
storySubtitle = thirdStorySubtitle
)
}
step("Click on left side") {
onSwapStoriesScreen { container.performTouchInput { click(centerLeft) } }
}
step("Check title and subtitle for story №${secondStoryIndex + 1}") {
checkStoriesContent(
storyIndex = secondStoryIndex,
storyTitle = secondStoryTitle,
storySubtitle = secondStorySubtitle
)
}
step("Click on left side") {
onSwapStoriesScreen { container.performTouchInput { click(centerLeft) } }
}
step("Check title and subtitle for story №${firstStoryIndex + 1}") {
checkStoriesContent(
storyIndex = firstStoryIndex,
storyTitle = firstStoryTitle,
storySubtitle = firstStorySubtitle
)
}
}
fun BaseTestCase.selectFeeType(feeType: FeeType, selectedFeeAmount: String) {
step("Click on 'Select fee' icon") {
onSwapTokenScreen { selectFeeIcon.performClick() }
}
when (feeType) {
FeeType.Market -> {
step("Click on 'Market' item") {
onSwapSelectNetworkFeeBottomSheet { marketSelectorItem.clickWithAssertion() }
}
step("Assert fee amount is equal to 'Market' fee:'$selectedFeeAmount'") {
onSwapTokenScreen { feeAmount.assertTextContains(selectedFeeAmount, substring = true) }
}
}
FeeType.Fast -> {
step("Click on 'Fast' item") {
onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.clickWithAssertion() }
}
step("Assert fee amount is equal to 'Fast' fee:'$selectedFeeAmount'") {
onSwapTokenScreen { feeAmount.assertTextContains(selectedFeeAmount, substring = true) }
}
}
}
}
fun BaseTestCase.selectFeeTypeAndReadFee(feeType: FeeType): String {
step("Click on 'Select fee' icon") {
onSwapTokenScreen { selectFeeIcon.performClick() }
}
step("Click on '$feeType' item") {
onSwapSelectNetworkFeeBottomSheet {
when (feeType) {
FeeType.Market -> marketSelectorItem.clickWithAssertion()
FeeType.Fast -> fastSelectorItem.clickWithAssertion()
}
}
}
var fee = ""
step("Read displayed '$feeType' fee amount") {
onSwapTokenScreen { fee = feeAmount.extractText() }
}
return fee
}
fun BaseTestCase.chackUnableToCoverFeeNotification(networkName: String, currencySymbol: String) {
step("Assert 'Unable to cover '$networkName' fee notification title is displayed'") {
onSwapTokenScreen { unableToCoverFeeNotificationTitle(networkName).assertIsDisplayed() }
}
step("Assert 'Unable to cover '$networkName' fee notification text is displayed'") {
onSwapTokenScreen {
unableToCoverFeeNotificationText(
currencyName = networkName,
currencySymbol = currencySymbol
).assertIsDisplayed()
}
}
step("Assert 'Unable to cover '$networkName' fee notification icon is displayed'") {
onSwapTokenScreen { unableToCoverFeeNotificationIcon(networkName).assertIsDisplayed() }
}
}
fun BaseTestCase.checkSwapWarning(
title: String,
message: String,
isDisplayed: Boolean = true,
swapButtonIsDisabled: Boolean = isDisplayed,
) {
val assertDisplay = if (isDisplayed) "displayed" else "not displayed"
step("Assert warning title is $assertDisplay") {
onSwapTokenScreen {
warningTitle(title).assertVisibility(isDisplayed)
}
}
step("Assert warning icon is $assertDisplay") {
onSwapTokenScreen {
warningIcon(message).assertVisibility(isDisplayed)
}
}
step("Assert warning message is $assertDisplay") {
onSwapTokenScreen {
warningMessage(message).assertVisibility(isDisplayed)
}
}
if (swapButtonIsDisabled)
step("Assert 'Swap' button is disabled") {
onSwapTokenScreen {
swapButton.assertIsNotEnabled()
}
}
else
step("Assert 'Swap' button is enabled") {
onSwapTokenScreen {
swapButton.assertIsEnabled()
}
}
}
/** Scans a card wallet and opens Swap for [tokenName] in [fromAccountName] without choosing the receive token yet. */
fun BaseTestCase.openSwapForTokenInAccount(
tokenName: String,
fromAccountName: String = "Account 1",
mockContent: MockContent? = null,
) {
step("Open 'Main' screen") {
openMainScreen(mockContent = mockContent)
}
step("Synchronize addresses") {
synchronizeAddresses(assertBalance = false)
}
step("Wait for addresses to be generated") {
waitForAddressesGenerated()
}
navigateToSwapForToken(tokenName, fromAccountName)
}
/** Opens Swap for [tokenName] in [fromAccountName] and picks it again in [toAccountName] to enter Transfer mode; needs a two-accounts-same-token mock. */
fun BaseTestCase.openSwapInTransferMode(
tokenName: String,
fromAccountName: String = "Account 1",
toAccountName: String = "Account 2",
mockContent: MockContent? = null,
) {
openSwapForTokenInAccount(tokenName, fromAccountName, mockContent)
step("Choose identical receive token '$tokenName' from '$toAccountName'") {
chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName)
}
}
/** Like [openSwapInTransferMode] but imports a hot wallet first — required for broadcasting flows (the mock card can't sign). */
fun BaseTestCase.openSwapInTransferModeWithHotWallet(
tokenName: String,
seedPhrase: String,
fromAccountName: String = "Account 1",
toAccountName: String = "Account 2",
) {
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
step("Generate missing addresses") {
generateMissingHotWalletAddresses()
}
step("Wait for addresses to be generated") {
waitForAddressesGenerated()
}
navigateToSwapForToken(tokenName, fromAccountName)
step("Choose identical receive token '$tokenName' from '$toAccountName'") {
chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName)
}
}
private fun BaseTestCase.navigateToSwapForToken(tokenName: String, fromAccountName: String) {
step("Collapse header") {
onMainScreen { collapseHeader() }
}
step("Scroll '$fromAccountName' into view (semantics, not touch — avoids the Markets sheet)") {
onMainScreen { scrollToAccount(fromAccountName) }
}
step("Expand account '$fromAccountName' and reveal token '$tokenName'") {
onMainScreen {
findAccountSectionByName(fromAccountName).clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onMainScreen { findTokenInAnyAccountByName(tokenName).assertIsDisplayed() }
},
)
}
}
step("Click on token with name: '$tokenName'") {
onMainScreen { findTokenInAnyAccountByName(tokenName).clickWithAssertion() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
}
// Hot wallets derive locally, so the second account's missing addresses are generated without a card scan when prompted.
fun BaseTestCase.generateMissingHotWalletAddresses() {
var notificationShown = false
onMainScreen { notificationShown = synchronizeAddressesButton.isDisplayedSafely() }
if (notificationShown) {
onMainScreen { synchronizeAddressesButton.performClick() }
}
}
// The receive selector shows "No address" until the second account's derivation lands; the prompt disappears when it does.
fun BaseTestCase.waitForAddressesGenerated() {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
var generated = false
onMainScreen { generated = !synchronizeAddressesButton.isDisplayedSafely() }
generated
}
}
/** Picks the identical [tokenName] in [receiveAccountName]; the receive list collapses the other account, so its header is expanded first. */
fun BaseTestCase.chooseIdenticalReceiveToken(tokenName: String, receiveAccountName: String) {
step("Click on 'Choose token' button") {
onSwapTokenScreen { chooseTokenButton.performClick() }
}
step("Expand account '$receiveAccountName' in receive selector") {
onSwapSelectTokenScreen { tokenWithName(receiveAccountName).performClick() }
}
step("Click on token with name '$tokenName'") {
onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() }
}
}
fun BaseTestCase.chooseReceiveToken(tokenName: String) {
step("Click on 'Choose token' button") {
onSwapTokenScreen { chooseTokenButton.performClick() }
}
step("Click on token with name '$tokenName'") {
onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() }
}
}
/** Reopens the receive selector via the receive-card icon and picks [tokenName] directly — the reopened selector keeps the account expanded. */
fun BaseTestCase.changeReceiveToken(tokenName: String) {
step("Open receive token selector") {
onSwapTokenScreen { receiveSelectTokenIcon.performClick() }
}
step("Click on token with name '$tokenName'") {
onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() }
}
}
/**
* From a clean start: open the main screen (cold by default, or an existing hot wallet when
* [seedPhrase] is given), open Swap for [fromTokenName], choose [receiveTokenName] to receive and
* enter [amount]. Scenario states stay in the test body.
*/
fun BaseTestCase.openSwapAmountScreen(
fromTokenName: String,
receiveTokenName: String,
amount: String,
seedPhrase: String? = null,
) {
if (seedPhrase == null) {
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
} else {
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
}
step("Click on token with name: '$fromTokenName'") {
onMainScreen { tokenWithTitleAndAddress(fromTokenName).clickWithAssertion() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails)
}
step("Choose receive token '$receiveTokenName'") {
chooseReceiveToken(receiveTokenName)
}
step("Input swap amount '$amount'") {
waitForIdle()
onSwapTokenScreen {
textInput.clickWithAssertion()
textInput.performTextReplacement(amount)
}
}
step("Wait for the receive amount to load") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapTokenScreen { receiveAmount.assertIsDisplayed() } }.isSuccess
}
}
}
/**
* Opens the swap 'Network fee' bottom sheet, retrying the click until the fee selector shows.
* Single action without its own step — wrap the call in a `step(...)`.
*/
fun BaseTestCase.openSwapNetworkFeeSelector() {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
runCatching { onSwapTokenScreen { networkFeeBlock.performClick() } }
runCatching { onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() } }.isSuccess
}
}
/**
* Opens the fee selector and switches the fee-paying token from [currentFeeToken] to [newFeeToken],
* then applies. Works both ways — coin -> stablecoin and back.
*/
fun BaseTestCase.switchFeeTokenAndApply(currentFeeToken: String, newFeeToken: String) {
step("Open the 'Network fee' bottom sheet") {
openSwapNetworkFeeSelector()
}
step("Click on '$currentFeeToken' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(currentFeeToken).performClick() }
}
step("Select '$newFeeToken' as the fee-paying token") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSendFeeSelectorBottomSheet { feeTokenItem(newFeeToken).performClick() } }.isSuccess
}
}
step("Click on 'Apply' button") {
waitForIdle()
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
}
/** Holds the last BASE_BUTTON; enters [accessCode] if a hot wallet prompts for it. */
fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) {
val buttonMatcher = hasTestTag(BaseButtonTestTags.BUTTON)
val buttons = composeTestRule.onAllNodes(buttonMatcher)
// HoldToConfirm is always last — withdraw renders an extra BASE_BUTTON for notifications.
val swapButton = buttons[buttons.fetchSemanticsNodes().lastIndex]
swapButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
waitForIdle()
val accessCodeInput = hasTestTag(HotWalletAccessCodeTestTags.ACCESS_CODE_INPUT)
val swapInProgressText = hasText(getResourceString(CoreUiR.string.swap_in_progress))
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
composeTestRule.onAllNodes(accessCodeInput).fetchSemanticsNodes().isNotEmpty() ||
composeTestRule.onAllNodes(swapInProgressText, useUnmergedTree = true)
.fetchSemanticsNodes().isNotEmpty()
}
val needsAccessCode =
composeTestRule.onAllNodes(accessCodeInput).fetchSemanticsNodes().isNotEmpty()
if (needsAccessCode && accessCode != null) {
composeTestRule.onNode(accessCodeInput).performTextInput(accessCode)
waitForIdle()
}
}
// Caller asserts the outcome — transfer mode has no in-progress marker to wait on.
fun BaseTestCase.holdToConfirmTransfer() {
composeTestRule.onNode(
hasTestTag(BaseButtonTestTags.BUTTON) and
hasText(getResourceString(CoreUiR.string.swapping_transfer_action)),
).performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
waitForIdle()
}
sealed class SwapEntryPoint {
object MainScreen : SwapEntryPoint()
object TokenDetails : SwapEntryPoint()
object MarketsTokenDetails : SwapEntryPoint()
object TokenActionsBottomSheet : SwapEntryPoint()
}
enum class FeeType {
Market,
Fast
}
fun BaseTestCase.inputAmount(amount: String) {
// No waitForIdle(): the transfer screen recalculates the fee continuously and never reaches idle.
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapTokenScreen { textInput.assertIsDisplayed() } }.isSuccess
}
onSwapTokenScreen {
textInput.clickWithAssertion()
textInput.performTextReplacement(amount)
}
}
// composeTestRule.waitUntil rather than flakySafely — the latter is unavailable in extensions on BaseTestCase.
fun BaseTestCase.assertTransferReady() {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapTokenScreen { transferButton.assertIsDisplayed() } }.isSuccess
}
onSwapTokenScreen { providersBlock.assertIsNotDisplayed() }
}
fun BaseTestCase.waitForFeeDisplayed() {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapTokenScreen { feeAmount.assertIsDisplayed() } }.isSuccess
}
}
fun BaseTestCase.swapFeeDiffersFrom(previousFee: String): Boolean {
var current = ""
onSwapTokenScreen { current = feeAmount.extractText() }
return current.isNotEmpty() && current != previousFee
}