Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-10 17:37:21 +03:00
parent 8ea23a2dd7
commit 4f1710d77c
23 changed files with 884 additions and 29 deletions

View file

@ -129,6 +129,47 @@ When the user asks to **port** an iOS test to Android:
Scenario files orchestrate flows; they must not define page objects or duplicate generic helpers.
### Page-object matchers: exhaust the native Kakao API before dropping to raw Compose
**Reviewers reject raw `composeTestRule` / `semanticsProvider.onNode(...)` / `onAllNodes(...)[i]` and
deep nested matchers when a native Kakao-Compose mechanism does the same thing.** Before writing any
such construct, look for the built-in KNode / `ViewBuilder` / `KLazyListNode` API — it almost always
exists. The raw form is a last resort, and even then it stays **inside the page object**, never in the
test body (the test only calls page-object members and scenarios — no `composeTestRule`, no test tags,
no `onNode`/`onAllNodes`, no bare matchers leak into it).
Native first, by need:
- **N-th of several identical nodes**`child { … ; hasPosition(index) }` (Kakao maps
`NodeMatcher.position``onAllNodes(matcher)[index]` for you). Do **not** hand-roll
`semanticsProvider.onAllNodes(matcher)[index]`.
- **Scroll to index / matcher / key** → inside a KNode block: `knode { performScrollToIndex(index) }`,
`knode { performScrollToNode(matcher) }`, `knode { performScrollToKey(key) }` (mirror
`MarketsPageObject.scrollToListedOnBlock`). These wrappers are `@ExperimentalTestApi`, so annotate the
page-object method `@OptIn(ExperimentalTestApi::class)` — that opt-in is expected, not a smell.
- **Relationship filters**`ViewBuilder` DSL inside `child { }`: `hasAnyChild`, `hasAnySibling`,
`hasAnyAncestor`, `hasAnyDescendant`, `addSemanticsMatcher(matcher)`, `useUnmergedTree = true`.
- **Lazy list / pager item (esp. below the fold)**`KLazyListNode` + `childWith { … }` / `childAt(index)`
(see `AddFundsBottomSheetPageObject`, `BuyTokenPageObject`), not a manual scroll + `onAllNodes`.
- **A raw Compose-Test op with no KNode wrapper** (e.g. `captureToImage()`, and any other
`SemanticsNodeInteraction` extension Kakao doesn't surface) → do **not** fall back to
`composeTestRule.onNode(hasTestTag(...))` in the scenario. Every KNode exposes a public `delegate`, and
the built-in actions/assertions are all just `delegate.perform(type) { <this: SemanticsNodeInteraction> }`
/ `delegate.check(type) { … }`. `ComposeOperationType` is an open interface, so declare a tiny private
`enum class Xxx : ComposeOperationType { … }` and reach the underlying `SemanticsNodeInteraction` from a
**page-object method** — reusing an existing KNode (its testTag + `useUnmergedTree`). Capture a return
value via a `lateinit var` written inside the lambda. Example (`TokenReceiveQrCodeBottomSheetPageObject.captureQrCodeBitmap`):
```kotlin
fun captureQrCodeBitmap(): Bitmap {
lateinit var bitmap: Bitmap
qrCode.delegate.perform(QrCodeAction.CAPTURE) { bitmap = captureToImage().asAndroidBitmap() }
return bitmap
}
private enum class QrCodeAction : ComposeOperationType { CAPTURE }
```
This keeps `composeTestRule` / test tags out of the scenario — the scenario just calls the page-object method.
- **Only if truly nothing fits**`semanticsProvider.onNode(...)` / `onAllNodes(...)` (the escape hatch
used by `MainScreenPageObject`), wrapped in a named page-object method with a one-line WHY comment.
### Strings
- **No hardcoded UI text** in matchers. Use `getResourceString(R.string.foo)` from
@ -164,20 +205,14 @@ Scenario files orchestrate flows; they must not define page objects or duplicate
that a screen "never idles", **cold-boot a fresh emulator** (`emulator -avd … -no-snapshot -wipe-data
-memory 4096 -cores 2`) and re-run. A suite that flaked across runs on a tired emulator can be a clean
10/10 on a fresh one (verified on this exact suite). Don't rewrite waits to work around emulator rot.
- **In scenario / `BaseTestCase`-extension code, `flakySafely` is NOT available** regardless — use the
same `composeTestRule.waitUntil` fallback (or `waitUntilAtLeastOneExists(matcher, timeout)` to wait for
appearance, `{ a exists || b exists }` for either/or).
- **Don't repeat the `composeTestRule.waitUntil(timeoutMillis = …) { runCatching { … }.isSuccess }`
block across steps — extract a one-line private helper** in the scenario file and call that instead.
A multi-step scenario that gates every async step this way turns into copy-paste noise (and reviewers
flag it). Add once, near the top of the file:
```kotlin
// flakySafely is unavailable in BaseTestCase extensions — wait until the assertion/action stops throwing.
private fun BaseTestCase.awaitSuccess(block: () -> Unit) {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT) { runCatching(block).isSuccess }
}
```
then each step reads `awaitSuccess { onXxxScreen { field.assertExists() } }` before the action.
- **In scenario / `BaseTestCase`-extension code, `flakySafely` is NOT available** regardless — use
`BaseTestCase.awaitSuccess(timeoutMillis = WAIT_UNTIL_TIMEOUT) { … }` (a shared member on `BaseTestCase`,
no import needed) which wraps `composeTestRule.waitUntil { runCatching(block).isSuccess }`. Each async step
reads `awaitSuccess { onXxxScreen { field.assertExists() } }` before the action. For appearance-only waits
`composeTestRule.waitUntilAtLeastOneExists(matcher, timeout)` (or `{ a exists || b exists }` for either/or)
is also fine.
**Do not re-declare a private `awaitSuccess` in a scenario file** — the shared `BaseTestCase.awaitSuccess`
already exists; older files may still have a private copy, don't copy that pattern.
- **Right-size the timeout — don't stamp `WAIT_UNTIL_TIMEOUT_LONG` on every step.** The timeout is a
*ceiling*, not a sleep (`waitUntil` returns the moment the condition holds), but the default
`WAIT_UNTIL_TIMEOUT` (20 s) already dwarfs a normal async transition. Reserve `…_LONG` / `…_VERY_LONG`

View file

@ -18,6 +18,7 @@ import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import com.tangem.common.allure.FailedStepScreenshotInterceptor
import com.tangem.common.constants.TestConstants.ALLURE_LABEL_NAME
import com.tangem.common.constants.TestConstants.ALLURE_LABEL_VALUE
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.rules.ApiEnvironmentRule
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -179,6 +180,14 @@ abstract class BaseTestCase : TestCase(
fun waitForIdle() = composeTestRule.waitForIdle()
/**
* Waits until [block] stops throwing (or [timeoutMillis] elapses). Use in scenario (BaseTestCase extension)
* code where flakySafely is unavailable; in test bodies prefer flakySafely.
*/
fun awaitSuccess(timeoutMillis: Long = WAIT_UNTIL_TIMEOUT, block: () -> Unit) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { runCatching(block).isSuccess }
}
private fun applicationInjectionRule(): ApplicationInjectionExecutionRule {
return ApplicationInjectionExecutionRule(
toggleStates = mapOf(

View file

@ -0,0 +1,22 @@
package com.tangem.common.utils
import android.graphics.Bitmap
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader
/** Decodes the text encoded in a QR-code [bitmap] (e.g. captured from a Compose node via captureToImage). */
fun decodeQrCode(bitmap: Bitmap): String {
val width = bitmap.width
val height = bitmap.height
val pixels = IntArray(width * height)
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
val source = RGBLuminanceSource(width, height, pixels)
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
val hints = mapOf(DecodeHintType.TRY_HARDER to true)
return QRCodeReader().decode(binaryBitmap, hints).text
}

View file

@ -61,6 +61,19 @@ fun BaseTestCase.openMainScreen(
}
}
/** Opens the main screen, synchronizes addresses, and opens the details of the token with [tokenName]. */
fun BaseTestCase.openTokenDetails(tokenName: String) {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
}
@OptIn(ExperimentalTestApi::class)
fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String, accessCode: String = "") {
step("Click on 'Get started' button") {

View file

@ -1,7 +1,6 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.performTextInputInChunks
import com.tangem.screens.accounts.onAccountDetailsScreen
@ -16,11 +15,6 @@ import com.tangem.core.res.R as CoreResR
private fun mainAccountName(): String = getResourceString(CoreResR.string.account_main_account_title)
// flakySafely is unavailable in BaseTestCase extensions — wait until the assertion/action stops throwing.
private fun BaseTestCase.awaitSuccess(block: () -> Unit) {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT) { runCatching(block).isSuccess }
}
fun BaseTestCase.openManageTokens(accountName: String = mainAccountName()) {
openWalletSettingsScreen()
openAccountDetails(accountName)

View file

@ -1,11 +1,58 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.extractText
import com.tangem.common.utils.decodeQrCode
import com.tangem.screens.onAddFundsBottomSheet
import com.tangem.screens.onReceiveAssetsBottomSheet
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTokenReceiveQrCodeBottomSheet
import com.tangem.screens.onTokenReceiveWarningBottomSheet
import io.qameta.allure.kotlin.Allure.step
import org.junit.Assert
/** Opens the receive flow from a funded token's details via 'Add funds' → 'Receive'. */
fun BaseTestCase.openReceiveViaAddFunds() {
step("Click on 'Add funds' button") {
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
}
step("Click on 'Receive' button in bottom sheet") {
onAddFundsBottomSheet { receiveButton.clickWithAssertion() }
}
}
/**
* Asserts the QR code encodes the displayed address for both address types of a two-address-type coin,
* and that the two addresses differ.
*/
fun BaseTestCase.assertQrCodesMatchForBothAddressTypes() {
step("Go to QR code bottom sheet for the first address type") {
goToQrCodeBottomSheet()
}
var firstAddress = ""
step("Assert QR code encodes the first displayed address") {
firstAddress = assertQrCodeEncodesDisplayedAddress()
}
step("Go back to the receive addresses") {
device.uiDevice.pressBack()
}
step("Switch to the second address type") {
awaitSuccess(WAIT_UNTIL_TIMEOUT_LONG) { onReceiveAssetsBottomSheet { addressesPager.assertIsDisplayed() } }
onReceiveAssetsBottomSheet { scrollToAddress(1) }
}
step("Click on 'Show QR code' button for the second address type") {
onReceiveAssetsBottomSheet { showQrCodeButton(1).clickWithAssertion() }
}
var secondAddress = ""
step("Assert QR code encodes the second displayed address") {
secondAddress = assertQrCodeEncodesDisplayedAddress()
}
step("Assert the two address types are different") {
Assert.assertNotEquals(firstAddress, secondAddress)
}
}
fun BaseTestCase.goToQrCodeBottomSheet() {
step("Assert 'Token receive warning' bottom sheet is displayed") {
@ -15,7 +62,7 @@ fun BaseTestCase.goToQrCodeBottomSheet() {
onTokenReceiveWarningBottomSheet { gotItButton.performClick() }
}
step("Click on 'Show QR code' button") {
onReceiveAssetsBottomSheet { showQrCodeButton.clickWithAssertion() }
onReceiveAssetsBottomSheet { showQrCodeButton().clickWithAssertion() }
}
}
@ -39,3 +86,16 @@ fun BaseTestCase.checkQrCodeBottomSheetScenario() {
onTokenReceiveQrCodeBottomSheet { shareButton.assertIsDisplayed() }
}
}
/** Decodes the QR code on the receive bottom sheet and asserts it encodes the displayed address; returns that address. */
fun BaseTestCase.assertQrCodeEncodesDisplayedAddress(): String {
var displayedAddress = ""
step("Assert QR code encodes the displayed address") {
onTokenReceiveQrCodeBottomSheet {
displayedAddress = address.extractText()
val decoded = decodeQrCode(captureQrCodeBitmap())
Assert.assertEquals(displayedAddress, decoded)
}
}
return displayedAddress
}

View file

@ -1,8 +1,10 @@
package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.TokenReceiveAssetsBottomSheetTestTags
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
@ -11,10 +13,23 @@ import io.github.kakaocup.kakao.common.utilities.getResourceString
class ReceiveAssetsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ReceiveAssetsBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val showQrCodeButton: KNode = child {
/** 'Show QR code' button of the address at [index]; both pager cards stay composed, so match by position. */
fun showQrCodeButton(index: Int = 0): KNode = child {
hasText(getResourceString(R.string.token_receive_show_qr_code_title))
hasPosition(index)
useUnmergedTree = true
}
val addressesPager: KNode = child {
hasTestTag(TokenReceiveAssetsBottomSheetTestTags.ADDRESSES_PAGER)
useUnmergedTree = true
}
/** Pages the addresses carousel to the address of the given [index]. */
@OptIn(ExperimentalTestApi::class)
fun scrollToAddress(index: Int) {
addressesPager { performScrollToIndex(index) }
}
}
internal fun BaseTestCase.onReceiveAssetsBottomSheet(function: ReceiveAssetsBottomSheetPageObject.() -> Unit) =

View file

@ -118,6 +118,11 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true
}
/** 'Receive' row of the zero-balance actions block (Buy / Swap / Receive), shown instead of the action buttons. */
val receiveButton: KNode = child {
hasText(getResourceString(R.string.common_receive))
}
fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child {
hasAnySibling(withText(getResourceString(R.string.warning_send_blocked_funds_for_fee_title, feeCurrencyName)))
hasTestTag(NotificationTestTags.ICON)

View file

@ -0,0 +1,40 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TokenMarketBlockTestTags
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
class TokenMarketBlockPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TokenMarketBlockPageObject>(semanticsProvider = semanticsProvider) {
val block: KNode = child {
hasTestTag(TokenMarketBlockTestTags.BLOCK)
useUnmergedTree = true
}
val title: KNode = child {
hasTestTag(TokenMarketBlockTestTags.TITLE)
useUnmergedTree = true
}
val price: KNode = child {
hasTestTag(TokenMarketBlockTestTags.PRICE)
useUnmergedTree = true
}
val priceChange: KNode = child {
hasTestTag(TokenMarketBlockTestTags.PRICE_CHANGE)
useUnmergedTree = true
}
val chart: KNode = child {
hasTestTag(TokenMarketBlockTestTags.CHART)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTokenMarketBlock(function: TokenMarketBlockPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -1,11 +1,15 @@
package com.tangem.screens
import android.graphics.Bitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.captureToImage
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.TokenReceiveQrCodeBottomSheetTestTags
import com.tangem.wallet.R
import io.github.kakaocup.compose.intercept.operation.ComposeOperationType
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
@ -50,6 +54,17 @@ class TokenReceiveQrCodeBottomSheetPageObject(semanticsProvider: SemanticsNodeIn
hasText(getResourceString(R.string.common_share))
useUnmergedTree = true
}
/** Captures the QR code node as a bitmap via the Kakao node delegate (no raw composeTestRule access). */
fun captureQrCodeBitmap(): Bitmap {
lateinit var bitmap: Bitmap
qrCode.delegate.perform(QrCodeAction.CAPTURE) {
bitmap = captureToImage().asAndroidBitmap()
}
return bitmap
}
private enum class QrCodeAction : ComposeOperationType { CAPTURE }
}
internal fun BaseTestCase.onTokenReceiveQrCodeBottomSheet(function: TokenReceiveQrCodeBottomSheetPageObject.() -> Unit) =

View file

@ -3,7 +3,6 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.TokenReceiveWarningBottomSheetTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
@ -18,9 +17,7 @@ class TokenReceiveWarningBottomSheetPageObject(semanticsProvider: SemanticsNodeI
}
val gotItButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_got_it))
useUnmergedTree = true
}
}

View file

@ -31,6 +31,16 @@ class TxHistoryPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
hasTestTag(TransactionHistoryItemTestTags.STATUS_CONFIRMED)
useUnmergedTree = true
}
fun transactionUnconfirmedStatus(title: String): KNode = transactionItem(title).child {
hasTestTag(TransactionHistoryItemTestTags.STATUS_UNCONFIRMED)
useUnmergedTree = true
}
fun transactionAddress(title: String, address: String): KNode = transactionItem(title).child {
hasText(text = address, substring = true)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTxHistoryScreen(function: TxHistoryPageObject.() -> Unit) =

View file

@ -1,23 +1,39 @@
package com.tangem.tests.actionButtons
import com.tangem.common.BaseTestCase
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
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.constants.TestConstants.XRP_RECIPIENT_ADDRESS
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.pullToRefresh
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.checkQrCodeBottomSheetScenario
import com.tangem.scenarios.enterAmountAndOpenSendConfirm
import com.tangem.scenarios.goToQrCodeBottomSheet
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openSendFromTokenDetails
import com.tangem.scenarios.openSendScreenWithHotWallet
import com.tangem.scenarios.openSendSuccessScreenViaLongClickOnSendButton
import com.tangem.scenarios.readNetworkFeeAmount
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.scenarios.waitUntilNetworkFeeIsStable
import com.tangem.screens.onAddFundsBottomSheet
import com.tangem.screens.onDialog
import com.tangem.screens.onMainScreen
import com.tangem.screens.onSendScreen
import com.tangem.screens.onSendSuccessScreen
import com.tangem.screens.onSwapStoriesScreen
import com.tangem.screens.onSwapTokenScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTransferBottomSheet
import com.tangem.screens.onTxHistoryScreen
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.Ignore
@ -259,4 +275,146 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
}
}
}
@AllureId("4465")
@DisplayName("Action buttons (token details screen): 'Send' blocked while a transaction is active, works after completion")
@Test
fun sendBlockedWhileTransactionActiveTest() {
val tokenName = "XRP Ledger"
val amount = "1"
val userTokensState = "XRPHotWalletSvS"
val quotesState = "Ripple"
val startedState = "Started"
val rippleAccountInfoScenario = "ripple_account_info"
val pendingSendMessagePrefix =
getResourceString(R.string.token_button_unavailability_reason_pending_transaction_send).substringBefore("%")
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(rippleAccountInfoScenario)
},
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario: '$rippleAccountInfoScenario' to state: '$startedState'") {
setWireMockScenarioState(scenarioName = rippleAccountInfoScenario, state = startedState)
}
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName)
}
step("Enter amount '$amount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = amount, recipientAddress = XRP_RECIPIENT_ADDRESS)
}
waitUntilNetworkFeeIsStable { readNetworkFeeAmount() }
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
step("Click on 'Close' button") {
onSendSuccessScreen { closeButton.clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Open the transfer bottom sheet") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Assert 'Send' button is not enabled while the transaction is active") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTransferBottomSheet { sendButton.assertIsNotEnabled() }
}
}
step("Click on 'Send' button") {
onTransferBottomSheet { sendButton.performClick() }
}
step("Assert pending-transaction notification dialog is displayed") {
onDialog { containerWithText(pendingSendMessagePrefix).assertIsDisplayed() }
}
step("Assert 'Send' screen is not opened") {
onSendScreen { amountInputTextField.assertDoesNotExist() }
}
// Tapping the 'Send' row dismisses the transfer bottom sheet (onActionDispatched) before the dialog shows.
step("Close the notification dialog") {
onDialog { okButton.clickWithAssertion() }
}
step("Pull to refresh to complete the active transaction") {
pullToRefresh()
}
step("Open the transfer bottom sheet again") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Assert 'Send' button is enabled after the transaction is completed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTransferBottomSheet { sendButton.assertIsEnabled() }
}
}
step("Click on 'Send' button") {
onTransferBottomSheet { sendButton.performClick() }
}
step("Assert 'Send' screen is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendScreen { amountInputTextField.assertIsDisplayed() }
}
}
}
}
@AllureId("10209")
@DisplayName("Action buttons (token details screen): 'Send' unavailable for a zero-balance token with an active transaction")
@Test
fun sendUnavailableForZeroBalanceWithActiveTransactionTest() {
val tokenName = "Dogecoin"
val zeroBalanceState = "ZeroBalance"
val activeTxHistoryState = "UnconfirmedOutgoing"
val balanceScenarioName = "dogecoin_balance"
val txHistoryScenarioName = "dogecoin_tx_history"
val sendingTitle = getResourceString(R.string.common_sending)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(balanceScenarioName)
resetWireMockScenarioState(txHistoryScenarioName)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$balanceScenarioName' to state: '$zeroBalanceState'") {
setWireMockScenarioState(scenarioName = balanceScenarioName, state = zeroBalanceState)
}
step("Set WireMock scenario: '$txHistoryScenarioName' to state: '$activeTxHistoryState'") {
setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = activeTxHistoryState)
}
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("Assert active outgoing '$sendingTitle' transaction block is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTxHistoryScreen { transactionItem(sendingTitle).assertIsDisplayed() }
}
}
step("Assert 'Transfer' button is not displayed for the zero-balance token") {
onTokenDetailsScreen { transferButton.assertIsNotDisplayed() }
}
}
}
}

View file

@ -0,0 +1,268 @@
package com.tangem.tests.tokenDetails
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.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.assertQrCodeEncodesDisplayedAddress
import com.tangem.scenarios.assertQrCodesMatchForBothAddressTypes
import com.tangem.scenarios.goToQrCodeBottomSheet
import com.tangem.scenarios.openReceiveViaAddFunds
import com.tangem.scenarios.openTokenDetails
import com.tangem.screens.onTokenDetailsScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class TokenDetailsAddressesTest : BaseTestCase() {
@AllureId("4947")
@DisplayName("Token details (address): QR code encodes the displayed address (Bitcoin, 2 address types)")
@Test
fun qrCodeEncodesDisplayedAddressBitcoinTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Open receive via 'Add funds'") {
openReceiveViaAddFunds()
}
step("Assert QR codes match for both address types") {
assertQrCodesMatchForBothAddressTypes()
}
}
}
@AllureId("10218")
@DisplayName("Token details (address): QR code encodes the displayed address (Cosmos)")
@Test
fun qrCodeEncodesDisplayedAddressCosmosTest() {
val tokenName = "Cosmos"
val networksProvidersScenario = "networks_providers"
val appTransfersNetworksState = "AppTransfersNetworks"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
},
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(networksProvidersScenario)
},
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState)
}
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Open receive via 'Add funds'") {
openReceiveViaAddFunds()
}
step("Go to QR code bottom sheet") {
goToQrCodeBottomSheet()
}
step("Assert QR code encodes the displayed address") {
assertQrCodeEncodesDisplayedAddress()
}
}
}
@AllureId("10219")
@DisplayName("Token details (address): QR code encodes the displayed address (Kaspa)")
@Test
fun qrCodeEncodesDisplayedAddressKaspaTest() {
val tokenName = "Kaspa"
val networksProvidersScenario = "networks_providers"
val appTransfersNetworksState = "AppTransfersNetworks"
val quotesKaspaState = "Kaspa"
val kaspaUtxoScenario = "kaspa_utxo"
val kaspaUtxoState = "more_than_84_android"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
},
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(networksProvidersScenario)
resetWireMockScenarioState(kaspaUtxoScenario)
},
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesKaspaState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesKaspaState)
}
step("Set WireMock scenario: '$kaspaUtxoScenario' to state: '$kaspaUtxoState'") {
setWireMockScenarioState(scenarioName = kaspaUtxoScenario, state = kaspaUtxoState)
}
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Open receive via 'Add funds'") {
openReceiveViaAddFunds()
}
step("Go to QR code bottom sheet") {
goToQrCodeBottomSheet()
}
step("Assert QR code encodes the displayed address") {
assertQrCodeEncodesDisplayedAddress()
}
}
}
@AllureId("10215")
@DisplayName("Token details (address): QR code encodes the displayed address (Litecoin, 2 address types)")
@Test
fun qrCodeEncodesDisplayedAddressLitecoinTest() {
val tokenName = "Litecoin"
val userTokensState = "Litecoin"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Click on 'Receive' button") {
onTokenDetailsScreen { receiveButton.clickWithAssertion() }
}
step("Assert QR codes match for both address types") {
assertQrCodesMatchForBothAddressTypes()
}
}
}
@AllureId("10220")
@DisplayName("Token details (address): QR code encodes the displayed address (XDC Network, 2 address types)")
@Test
fun qrCodeEncodesDisplayedAddressXdcTest() {
val tokenName = "XDC Network"
val userTokensState = "XDC"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Click on 'Receive' button") {
onTokenDetailsScreen { receiveButton.clickWithAssertion() }
}
step("Assert QR codes match for both address types") {
assertQrCodesMatchForBothAddressTypes()
}
}
}
@AllureId("10216")
@DisplayName("Token details (address): QR code encodes the displayed address (Hedera)")
@Test
fun qrCodeEncodesDisplayedAddressHederaTest() {
val tokenName = "Hedera"
val networksProvidersScenario = "networks_providers"
val appTransfersNetworksState = "AppTransfersNetworks"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
},
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(networksProvidersScenario)
},
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState)
}
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Open receive via 'Add funds'") {
openReceiveViaAddFunds()
}
step("Go to QR code bottom sheet") {
goToQrCodeBottomSheet()
}
step("Assert QR code encodes the displayed address") {
assertQrCodeEncodesDisplayedAddress()
}
}
}
@AllureId("10217")
@DisplayName("Token details (address): QR code encodes the displayed address (Ethereum)")
@Test
fun qrCodeEncodesDisplayedAddressEthereumTest() {
val tokenName = "Ethereum"
setupHooks().run {
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Open receive via 'Add funds'") {
openReceiveViaAddFunds()
}
step("Go to QR code bottom sheet") {
goToQrCodeBottomSheet()
}
step("Assert QR code encodes the displayed address") {
assertQrCodeEncodesDisplayedAddress()
}
}
}
@AllureId("10214")
@DisplayName("Token details (address): QR code encodes the displayed address (Decimal Smart Chain, 2 address types)")
@Test
fun qrCodeEncodesDisplayedAddressDecimalTest() {
val tokenName = "Decimal Smart Chain"
val userTokensState = "Decimal"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Click on 'Receive' action") {
onTokenDetailsScreen { receiveButton.clickWithAssertion() }
}
step("Assert QR codes match for both address types") {
assertQrCodesMatchForBothAddressTypes()
}
}
}
}

View file

@ -0,0 +1,75 @@
package com.tangem.tests.tokenDetails
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.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTokenMarketBlock
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
@HiltAndroidTest
class TokenDetailsMarketPriceTests : BaseTestCase() {
@AllureId("301")
@DisplayName("Token details: Market Price block data")
@Test
fun marketPriceBlockDataTest() {
val tokenName = "Dogecoin"
val marketPriceTitle = getResourceString(R.string.markets_common_market_price)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName)
}
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("Assert 'Market Price' block is displayed with title '$marketPriceTitle'") {
onTokenMarketBlock {
block.assertIsDisplayed()
title.assertTextEquals(marketPriceTitle)
}
}
step("Assert price rate is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenMarketBlock { price.assertIsDisplayed() }
}
}
step("Assert 24h price change is displayed") {
onTokenMarketBlock { priceChange.assertIsDisplayed() }
}
step("Assert mini chart is displayed") {
onTokenMarketBlock { chart.assertIsDisplayed() }
}
}
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.tests.tokenDetails
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.DOGECOIN_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.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTxHistoryScreen
import com.tangem.utils.toBriefAddressFormat
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
@HiltAndroidTest
class TokenDetailsTests : BaseTestCase() {
private val txHistoryScenarioName = "dogecoin_tx_history"
@AllureId("304")
@DisplayName("Token details: active outgoing transaction block")
@Test
fun activeOutgoingTransactionBlockTest() {
val tokenName = "Dogecoin"
val currencySymbol = "DOGE"
val txHistoryScenarioState = "UnconfirmedOutgoing"
val sendingTitle = getResourceString(R.string.common_sending)
val recipientBriefAddress = DOGECOIN_RECIPIENT_ADDRESS.toBriefAddressFormat()
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(txHistoryScenarioName)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$txHistoryScenarioName' to state: '$txHistoryScenarioState'") {
setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = txHistoryScenarioState)
}
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("Assert active outgoing '$sendingTitle' transaction is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTxHistoryScreen { transactionItem(sendingTitle).assertIsDisplayed() }
}
}
step("Assert active outgoing transaction status is unconfirmed") {
onTxHistoryScreen { transactionUnconfirmedStatus(sendingTitle).assertIsDisplayed() }
}
step("Assert active outgoing transaction amount is displayed in '$currencySymbol'") {
onTxHistoryScreen {
transactionAmount(sendingTitle).assertIsDisplayed()
transactionCurrency(sendingTitle).assertTextEquals(currencySymbol)
}
}
step("Assert active outgoing transaction recipient address '$recipientBriefAddress' is displayed") {
onTxHistoryScreen { transactionAddress(sendingTitle, recipientBriefAddress).assertIsDisplayed() }
}
}
}
}

View file

@ -160,6 +160,10 @@ object WalletMockContent : MockContent {
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
),
DerivationPath("m/44'/111111'/0'/0/0") to ExtendedPublicKey( // Kaspa
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
),
),
extendedPublicKey = ExtendedPublicKey(
publicKey = secp256k1WalletPublicKey,
@ -254,6 +258,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/84'/2'/0'/0/0") to ExtendedPublicKey( // ltc (reuses valid btc key)
publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52),
chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
@ -261,6 +272,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/550'/0'/0/0") to ExtendedPublicKey( // xdc (EVM, reuses valid eth key)
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/60'/0'/0/1") to ExtendedPublicKey( // eth (account 2)
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
@ -486,6 +504,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/3030'/0'/0'/0'") to ExtendedPublicKey( // Hedera (address resolves via network)
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
),
),

View file

@ -22,6 +22,7 @@ import com.tangem.core.ui.res.TangemTheme
fun RowScope.TokenRowPriceChangeContent(
priceChangeState: PriceChangeState.Content,
isFlickering: Boolean,
modifier: Modifier = Modifier,
isAvailable: Boolean = true,
) {
val color = when (priceChangeState.type) {
@ -54,7 +55,7 @@ fun RowScope.TokenRowPriceChangeContent(
AnimatedContent(
targetState = priceChangeState.valueInPercent,
label = "Update the price text",
modifier = Modifier.padding(start = TangemTheme.dimens2.x0_5),
modifier = modifier.padding(start = TangemTheme.dimens2.x0_5),
) { animatedText ->
Text(
text = animatedText,

View file

@ -0,0 +1,9 @@
package com.tangem.core.ui.test
object TokenMarketBlockTestTags {
const val BLOCK = "TOKEN_MARKET_BLOCK"
const val TITLE = "TOKEN_MARKET_BLOCK_TITLE"
const val PRICE = "TOKEN_MARKET_BLOCK_PRICE"
const val PRICE_CHANGE = "TOKEN_MARKET_BLOCK_PRICE_CHANGE"
const val CHART = "TOKEN_MARKET_BLOCK_CHART"
}

View file

@ -0,0 +1,5 @@
package com.tangem.core.ui.test
object TokenReceiveAssetsBottomSheetTestTags {
const val ADDRESSES_PAGER = "TOKEN_RECEIVE_ASSETS_ADDRESSES_PAGER"
}

View file

@ -9,4 +9,5 @@ object TransactionHistoryItemTestTags {
/** 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"
const val STATUS_UNCONFIRMED = STATUS_PREFIX + "UNCONFIRMED"
}

View file

@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -36,6 +37,7 @@ import com.tangem.core.ui.ds.row.token.internal.TokenRowPriceChangeContent
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.TokenMarketBlockTestTags
import com.tangem.core.ui.R as CoreR
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.token.block.impl.model.formatter.toChartType
@ -53,13 +55,16 @@ internal fun TokenMarketBlock(tokenMarketBlockUM: TokenMarketBlockUM, modifier:
.fillMaxWidth()
.clip(RoundedCornerShape(TangemTheme.dimens2.x5))
.background(TangemTheme.colors2.surface.level3)
.clickable(onClick = tokenMarketBlockUM.onClick),
.clickable(onClick = tokenMarketBlockUM.onClick)
.testTag(TokenMarketBlockTestTags.BLOCK),
) {
Text(
text = stringResourceSafe(id = R.string.markets_common_market_price),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
modifier = Modifier
.layoutId(TangemRowLayoutId.START_TOP)
.testTag(TokenMarketBlockTestTags.TITLE),
)
Row(
@ -72,6 +77,7 @@ internal fun TokenMarketBlock(tokenMarketBlockUM: TokenMarketBlockUM, modifier:
overflow = TextOverflow.Ellipsis,
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier.testTag(TokenMarketBlockTestTags.PRICE),
)
TokenRowPriceChangeContent(
priceChangeState = PriceChangeState.Content(
@ -79,11 +85,14 @@ internal fun TokenMarketBlock(tokenMarketBlockUM: TokenMarketBlockUM, modifier:
valueInPercent = tokenMarketBlockUM.h24Percent.orEmpty(),
),
isFlickering = false,
modifier = Modifier.testTag(TokenMarketBlockTestTags.PRICE_CHANGE),
)
}
Box(
modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM),
modifier = Modifier
.layoutId(TangemRowLayoutId.END_BOTTOM)
.testTag(TokenMarketBlockTestTags.CHART),
) {
val chartModifier = Modifier.requiredSize(width = ChartWidth, height = ChartHeight)

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -50,6 +51,7 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_copy_24
import com.tangem.core.ui.res.generated.icons.ic_share_android_24
import com.tangem.core.ui.test.TokenReceiveAssetsBottomSheetTestTags
import com.tangem.features.tokenreceive.entity.ReceiveAddress
import com.tangem.features.tokenreceive.ui.state.ReceiveAssetsUM
import kotlinx.collections.immutable.ImmutableList
@ -175,6 +177,7 @@ private fun PrimaryAddressesItems(
state = pagerState,
contentPadding = PaddingValues(horizontal = 16.dp),
pageSpacing = 16.dp,
modifier = Modifier.testTag(TokenReceiveAssetsBottomSheetTestTags.ADDRESSES_PAGER),
) { page ->
val address = addresses[page]
AddressItem(