Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-08 14:31:40 +03:00
parent 8683516c59
commit c81ba95db0
17 changed files with 827 additions and 10 deletions

View file

@ -71,6 +71,12 @@ When the user asks to **port** an iOS test to Android:
strings inside `step(...)`. strings inside `step(...)`.
- **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which - **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which
click failed in the Allure report. click failed in the Allure report.
- **Every scenario call in the test body is wrapped in its own `step("…")`**, even though the scenario
itself contains inner `step(...)`s — the outer step names the flow in the Allure tree, the inner ones
detail it (nested steps are expected). `step(...)` (Allure) is callable anywhere, including inside
scenario extension functions; only `flakySafely` is restricted to the `TestCase` body. Caveat: don't
wrap a *mutating* scenario (e.g. one that long-clicks to sign+send) in `flakySafely` — a retry would
re-fire the action; rely on the assertion's own built-in retry instead.
- **Step naming**: `Click on 'X' button` (not "Tap X"); `Assert <thing> is displayed` / `is not displayed`. - **Step naming**: `Click on 'X' button` (not "Tap X"); `Assert <thing> is displayed` / `is not displayed`.
Reviewers reject `is visible`, `does not exist`, `Check X visible` — the convention is **`is displayed` / Reviewers reject `is visible`, `does not exist`, `Check X visible` — the convention is **`is displayed` /
`is not displayed`** even though older tests in the file may still use the old phrasing (don't copy it). `is not displayed`** even though older tests in the file may still use the old phrasing (don't copy it).

View file

@ -102,6 +102,45 @@ to a screen via `router::pop` does NOT re-fetch. A test that switches WireMock s
action and the assertion MUST explicitly trigger a refresh on the now-frontmost screen — otherwise the action and the assertion MUST explicitly trigger a refresh on the now-frontmost screen — otherwise the
stale in-memory data wins. stale in-memory data wins.
## Terminal screen never reaches Compose-idle: self-feeding `StateFlow` loop
A screen whose model writes a fresh state back into the same `StateFlow` it observes will recompose
forever, so **any** Compose/Espresso assertion on it times out with `ComposeNotIdleException`
(`autoAdvance=true`) or `AppNotIdleException` "last message = DispatchedContinuation target=Handler"
(`autoAdvance=false`). The classic shape (hit on the send-v2 `ConfirmSuccess` screen, [REDACTED_TASK_KEY]):
```kotlin
combine(uiState, currentRoute)
.onEach { (state, _) -> callback.onResult(state.copy(navigationUM = NavigationUM.Content(onClick = { … }))) }
// callback writes back into uiState → emits again → onEach again → ∞
```
`NavigationUM.Content` is a `data class` whose fields are **lambdas**, recreated every pass → `equals`
is always false → `StateFlow` never dedups → unthrottled loop. No test-side workaround helps (it's an
app loop): not `flakySafely`, not longer timeouts, not mocking external sources, not UiAutomator
(touching the window mid-async-signing aborts the send). **Fix is app-side** — emit once (guard the
`filter`/`distinctUntilChanged` so the self-induced field is ignored). If you see `ComposeNotIdle` on a
*static-looking* success/result screen, suspect this before blaming background polling.
## Animation-gated content via `delay()` never appears under the test clock
Compose UI tests run inside `runTest`**virtual time**. A `LaunchedEffect { delay(600); visible = true }`
that gates the screen body behind `AnimatedVisibility(visible)` will *never* reveal it once the
composition is otherwise idle: `waitForIdle` sees no pending frame-clock awaiters, so it stops without
advancing the virtual clock to the delay's deadline. The body stays empty (you see only the parent
chrome, e.g. a top-bar close icon), the `testTag` is absent, and `assertIsDisplayed` fails as
"not displayed" — **after** burning the full wall-clock timeout. `flakySafely(LONG)` does NOT help:
it retries in wall-clock time while virtual time stays frozen.
Distinguish from the loop trap above: a `delay`-gate gives a clean `AssertionError: … not displayed`
(idle is reached, node just isn't there); the loop gives a `ComposeNotIdle`/`AppNotIdle` timeout.
Fixes: (a) app-side — drop the pre-`delay`, let the enter transition (`slideIn`/`fadeIn`) play on the
frame clock (which `autoAdvance` *does* pump); or (b) put the asserted `testTag` on a node **outside**
the `AnimatedVisibility` so the container exists from frame 0. A plain coroutine `delay` is not a
frame-clock awaiter, so advancing frames won't fire it — only `advanceTimeBy` (with `autoAdvance=false`)
would, which is fragile. Prefer the app-side fix.
## Hot wallet imports with access code ## Hot wallet imports with access code
- `openMainScreenWithExistingHotWallet(seedPhrase, accessCode: String = "")` in `BaseScenarios.kt` - `openMainScreenWithExistingHotWallet(seedPhrase, accessCode: String = "")` in `BaseScenarios.kt`

View file

@ -0,0 +1,90 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.screens.*
import io.qameta.allure.kotlin.Allure.step
/**
* From the recipient step: fill the address and advance to the 'Send confirm' screen.
* Uses `composeTestRule.waitUntil` because `flakySafely` is unavailable in extensions on [BaseTestCase].
*/
fun BaseTestCase.enterRecipientAndOpenSendConfirm(recipientAddress: String) {
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) }
}
step("Click on 'Next' button until 'Send confirm' screen opens") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { openSendConfirmScreenViaNextButton() }.isSuccess
}
}
}
/** Enter the send amount, then fill the recipient and open the 'Send confirm' screen. */
fun BaseTestCase.enterAmountAndOpenSendConfirm(amount: String, recipientAddress: String) {
step("Type '$amount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amount)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
enterRecipientAndOpenSendConfirm(recipientAddress)
}
/**
* On the 'Send confirm' screen, open the network-fee selector and switch the fee token from the
* native coin to the given (stablecoin) token the core gasless action repeated across the suite.
*/
fun BaseTestCase.selectStablecoinAsFeeToken(coinName: String, tokenName: String) {
step("Click on 'Network fee' block") {
onSendConfirmScreen {
feeSelectorBlock.assertIsDisplayed()
feeSelectorBlock.performClick()
}
}
step("Click on '$coinName' fee token to open 'Choose token'") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSendFeeSelectorBottomSheet { feeTokenItem(coinName).performClick() } }.isSuccess
}
}
step("Select '$tokenName' as the fee-paying token") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
}
/**
* Open an existing hot wallet (gasless signing needs a hot wallet, not the mock card), set the
* portfolio and quotes mocks, and reach the send amount input for the given token.
*/
fun BaseTestCase.openGaslessSendScreenWithHotWallet(
seedPhrase: String,
tokenName: String,
userTokensState: String,
quotesState: String,
) {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
}

View file

@ -84,6 +84,18 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
useUnmergedTree = true useUnmergedTree = true
} }
fun warningMessageContaining(textPart: String): KNode = child {
hasTestTag(NotificationTestTags.MESSAGE)
hasText(textPart, substring = true)
useUnmergedTree = true
}
fun warningTitleContaining(textPart: String): KNode = child {
hasTestTag(NotificationTestTags.TITLE)
hasText(textPart, substring = true)
useUnmergedTree = true
}
fun warningIcon(message: String): KNode = child { fun warningIcon(message: String): KNode = child {
hasTestTag(NotificationTestTags.ICON) hasTestTag(NotificationTestTags.ICON)
hasAnySibling(withText(message)) hasAnySibling(withText(message))
@ -145,6 +157,12 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
useUnmergedTree = true useUnmergedTree = true
} }
fun feeBlockCurrency(symbol: String): KNode = child {
hasTestTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK)
hasAnyDescendant(withText(symbol))
useUnmergedTree = true
}
val refreshButton: KNode = child { val refreshButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON) hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(CoreUiR.string.warning_button_refresh)) hasText(getResourceString(CoreUiR.string.warning_button_refresh))

View file

@ -0,0 +1,65 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.wallet.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
/**
* Gasless fee selector modal: the `NetworkFee` route (fee-paying token row + selected speed) and the
* `ChooseToken` route. The `ChooseSpeed` route is covered by [SendSelectNetworkFeeBottomSheetPageObject].
*/
class SendFeeSelectorBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<SendFeeSelectorBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val networkFeeTitle: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
hasText(getResourceString(R.string.common_network_fee_title))
useUnmergedTree = true
}
val chooseTokenTitle: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
hasText(getResourceString(R.string.fee_selector_choose_token_title))
useUnmergedTree = true
}
val feeTokenRow: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
useUnmergedTree = true
}
fun feeTokenItem(tokenName: String): KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
hasAnyChild(withText(tokenName))
useUnmergedTree = true
}
fun feeSpeedItemTitle(speed: String): KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE)
hasText(speed)
useUnmergedTree = true
}
val applyButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasAnyDescendant(withText(getResourceString(R.string.common_apply)))
useUnmergedTree = true
}
val notEnoughFundsError: KNode = child {
hasText(getResourceString(R.string.gasless_not_enough_funds_to_cover_token_fee))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onSendFeeSelectorBottomSheet(function: SendFeeSelectorBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,37 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TransactionHistoryItemTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import androidx.compose.ui.test.hasText as withText
class TxHistoryPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TxHistoryPageObject>(semanticsProvider = semanticsProvider) {
fun transactionItem(title: String): KNode = child {
hasTestTag(TransactionHistoryItemTestTags.ITEM)
hasAnyDescendant(withText(title))
useUnmergedTree = true
}
fun transactionAmount(title: String): KNode = transactionItem(title).child {
hasTestTag(TransactionHistoryItemTestTags.AMOUNT)
useUnmergedTree = true
}
fun transactionCurrency(title: String): KNode = transactionItem(title).child {
hasTestTag(TransactionHistoryItemTestTags.CURRENCY)
useUnmergedTree = true
}
fun transactionConfirmedStatus(title: String): KNode = transactionItem(title).child {
hasTestTag(TransactionHistoryItemTestTags.STATUS_CONFIRMED)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTxHistoryScreen(function: TxHistoryPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,317 @@
package com.tangem.tests.send.gasless
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.enterAmountAndOpenSendConfirm
import com.tangem.scenarios.enterRecipientAndOpenSendConfirm
import com.tangem.scenarios.openSendScreen
import com.tangem.scenarios.selectStablecoinAsFeeToken
import com.tangem.screens.onSendConfirmScreen
import com.tangem.screens.onSendFeeSelectorBottomSheet
import com.tangem.screens.onSendScreen
import com.tangem.screens.onSendSelectNetworkFeeBottomSheet
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
/**
* Gasless network-fee behaviour on the send summary (fee selector): availability, calculation,
* speed options, switching the fee token, and balance-driven notifications. All run on the default
* (cold) wallet without signing a transaction.
*/
@HiltAndroidTest
class GaslessFeeTest : BaseTestCase() {
private val scenarioState = "PolygonUSDC"
private val tokenName = "USDC"
private val nativeTokenName = "Polygon"
private val tokenAmount = "1"
@AllureId("5061")
@DisplayName("Gasless: Network fee on summary is selectable and the stablecoin is available for the fee")
@Test
fun checkNetworkFeeTokenSelectionAvailableTest() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Assert 'Network fee' block with token selection is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen {
feeSelectorTitle.assertIsDisplayed()
selectFeeIcon.assertIsDisplayed()
}
}
}
step("Click on 'Network fee' block") {
onSendConfirmScreen { feeSelectorBlock.performClick() }
}
step("Assert 'Network fee' bottom sheet is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() }
}
}
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Assert 'Choose token' bottom sheet is displayed") {
onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() }
}
step("Assert '$tokenName' is available for the fee payment") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).assertIsDisplayed() }
}
}
}
@AllureId("5062")
@DisplayName("Gasless: network fee for a stablecoin is calculated and shown in the stablecoin")
@Test
fun checkFeeCalculatedInStablecoinTest() {
val marketSpeed = getResourceString(R.string.common_fee_selector_option_market)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Assert the fee is shown under the '$marketSpeed' speed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).assertIsDisplayed() }
}
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert the network fee is calculated in '$tokenName' (not in the coin) on the summary") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen {
feeBlockCurrency(tokenName).assertIsDisplayed()
feeAmount.assertIsDisplayed()
}
}
}
}
}
@AllureId("5064")
@DisplayName("Gasless: only Market speed is available when paying the fee with a stablecoin")
@Test
fun checkOnlyMarketSpeedAvailableForStablecoinFeeTest() {
val marketSpeed = getResourceString(R.string.common_fee_selector_option_market)
val fastSpeed = getResourceString(R.string.common_fee_selector_option_fast)
val slowSpeed = getResourceString(R.string.common_fee_selector_option_slow)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Click on 'Network fee' block") {
onSendConfirmScreen {
feeSelectorBlock.assertIsDisplayed()
feeSelectorBlock.performClick()
}
}
step("Assert 'Network fee' bottom sheet is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() }
}
}
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Assert 'Choose token' bottom sheet is displayed") {
onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() }
}
step("Select '$tokenName' as the fee-paying token") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
step("Assert 'Network fee' bottom sheet is displayed after token selection") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() }
}
}
step("Assert '$marketSpeed' speed is displayed") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).assertIsDisplayed() }
}
step("Assert '$fastSpeed' speed is not displayed") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(fastSpeed).assertIsNotDisplayed() }
}
step("Assert '$slowSpeed' speed is not displayed") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(slowSpeed).assertIsNotDisplayed() }
}
step("Click on '$marketSpeed' fee row") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() }
}
step("Assert 'Choose speed' bottom sheet did not open for stablecoin fee") {
onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsNotDisplayed() }
}
}
}
@AllureId("5068")
@DisplayName("Gasless: switching the fee token back to the coin restores the standard fee flow")
@Test
fun checkSwitchFeeTokenBackToCoinTest() {
val nativeSymbol = "POL"
val feeCoverageTitle = getResourceString(R.string.send_network_fee_warning_title)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Open the fee token selector again via the '$tokenName' fee token") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
}
step("Switch the fee token back to '$nativeTokenName'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Click on 'Apply' button") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
}
step("Assert the network fee is now paid in '$nativeSymbol' on the summary") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { feeBlockCurrency(nativeSymbol).assertIsDisplayed() }
}
}
step("Assert 'Network fee coverage' notification is not displayed (standard fee flow)") {
onSendConfirmScreen { warningTitle(feeCoverageTitle).assertIsNotDisplayed() }
}
step("Assert 'Send' button is enabled") {
onSendConfirmScreen { sendButton.assertIsEnabled() }
}
}
}
@AllureId("5063")
@DisplayName("Gasless: insufficient stablecoin balance to cover the fee shows error and blocks send")
@Test
fun checkInsufficientBalanceForFeeTest() {
val usdcBalanceScenario = "polygon_usdc_balance"
val lowBalanceState = "LowBalance"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(usdcBalanceScenario)
}
).run {
step("Set WireMock scenario '$usdcBalanceScenario' to '$lowBalanceState'") {
setWireMockScenarioState(scenarioName = usdcBalanceScenario, state = lowBalanceState)
}
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Click on 'Max' button") {
onSendScreen { maxButton.performClick() }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Enter the recipient and open the 'Send confirm' screen") {
enterRecipientAndOpenSendConfirm(ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Assert 'Not enough funds' error is displayed in the fee selector") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { notEnoughFundsError.assertIsDisplayed() }
}
}
step("Assert 'Apply' button is disabled (cannot pay the fee with insufficient balance)") {
onSendFeeSelectorBottomSheet { applyButton.assertIsNotEnabled() }
}
}
}
@AllureId("5097")
@DisplayName("Gasless: no insufficient-coin-for-fee notification is shown when gasless covers the fee")
@Test
fun checkNoInsufficientCoinNotificationWhenGaslessTest() {
val coinBalanceScenario = "polygon_coin_balance"
val zeroBalanceState = "ZeroBalance"
val feeBlockedTitlePart = getResourceString(R.string.warning_send_blocked_funds_for_fee_title, "X")
.substringAfter("X ")
.trim()
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(coinBalanceScenario)
}
).run {
step("Set WireMock scenario '$coinBalanceScenario' to '$zeroBalanceState'") {
setWireMockScenarioState(scenarioName = coinBalanceScenario, state = zeroBalanceState)
}
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Assert the fee defaults to '$tokenName' (gasless covers the missing coin)") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { feeBlockCurrency(tokenName).assertIsDisplayed() }
}
}
step("Assert the insufficient-coin-for-fee notification is not shown") {
onSendConfirmScreen { warningTitleContaining(feeBlockedTitlePart).assertIsNotDisplayed() }
}
step("Assert 'Send' button is enabled") {
onSendConfirmScreen { sendButton.assertIsEnabled() }
}
}
}
}

View file

@ -0,0 +1,189 @@
package com.tangem.tests.send.gasless
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.*
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
/**
* Gasless send lifecycle: signing and broadcasting a stablecoin-fee transaction (hot wallet),
* the max-amount fee reservation, and the completed gasless transaction in the token history.
*/
@HiltAndroidTest
class GaslessSendTest : BaseTestCase() {
private val scenarioState = "PolygonUSDC"
private val tokenName = "USDC"
private val currencySymbol = "USDC"
private val nativeTokenName = "Polygon"
private val hotWalletTokensState = "PolygonUSDCHotWallet"
private val tokenAmount = "1"
@AllureId("5069")
@DisplayName("Gasless: max amount reserves the stablecoin fee and stays sendable")
@Test
fun checkMaxAmountSendTest() {
val feeCoverageTitle = getResourceString(R.string.send_network_fee_warning_title)
val feeCoverageMessagePart = getResourceString(R.string.common_network_fee_warning_content, "", "")
.substringBefore("(")
.trim()
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openGaslessSendScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
userTokensState = hotWalletTokensState,
quotesState = scenarioState,
)
}
step("Click on 'Max' button") {
onSendScreen { maxButton.performClick() }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Enter the recipient and open the 'Send confirm' screen") {
enterRecipientAndOpenSendConfirm(ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert 'Network fee coverage' notification title is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { warningTitle(feeCoverageTitle).assertIsDisplayed() }
}
}
step("Assert 'Network fee coverage' notification text is displayed (amount reduced by fee)") {
onSendConfirmScreen { warningMessageContaining(feeCoverageMessagePart).assertIsDisplayed() }
}
step("Assert 'Send' button is enabled (enough left for the fee)") {
onSendConfirmScreen { sendButton.assertIsEnabled() }
}
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
@AllureId("5065")
@DisplayName("Gasless: sign and send a stablecoin transaction with the stablecoin fee")
@Test
fun checkSignAndSendGaslessTransactionTest() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openGaslessSendScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
userTokensState = hotWalletTokensState,
quotesState = scenarioState,
)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert gasless fee is paid in '$currencySymbol' and 'Send' is enabled") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen {
feeBlockCurrency(currencySymbol).assertIsDisplayed()
sendButton.assertIsEnabled()
}
}
}
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
@AllureId("5066")
@DisplayName("Gasless: completed gasless transaction is shown in token transaction history")
@Test
fun checkGaslessTransactionInHistoryTest() {
val sentAmount = "1.00"
val gaslessFeeAmount = "0.10"
val sentTitle = getResourceString(R.string.common_sent)
val gaslessFeeTitle = getResourceString(R.string.gasless_transaction_fee)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState)
}
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Wait for gasless '$gaslessFeeTitle' transaction in history") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTxHistoryScreen { transactionItem(gaslessFeeTitle).assertIsDisplayed() }
}
}
step("Assert '$sentTitle' transaction is displayed") {
onTxHistoryScreen { transactionItem(sentTitle).assertIsDisplayed() }
}
step("Assert '$sentTitle' amount '$sentAmount' is displayed in '$currencySymbol'") {
onTxHistoryScreen {
transactionAmount(sentTitle).assertTextContains(sentAmount, substring = true)
transactionCurrency(sentTitle).assertTextEquals(currencySymbol)
}
}
step("Assert gasless '$gaslessFeeTitle' amount '$gaslessFeeAmount' is displayed in '$currencySymbol'") {
onTxHistoryScreen {
transactionAmount(gaslessFeeTitle).assertTextContains(gaslessFeeAmount, substring = true)
transactionCurrency(gaslessFeeTitle).assertTextEquals(currencySymbol)
}
}
step("Assert gasless '$gaslessFeeTitle' status is confirmed") {
onTxHistoryScreen { transactionConfirmedStatus(gaslessFeeTitle).assertIsDisplayed() }
}
}
}
}

View file

@ -60,6 +60,7 @@ internal class DefaultAuthProvider(
override fun getGaslessServiceApiKey(apiEnvironment: Provider<ApiEnvironment>): ProviderSuspend<String> { override fun getGaslessServiceApiKey(apiEnvironment: Provider<ApiEnvironment>): ProviderSuspend<String> {
return ProviderSuspend { return ProviderSuspend {
when (apiEnvironment.invoke()) { when (apiEnvironment.invoke()) {
ApiEnvironment.MOCK,
ApiEnvironment.DEV, ApiEnvironment.DEV,
-> environmentConfig.gaslessTxApiKeyDev -> environmentConfig.gaslessTxApiKeyDev
ApiEnvironment.PROD -> environmentConfig.gaslessTxApiKey ApiEnvironment.PROD -> environmentConfig.gaslessTxApiKey

View file

@ -20,11 +20,13 @@ internal class GaslessTxService(
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf( override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createProdEnvironment(), createProdEnvironment(),
createDevEnvironment(), createDevEnvironment(),
createMockedEnvironment(),
) )
private fun getInitialEnvironment(): ApiEnvironment { private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) { return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE, MOCKED_BUILD_TYPE,
-> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE, DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV -> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE, INTERNAL_BUILD_TYPE,
@ -47,6 +49,12 @@ internal class GaslessTxService(
headers = createHeaders(ApiEnvironment.DEV), headers = createHeaders(ApiEnvironment.DEV),
) )
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = MOCK_BASE_URL,
headers = createHeaders(ApiEnvironment.MOCK),
)
private fun createHeaders(environment: ApiEnvironment) = buildMap { private fun createHeaders(environment: ApiEnvironment) = buildMap {
putAll(RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values) putAll(RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values)
put( put(
@ -60,5 +68,6 @@ internal class GaslessTxService(
private companion object { private companion object {
private const val PROD_BASE_URL = "https://gasless.tangem.org/" private const val PROD_BASE_URL = "https://gasless.tangem.org/"
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]" private const val DEV_BASE_URL = "[REDACTED_ENV_URL]"
private const val MOCK_BASE_URL = "[REDACTED_ENV_URL]"
} }
} }

View file

@ -46,6 +46,7 @@ class WireMockRedirectInterceptor : Interceptor {
private val REDIRECTABLE_THIRD_PARTY_HOSTS = setOf( private val REDIRECTABLE_THIRD_PARTY_HOSTS = setOf(
"deep-index.moralis.io", "deep-index.moralis.io",
"solana-gateway.moralis.io", "solana-gateway.moralis.io",
"api.etherscan.io",
) )
/** /**

View file

@ -318,6 +318,7 @@ internal class ProdApiConfigsManagerTest {
private fun createGaslessTxServiceModel(): TestModel { private fun createGaslessTxServiceModel(): TestModel {
val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) { val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE, MOCKED_BUILD_TYPE,
-> ApiEnvironment.MOCK to "[REDACTED_ENV_URL]"
DEBUG_BUILD_TYPE, DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV to "[REDACTED_ENV_URL]" -> ApiEnvironment.DEV to "[REDACTED_ENV_URL]"
INTERNAL_BUILD_TYPE, INTERNAL_BUILD_TYPE,

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
@ -43,6 +44,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.TransactionHistoryItemTestTags
@Composable @Composable
fun TransactionItem(state: TransactionItemUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { fun TransactionItem(state: TransactionItemUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
@ -68,6 +70,7 @@ private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boole
val rowModifier = modifier val rowModifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = state.onClick) .clickable(onClick = state.onClick)
.testTag(TransactionHistoryItemTestTags.ITEM)
TangemRowContainer( TangemRowContainer(
modifier = rowModifier, modifier = rowModifier,
@ -82,12 +85,15 @@ private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boole
modifier = Modifier modifier = Modifier
.layoutId(TangemRowLayoutId.HEAD) .layoutId(TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x3) .padding(end = TangemTheme.dimens2.x3)
.size(TangemTheme.dimens2.x10), .size(TangemTheme.dimens2.x10)
.testTag(TransactionHistoryItemTestTags.STATUS_PREFIX + state.status.testTagSuffix),
) )
TitleText( TitleText(
title = state.title, title = state.title,
status = state.status, status = state.status,
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), modifier = Modifier
.layoutId(TangemRowLayoutId.START_TOP)
.testTag(TransactionHistoryItemTestTags.TITLE),
) )
SubtitleText( SubtitleText(
subtitle = state.subtitle, subtitle = state.subtitle,
@ -100,13 +106,16 @@ private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boole
amount = state.amount, amount = state.amount,
status = state.status, status = state.status,
isBalanceHidden = isBalanceHidden, isBalanceHidden = isBalanceHidden,
modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), modifier = Modifier
.layoutId(TangemRowLayoutId.END_TOP)
.testTag(TransactionHistoryItemTestTags.AMOUNT),
) )
CurrencyText( CurrencyText(
symbol = state.currencySymbol, symbol = state.currencySymbol,
modifier = Modifier modifier = Modifier
.layoutId(TangemRowLayoutId.END_BOTTOM) .layoutId(TangemRowLayoutId.END_BOTTOM)
.padding(top = TangemTheme.dimens2.x0_5), .padding(top = TangemTheme.dimens2.x0_5)
.testTag(TransactionHistoryItemTestTags.CURRENCY),
) )
} }
} }
@ -146,6 +155,13 @@ private val Status.iconTint: Color
is Status.Failed -> TangemTheme.colors2.markers.iconRed is Status.Failed -> TangemTheme.colors2.markers.iconRed
} }
private val Status.testTagSuffix: String
get() = when (this) {
is Status.Confirmed -> "CONFIRMED"
is Status.Unconfirmed -> "UNCONFIRMED"
is Status.Failed -> "FAILED"
}
// endregion // endregion
// region Title / Subtitle // region Title / Subtitle

View file

@ -0,0 +1,12 @@
package com.tangem.core.ui.test
object TransactionHistoryItemTestTags {
const val ITEM = "TRANSACTION_HISTORY_ITEM"
const val TITLE = "TRANSACTION_HISTORY_ITEM_TITLE"
const val AMOUNT = "TRANSACTION_HISTORY_ITEM_AMOUNT"
const val CURRENCY = "TRANSACTION_HISTORY_ITEM_CURRENCY"
/** Status is conveyed visually (icon + color), so it is exposed via a status-suffixed tag. */
const val STATUS_PREFIX = "TRANSACTION_HISTORY_ITEM_STATUS_"
const val STATUS_CONFIRMED = STATUS_PREFIX + "CONFIRMED"
}

View file

@ -48,7 +48,15 @@ internal class SendConfirmSuccessModel @Inject constructor(
flow = uiState, flow = uiState,
flow2 = params.currentRoute, flow2 = params.currentRoute,
transform = { state, route -> state to route }, transform = { state, route -> state to route },
).filter { it.second is CommonSendRoute.ConfirmSuccess }.onEach { (state, _) -> ).filter { (state, route) ->
// Emit the success navigation exactly once. Building NavigationUM.Content here creates fresh
// lambdas every time, so the SendUM written back via callback.onResult is never equal to the
// previous one — without this guard the combine re-triggers itself endlessly and the success
// screen recomposes forever (never reaching Compose idle). See [REDACTED_TASK_KEY].
route is CommonSendRoute.ConfirmSuccess &&
(state.navigationUM as? NavigationUM.Content)?.source !=
CommonSendRoute.ConfirmSuccess.javaClass.simpleName
}.onEach { (state, _) ->
params.callback.onResult( params.callback.onResult(
state.copy( state.copy(
navigationUM = NavigationUM.Content( navigationUM = NavigationUM.Content(

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tangem.common.ui.amountScreen.ui.AmountBlock import com.tangem.common.ui.amountScreen.ui.AmountBlock
import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2 import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2
@ -17,6 +18,7 @@ import com.tangem.core.ui.components.transactions.TransactionDoneTitle
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.TransactionSuccessScreenTestTags
import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toPx import com.tangem.core.ui.utils.toPx
import com.tangem.core.ui.utils.toTimeFormat import com.tangem.core.ui.utils.toTimeFormat
@ -25,14 +27,12 @@ import com.tangem.features.send.v2.common.ui.FeeBlockSuccess
import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.send.ui.state.SendUM import com.tangem.features.send.v2.send.ui.state.SendUM
import kotlinx.coroutines.delay
@Composable @Composable
internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent: SendDestinationBlockComponent) { internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent: SendDestinationBlockComponent) {
var isVisible by remember { mutableStateOf(false) } var isVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(ANIMATION_DELAY)
isVisible = true isVisible = true
} }
@ -50,7 +50,8 @@ internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.background(TangemTheme.colors.background.tertiary), .background(TangemTheme.colors.background.tertiary)
.testTag(TransactionSuccessScreenTestTags.CONTAINER),
) { ) {
SuccessContent( SuccessContent(
sendUM = sendUM, sendUM = sendUM,
@ -111,5 +112,4 @@ private fun SuccessContent(
} }
} }
private const val ANIMATION_DELAY = 600L
private val ANIMATION_OFFSET = (-40).dp private val ANIMATION_OFFSET = (-40).dp

View file

@ -46,7 +46,15 @@ internal class NFTSendSuccessModel @Inject constructor(
flow = uiState, flow = uiState,
flow2 = params.currentRoute, flow2 = params.currentRoute,
transform = { state, route -> state to route }, transform = { state, route -> state to route },
).filter { it.second is CommonSendRoute.ConfirmSuccess }.onEach { (state, _) -> ).filter { (state, route) ->
// Emit the success navigation exactly once. Building NavigationUM.Content here creates fresh
// lambdas every time, so the SendUM written back via callback.onResult is never equal to the
// previous one — without this guard the combine re-triggers itself endlessly and the success
// screen recomposes forever (never reaching Compose idle). See [REDACTED_TASK_KEY].
route is CommonSendRoute.ConfirmSuccess &&
(state.navigationUM as? NavigationUM.Content)?.source !=
CommonSendRoute.ConfirmSuccess.javaClass.simpleName
}.onEach { (state, _) ->
params.callback.onResult( params.callback.onResult(
state.copy( state.copy(
navigationUM = NavigationUM.Content( navigationUM = NavigationUM.Content(