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. 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 ### Strings
- **No hardcoded UI text** in matchers. Use `getResourceString(R.string.foo)` from - **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 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 -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. 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 - **In scenario / `BaseTestCase`-extension code, `flakySafely` is NOT available** regardless — use
same `composeTestRule.waitUntil` fallback (or `waitUntilAtLeastOneExists(matcher, timeout)` to wait for `BaseTestCase.awaitSuccess(timeoutMillis = WAIT_UNTIL_TIMEOUT) { … }` (a shared member on `BaseTestCase`,
appearance, `{ a exists || b exists }` for either/or). no import needed) which wraps `composeTestRule.waitUntil { runCatching(block).isSuccess }`. Each async step
- **Don't repeat the `composeTestRule.waitUntil(timeoutMillis = …) { runCatching { … }.isSuccess }` reads `awaitSuccess { onXxxScreen { field.assertExists() } }` before the action. For appearance-only waits
block across steps — extract a one-line private helper** in the scenario file and call that instead. `composeTestRule.waitUntilAtLeastOneExists(matcher, timeout)` (or `{ a exists || b exists }` for either/or)
A multi-step scenario that gates every async step this way turns into copy-paste noise (and reviewers is also fine.
flag it). Add once, near the top of the file: **Do not re-declare a private `awaitSuccess` in a scenario file** — the shared `BaseTestCase.awaitSuccess`
```kotlin already exists; older files may still have a private copy, don't copy that pattern.
// 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.
- **Right-size the timeout — don't stamp `WAIT_UNTIL_TIMEOUT_LONG` on every step.** The timeout is a - **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 *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` `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.allure.FailedStepScreenshotInterceptor
import com.tangem.common.constants.TestConstants.ALLURE_LABEL_NAME import com.tangem.common.constants.TestConstants.ALLURE_LABEL_NAME
import com.tangem.common.constants.TestConstants.ALLURE_LABEL_VALUE 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.common.rules.ApiEnvironmentRule
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -179,6 +180,14 @@ abstract class BaseTestCase : TestCase(
fun waitForIdle() = composeTestRule.waitForIdle() 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 { private fun applicationInjectionRule(): ApplicationInjectionExecutionRule {
return ApplicationInjectionExecutionRule( return ApplicationInjectionExecutionRule(
toggleStates = mapOf( 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) @OptIn(ExperimentalTestApi::class)
fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String, accessCode: String = "") { fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String, accessCode: String = "") {
step("Click on 'Get started' button") { step("Click on 'Get started' button") {

View file

@ -1,7 +1,6 @@
package com.tangem.scenarios package com.tangem.scenarios
import com.tangem.common.BaseTestCase 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.clickWithAssertion
import com.tangem.common.extensions.performTextInputInChunks import com.tangem.common.extensions.performTextInputInChunks
import com.tangem.screens.accounts.onAccountDetailsScreen 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) 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()) { fun BaseTestCase.openManageTokens(accountName: String = mainAccountName()) {
openWalletSettingsScreen() openWalletSettingsScreen()
openAccountDetails(accountName) openAccountDetails(accountName)

View file

@ -1,11 +1,58 @@
package com.tangem.scenarios package com.tangem.scenarios
import com.tangem.common.BaseTestCase 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.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.onReceiveAssetsBottomSheet
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTokenReceiveQrCodeBottomSheet import com.tangem.screens.onTokenReceiveQrCodeBottomSheet
import com.tangem.screens.onTokenReceiveWarningBottomSheet import com.tangem.screens.onTokenReceiveWarningBottomSheet
import io.qameta.allure.kotlin.Allure.step 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() { fun BaseTestCase.goToQrCodeBottomSheet() {
step("Assert 'Token receive warning' bottom sheet is displayed") { step("Assert 'Token receive warning' bottom sheet is displayed") {
@ -15,7 +62,7 @@ fun BaseTestCase.goToQrCodeBottomSheet() {
onTokenReceiveWarningBottomSheet { gotItButton.performClick() } onTokenReceiveWarningBottomSheet { gotItButton.performClick() }
} }
step("Click on 'Show QR code' button") { step("Click on 'Show QR code' button") {
onReceiveAssetsBottomSheet { showQrCodeButton.clickWithAssertion() } onReceiveAssetsBottomSheet { showQrCodeButton().clickWithAssertion() }
} }
} }
@ -39,3 +86,16 @@ fun BaseTestCase.checkQrCodeBottomSheetScenario() {
onTokenReceiveQrCodeBottomSheet { shareButton.assertIsDisplayed() } 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 package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R 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
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
@ -11,10 +13,23 @@ import io.github.kakaocup.kakao.common.utilities.getResourceString
class ReceiveAssetsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class ReceiveAssetsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ReceiveAssetsBottomSheetPageObject>(semanticsProvider = semanticsProvider) { 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)) hasText(getResourceString(R.string.token_receive_show_qr_code_title))
hasPosition(index)
useUnmergedTree = true 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) = internal fun BaseTestCase.onReceiveAssetsBottomSheet(function: ReceiveAssetsBottomSheetPageObject.() -> Unit) =

View file

@ -118,6 +118,11 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true 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 { fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child {
hasAnySibling(withText(getResourceString(R.string.warning_send_blocked_funds_for_fee_title, feeCurrencyName))) hasAnySibling(withText(getResourceString(R.string.warning_send_blocked_funds_for_fee_title, feeCurrencyName)))
hasTestTag(NotificationTestTags.ICON) 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 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.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.captureToImage
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseBottomSheetTestTags import com.tangem.core.ui.test.BaseBottomSheetTestTags
import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.TokenReceiveQrCodeBottomSheetTestTags import com.tangem.core.ui.test.TokenReceiveQrCodeBottomSheetTestTags
import com.tangem.wallet.R 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
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
@ -50,6 +54,17 @@ class TokenReceiveQrCodeBottomSheetPageObject(semanticsProvider: SemanticsNodeIn
hasText(getResourceString(R.string.common_share)) hasText(getResourceString(R.string.common_share))
useUnmergedTree = true 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) = internal fun BaseTestCase.onTokenReceiveQrCodeBottomSheet(function: TokenReceiveQrCodeBottomSheetPageObject.() -> Unit) =

View file

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

View file

@ -31,6 +31,16 @@ class TxHistoryPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
hasTestTag(TransactionHistoryItemTestTags.STATUS_CONFIRMED) hasTestTag(TransactionHistoryItemTestTags.STATUS_CONFIRMED)
useUnmergedTree = true 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) = internal fun BaseTestCase.onTxHistoryScreen(function: TxHistoryPageObject.() -> Unit) =

View file

@ -1,23 +1,39 @@
package com.tangem.tests.actionButtons package com.tangem.tests.actionButtons
import com.tangem.common.BaseTestCase 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
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.clickWithAssertion
import com.tangem.common.extensions.pullToRefresh
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.checkQrCodeBottomSheetScenario import com.tangem.scenarios.checkQrCodeBottomSheetScenario
import com.tangem.scenarios.enterAmountAndOpenSendConfirm
import com.tangem.scenarios.goToQrCodeBottomSheet import com.tangem.scenarios.goToQrCodeBottomSheet
import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openSendFromTokenDetails 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.synchronizeAddresses
import com.tangem.scenarios.waitUntilNetworkFeeIsStable
import com.tangem.screens.onAddFundsBottomSheet import com.tangem.screens.onAddFundsBottomSheet
import com.tangem.screens.onDialog
import com.tangem.screens.onMainScreen import com.tangem.screens.onMainScreen
import com.tangem.screens.onSendScreen import com.tangem.screens.onSendScreen
import com.tangem.screens.onSendSuccessScreen
import com.tangem.screens.onSwapStoriesScreen import com.tangem.screens.onSwapStoriesScreen
import com.tangem.screens.onSwapTokenScreen import com.tangem.screens.onSwapTokenScreen
import com.tangem.screens.onTokenDetailsScreen import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTransferBottomSheet import com.tangem.screens.onTransferBottomSheet
import com.tangem.screens.onTxHistoryScreen
import dagger.hilt.android.testing.HiltAndroidTest 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.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore 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), 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), 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( extendedPublicKey = ExtendedPublicKey(
publicKey = secp256k1WalletPublicKey, publicKey = secp256k1WalletPublicKey,
@ -254,6 +258,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0), parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 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 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), 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), 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), parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 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) 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), 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), 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), parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 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( fun RowScope.TokenRowPriceChangeContent(
priceChangeState: PriceChangeState.Content, priceChangeState: PriceChangeState.Content,
isFlickering: Boolean, isFlickering: Boolean,
modifier: Modifier = Modifier,
isAvailable: Boolean = true, isAvailable: Boolean = true,
) { ) {
val color = when (priceChangeState.type) { val color = when (priceChangeState.type) {
@ -54,7 +55,7 @@ fun RowScope.TokenRowPriceChangeContent(
AnimatedContent( AnimatedContent(
targetState = priceChangeState.valueInPercent, targetState = priceChangeState.valueInPercent,
label = "Update the price text", label = "Update the price text",
modifier = Modifier.padding(start = TangemTheme.dimens2.x0_5), modifier = modifier.padding(start = TangemTheme.dimens2.x0_5),
) { animatedText -> ) { animatedText ->
Text( Text(
text = animatedText, 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. */ /** 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_PREFIX = "TRANSACTION_HISTORY_ITEM_STATUS_"
const val STATUS_CONFIRMED = STATUS_PREFIX + "CONFIRMED" 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.layoutId import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter 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.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.TokenMarketBlockTestTags
import com.tangem.core.ui.R as CoreR import com.tangem.core.ui.R as CoreR
import com.tangem.features.markets.impl.R import com.tangem.features.markets.impl.R
import com.tangem.features.markets.token.block.impl.model.formatter.toChartType import com.tangem.features.markets.token.block.impl.model.formatter.toChartType
@ -53,13 +55,16 @@ internal fun TokenMarketBlock(tokenMarketBlockUM: TokenMarketBlockUM, modifier:
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(TangemTheme.dimens2.x5)) .clip(RoundedCornerShape(TangemTheme.dimens2.x5))
.background(TangemTheme.colors2.surface.level3) .background(TangemTheme.colors2.surface.level3)
.clickable(onClick = tokenMarketBlockUM.onClick), .clickable(onClick = tokenMarketBlockUM.onClick)
.testTag(TokenMarketBlockTestTags.BLOCK),
) { ) {
Text( Text(
text = stringResourceSafe(id = R.string.markets_common_market_price), text = stringResourceSafe(id = R.string.markets_common_market_price),
style = TangemTheme.typography2.bodySemibold16, style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary, color = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), modifier = Modifier
.layoutId(TangemRowLayoutId.START_TOP)
.testTag(TokenMarketBlockTestTags.TITLE),
) )
Row( Row(
@ -72,6 +77,7 @@ internal fun TokenMarketBlock(tokenMarketBlockUM: TokenMarketBlockUM, modifier:
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = TangemTheme.typography2.captionMedium12, style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.primary, color = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier.testTag(TokenMarketBlockTestTags.PRICE),
) )
TokenRowPriceChangeContent( TokenRowPriceChangeContent(
priceChangeState = PriceChangeState.Content( priceChangeState = PriceChangeState.Content(
@ -79,11 +85,14 @@ internal fun TokenMarketBlock(tokenMarketBlockUM: TokenMarketBlockUM, modifier:
valueInPercent = tokenMarketBlockUM.h24Percent.orEmpty(), valueInPercent = tokenMarketBlockUM.h24Percent.orEmpty(),
), ),
isFlickering = false, isFlickering = false,
modifier = Modifier.testTag(TokenMarketBlockTestTags.PRICE_CHANGE),
) )
} }
Box( Box(
modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM), modifier = Modifier
.layoutId(TangemRowLayoutId.END_BOTTOM)
.testTag(TokenMarketBlockTestTags.CHART),
) { ) {
val chartModifier = Modifier.requiredSize(width = ChartWidth, height = ChartHeight) 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.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.Icons
import com.tangem.core.ui.res.generated.icons.ic_copy_24 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.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.entity.ReceiveAddress
import com.tangem.features.tokenreceive.ui.state.ReceiveAssetsUM import com.tangem.features.tokenreceive.ui.state.ReceiveAssetsUM
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -175,6 +177,7 @@ private fun PrimaryAddressesItems(
state = pagerState, state = pagerState,
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
pageSpacing = 16.dp, pageSpacing = 16.dp,
modifier = Modifier.testTag(TokenReceiveAssetsBottomSheetTestTags.ADDRESSES_PAGER),
) { page -> ) { page ->
val address = addresses[page] val address = addresses[page]
AddressItem( AddressItem(