From 4f1710d77ca46c7f3f86aa0f7a7bd27e454ed067 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 17:37:21 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .claude/skills/write-ui-test/SKILL.md | 63 +++- .../kotlin/com/tangem/common/BaseTestCase.kt | 9 + .../com/tangem/common/utils/QrCodeUtils.kt | 22 ++ .../com/tangem/scenarios/BaseScenarios.kt | 13 + .../tangem/scenarios/ManageTokensScenarios.kt | 6 - .../com/tangem/scenarios/ReceiveScenarios.kt | 62 +++- .../ReceiveAssetsBottomSheetPageObject.kt | 17 +- .../tangem/screens/TokenDetailsPageObject.kt | 5 + .../screens/TokenMarketBlockPageObject.kt | 40 +++ ...TokenReceiveQrCodeBottomSheetPageObject.kt | 15 + ...okenReceiveWarningBottomSheetPageObject.kt | 3 - .../com/tangem/screens/TxHistoryPageObject.kt | 10 + .../TokenDetailsScreenActionButtonsTest.kt | 158 +++++++++++ .../tokenDetails/TokenDetailsAddressesTest.kt | 268 ++++++++++++++++++ .../TokenDetailsMarketPriceTests.kt | 75 +++++ .../tests/tokenDetails/TokenDetailsTests.kt | 86 ++++++ .../sdk/mocks/content/WalletMockContent.kt | 25 ++ .../internal/TokenRowPriceChangeContent.kt | 3 +- .../core/ui/test/TokenMarketBlockTestTags.kt | 9 + .../TokenReceiveAssetsBottomSheetTestTags.kt | 5 + .../ui/test/TransactionHistoryItemTestTags.kt | 1 + .../token/block/impl/ui/TokenMarketBlock.kt | 15 +- .../ui/TokenReceiveAssetsContent.kt | 3 + 23 files changed, 884 insertions(+), 29 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/common/utils/QrCodeUtils.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/TokenMarketBlockPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/tokenDetails/TokenDetailsAddressesTest.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/tokenDetails/TokenDetailsMarketPriceTests.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/tokenDetails/TokenDetailsTests.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/TokenMarketBlockTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveAssetsBottomSheetTestTags.kt diff --git a/.claude/skills/write-ui-test/SKILL.md b/.claude/skills/write-ui-test/SKILL.md index bcdffcc8f8..4bb08779b2 100644 --- a/.claude/skills/write-ui-test/SKILL.md +++ b/.claude/skills/write-ui-test/SKILL.md @@ -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) { }` + / `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` diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 53c2988bc6..8c646b2c7c 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -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( diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/QrCodeUtils.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/QrCodeUtils.kt new file mode 100644 index 0000000000..ef28e13d96 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/QrCodeUtils.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 31b41c8bea..0d27399737 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -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") { diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/ManageTokensScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/ManageTokensScenarios.kt index 85b85decfc..916ab2d773 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/ManageTokensScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/ManageTokensScenarios.kt @@ -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) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/ReceiveScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/ReceiveScenarios.kt index 57aff3cecc..6e8e34f59b 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/ReceiveScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/ReceiveScenarios.kt @@ -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 +} diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ReceiveAssetsBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ReceiveAssetsBottomSheetPageObject.kt index acea682a27..44b025c850 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/ReceiveAssetsBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/ReceiveAssetsBottomSheetPageObject.kt @@ -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(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) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index 804d998508..b1a7750edf 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -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) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenMarketBlockPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenMarketBlockPageObject.kt new file mode 100644 index 0000000000..550b9db9a7 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenMarketBlockPageObject.kt @@ -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(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) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveQrCodeBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveQrCodeBottomSheetPageObject.kt index e6bb3c228b..11eb5f94e3 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveQrCodeBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveQrCodeBottomSheetPageObject.kt @@ -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) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveWarningBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveWarningBottomSheetPageObject.kt index 2a0148863b..77173fb0af 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveWarningBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenReceiveWarningBottomSheetPageObject.kt @@ -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 } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TxHistoryPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TxHistoryPageObject.kt index 3ce45be03f..393a529e3e 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TxHistoryPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TxHistoryPageObject.kt @@ -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) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt index 6303345b96..218cf73ce3 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt @@ -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() } + } + } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/tokenDetails/TokenDetailsAddressesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/tokenDetails/TokenDetailsAddressesTest.kt new file mode 100644 index 0000000000..8e499b530f --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/tokenDetails/TokenDetailsAddressesTest.kt @@ -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() + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/tokenDetails/TokenDetailsMarketPriceTests.kt b/app/src/androidTest/kotlin/com/tangem/tests/tokenDetails/TokenDetailsMarketPriceTests.kt new file mode 100644 index 0000000000..eed420b1dd --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/tokenDetails/TokenDetailsMarketPriceTests.kt @@ -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() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/tokenDetails/TokenDetailsTests.kt b/app/src/androidTest/kotlin/com/tangem/tests/tokenDetails/TokenDetailsTests.kt new file mode 100644 index 0000000000..63190916b4 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/tokenDetails/TokenDetailsTests.kt @@ -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() } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index a8c47499e7..83af7bc92e 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -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, + ), ), ), diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt index 14e61f4061..885c142f75 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt @@ -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, diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TokenMarketBlockTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TokenMarketBlockTestTags.kt new file mode 100644 index 0000000000..96e736e335 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TokenMarketBlockTestTags.kt @@ -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" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveAssetsBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveAssetsBottomSheetTestTags.kt new file mode 100644 index 0000000000..0a71fa0839 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TokenReceiveAssetsBottomSheetTestTags.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui.test + +object TokenReceiveAssetsBottomSheetTestTags { + const val ADDRESSES_PAGER = "TOKEN_RECEIVE_ASSETS_ADDRESSES_PAGER" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryItemTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryItemTestTags.kt index 8a63a4ac21..9a0681880e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryItemTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryItemTestTags.kt @@ -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" } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt index a260c3616c..ae6414f004 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt @@ -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) diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt index 4577352882..a025eae2c6 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt @@ -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(