Updated on 2026-08-14
This commit is contained in:
parent
8683516c59
commit
c81ba95db0
17 changed files with 827 additions and 10 deletions
|
|
@ -71,6 +71,12 @@ When the user asks to **port** an iOS test to Android:
|
|||
strings inside `step(...)`.
|
||||
- **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which
|
||||
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`.
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
- `openMainScreenWithExistingHotWallet(seedPhrase, accessCode: String = "")` in `BaseScenarios.kt`
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -84,6 +84,18 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
|
|||
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 {
|
||||
hasTestTag(NotificationTestTags.ICON)
|
||||
hasAnySibling(withText(message))
|
||||
|
|
@ -145,6 +157,12 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
|
|||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
fun feeBlockCurrency(symbol: String): KNode = child {
|
||||
hasTestTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK)
|
||||
hasAnyDescendant(withText(symbol))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val refreshButton: KNode = child {
|
||||
hasTestTag(BaseButtonTestTags.BUTTON)
|
||||
hasText(getResourceString(CoreUiR.string.warning_button_refresh))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -60,6 +60,7 @@ internal class DefaultAuthProvider(
|
|||
override fun getGaslessServiceApiKey(apiEnvironment: Provider<ApiEnvironment>): ProviderSuspend<String> {
|
||||
return ProviderSuspend {
|
||||
when (apiEnvironment.invoke()) {
|
||||
ApiEnvironment.MOCK,
|
||||
ApiEnvironment.DEV,
|
||||
-> environmentConfig.gaslessTxApiKeyDev
|
||||
ApiEnvironment.PROD -> environmentConfig.gaslessTxApiKey
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@ internal class GaslessTxService(
|
|||
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
|
||||
createProdEnvironment(),
|
||||
createDevEnvironment(),
|
||||
createMockedEnvironment(),
|
||||
)
|
||||
|
||||
private fun getInitialEnvironment(): ApiEnvironment {
|
||||
return when (BuildConfig.BUILD_TYPE) {
|
||||
MOCKED_BUILD_TYPE,
|
||||
-> ApiEnvironment.MOCK
|
||||
DEBUG_BUILD_TYPE,
|
||||
-> ApiEnvironment.DEV
|
||||
INTERNAL_BUILD_TYPE,
|
||||
|
|
@ -47,6 +49,12 @@ internal class GaslessTxService(
|
|||
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 {
|
||||
putAll(RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values)
|
||||
put(
|
||||
|
|
@ -60,5 +68,6 @@ internal class GaslessTxService(
|
|||
private companion object {
|
||||
private const val PROD_BASE_URL = "https://gasless.tangem.org/"
|
||||
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]"
|
||||
private const val MOCK_BASE_URL = "[REDACTED_ENV_URL]"
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ class WireMockRedirectInterceptor : Interceptor {
|
|||
private val REDIRECTABLE_THIRD_PARTY_HOSTS = setOf(
|
||||
"deep-index.moralis.io",
|
||||
"solana-gateway.moralis.io",
|
||||
"api.etherscan.io",
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ internal class ProdApiConfigsManagerTest {
|
|||
private fun createGaslessTxServiceModel(): TestModel {
|
||||
val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) {
|
||||
MOCKED_BUILD_TYPE,
|
||||
-> ApiEnvironment.MOCK to "[REDACTED_ENV_URL]"
|
||||
DEBUG_BUILD_TYPE,
|
||||
-> ApiEnvironment.DEV to "[REDACTED_ENV_URL]"
|
||||
INTERNAL_BUILD_TYPE,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
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.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.test.TransactionHistoryItemTestTags
|
||||
|
||||
@Composable
|
||||
fun TransactionItem(state: TransactionItemUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
|
||||
|
|
@ -68,6 +70,7 @@ private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boole
|
|||
val rowModifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = state.onClick)
|
||||
.testTag(TransactionHistoryItemTestTags.ITEM)
|
||||
|
||||
TangemRowContainer(
|
||||
modifier = rowModifier,
|
||||
|
|
@ -82,12 +85,15 @@ private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boole
|
|||
modifier = Modifier
|
||||
.layoutId(TangemRowLayoutId.HEAD)
|
||||
.padding(end = TangemTheme.dimens2.x3)
|
||||
.size(TangemTheme.dimens2.x10),
|
||||
.size(TangemTheme.dimens2.x10)
|
||||
.testTag(TransactionHistoryItemTestTags.STATUS_PREFIX + state.status.testTagSuffix),
|
||||
)
|
||||
TitleText(
|
||||
title = state.title,
|
||||
status = state.status,
|
||||
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
|
||||
modifier = Modifier
|
||||
.layoutId(TangemRowLayoutId.START_TOP)
|
||||
.testTag(TransactionHistoryItemTestTags.TITLE),
|
||||
)
|
||||
SubtitleText(
|
||||
subtitle = state.subtitle,
|
||||
|
|
@ -100,13 +106,16 @@ private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boole
|
|||
amount = state.amount,
|
||||
status = state.status,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP),
|
||||
modifier = Modifier
|
||||
.layoutId(TangemRowLayoutId.END_TOP)
|
||||
.testTag(TransactionHistoryItemTestTags.AMOUNT),
|
||||
)
|
||||
CurrencyText(
|
||||
symbol = state.currencySymbol,
|
||||
modifier = Modifier
|
||||
.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
|
||||
}
|
||||
|
||||
private val Status.testTagSuffix: String
|
||||
get() = when (this) {
|
||||
is Status.Confirmed -> "CONFIRMED"
|
||||
is Status.Unconfirmed -> "UNCONFIRMED"
|
||||
is Status.Failed -> "FAILED"
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Title / Subtitle
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -48,7 +48,15 @@ internal class SendConfirmSuccessModel @Inject constructor(
|
|||
flow = uiState,
|
||||
flow2 = params.currentRoute,
|
||||
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(
|
||||
state.copy(
|
||||
navigationUM = NavigationUM.Content(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import androidx.compose.foundation.verticalScroll
|
|||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.amountScreen.ui.AmountBlock
|
||||
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.wrappedList
|
||||
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.toPx
|
||||
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.impl.R
|
||||
import com.tangem.features.send.v2.send.ui.state.SendUM
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent: SendDestinationBlockComponent) {
|
||||
var isVisible by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(ANIMATION_DELAY)
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +50,8 @@ internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent
|
|||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(TangemTheme.colors.background.tertiary),
|
||||
.background(TangemTheme.colors.background.tertiary)
|
||||
.testTag(TransactionSuccessScreenTestTags.CONTAINER),
|
||||
) {
|
||||
SuccessContent(
|
||||
sendUM = sendUM,
|
||||
|
|
@ -111,5 +112,4 @@ private fun SuccessContent(
|
|||
}
|
||||
}
|
||||
|
||||
private const val ANIMATION_DELAY = 600L
|
||||
private val ANIMATION_OFFSET = (-40).dp
|
||||
|
|
@ -46,7 +46,15 @@ internal class NFTSendSuccessModel @Inject constructor(
|
|||
flow = uiState,
|
||||
flow2 = params.currentRoute,
|
||||
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(
|
||||
state.copy(
|
||||
navigationUM = NavigationUM.Content(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue