From 2110b5f5413bb9800e828465d5769f4453a011db Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Jun 2026 09:12:45 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .claude/skills/write-ui-test/SKILL.md | 10 ++++ .../com/tangem/scenarios/BaseScenarios.kt | 5 +- .../com/tangem/screens/DetailsPageObject.kt | 16 ++---- .../com/tangem/screens/DialogPageObject.kt | 5 +- .../screens/WalletSettingsPageObject.kt | 27 +++++++++ .../com/tangem/tests/AppCurrencyTest.kt | 31 ++++++++--- .../kotlin/com/tangem/tests/DetailsTest.kt | 55 ++----------------- .../com/tangem/tests/WalletRenameTest.kt | 5 +- .../core/ui/test/DetailsScreenTestTags.kt | 1 + .../details/ui/UserWalletListBlock.kt | 6 +- 10 files changed, 87 insertions(+), 74 deletions(-) diff --git a/.claude/skills/write-ui-test/SKILL.md b/.claude/skills/write-ui-test/SKILL.md index 77b244bafd..a0322d94b3 100644 --- a/.claude/skills/write-ui-test/SKILL.md +++ b/.claude/skills/write-ui-test/SKILL.md @@ -45,6 +45,16 @@ When the user asks to **port** an iOS test to Android: XCUITest/accessibility identifiers → Compose `testTag`; iOS `*Screen` page objects → Kotlin page objects in `com/tangem/screens/`; XCTest assertions → Kaspresso/Truth assertions. Re-derive the real Android `testTag`s and string resources from production source — never reuse iOS identifier strings. +- **Card/wallet mock mapping — where iOS uses `wallet2`, Android uses the default `Wallet`** + (`openMainScreen()` with no `productType` → `ProductType.Wallet`). Do NOT port iOS `.wallet2` to + `ProductType.Wallet2`. Other cards map directly: iOS `.twin` → `ProductType.Twins`, `.xrpNote` → + `ProductType.Note`, `.four12` → `Firmware412MockContent` (via the `mockContent` param). `ProductType.Wallet2` + exists but is a distinct Wallet-2.0-card case, not the iOS-`wallet2` analog. +- **A wallet has no balance until you sync.** The default `Wallet` mock starts with missing derivations + ("Some addresses are missing"); the fiat balance shows `—` until you call `synchronizeAddresses()` after + `openMainScreen()` (mirror `TotalBalanceUpdateTest`). Any test asserting a balance/fiat-equivalent must + sync first, then `waitUntil` the value loads — balances re-load asynchronously (e.g. after an app-currency + change the equivalent repaints with a delay). - The WireMock scenarios are usually shared across platforms, but the branch may differ (see `reference/running-and-debugging.md`). diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 911b7526d1..272f804ed8 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -175,7 +175,10 @@ fun BaseTestCase.openDeviceSettingsScreen() { onDetailsScreen { walletNameButton.performClick() } } step("Click on 'Device settings' button") { - onWalletSettingsScreen { deviceSettingsButton.clickWithAssertion() } + onWalletSettingsScreen { + scrollToDeviceSettings() + deviceSettingsButton.clickWithAssertion() + } } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt index 7c7a8635cd..4281531d5b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt @@ -23,17 +23,11 @@ class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasText(getResourceString(R.string.wallet_connect_title)) } - private val walletBlock: KNode = child { - hasTestTag(DetailsScreenTestTags.SCREEN_ITEM) - } - - val walletNameButton: KNode = walletBlock.child { - hasClickAction() - hasPosition(0) - } - - val scanCardButton: KNode = walletBlock.child { - hasText(getResourceString(R.string.scan_card_settings_button)) + // Match the wallet row by its own tag (not position-0 clickable, which races with the async-loading + // "Add Wallet" button and otherwise triggers a re-scan → "already saved" dialog). + val walletNameButton: KNode = child { + hasTestTag(DetailsScreenTestTags.USER_WALLET_ITEM) + useUnmergedTree = true } val buyTangemButton: KNode = child { diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt index 93d6b19b64..00e3613039 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt @@ -9,6 +9,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasTestTag as withTestTag class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -25,8 +26,10 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasTestTag(BaseDialogTestTags.TEXT) } + // Tag is on the OutlineTextField wrapper; the editable node is its descendant with a SetText action. val inputField: KNode = child { - hasTestTag(BaseDialogTestTags.TEXT_INPUT_FIELD) + hasSetTextAction() + hasAnyAncestor(withTestTag(BaseDialogTestTags.TEXT_INPUT_FIELD)) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt index 0babf5f29f..5a014f01f9 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt @@ -1,5 +1,6 @@ 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.test.TopAppBarTestTags @@ -9,6 +10,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : @@ -22,6 +24,31 @@ class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi hasTestTag(WalletSettingsScreenTestTags.SCREEN_ITEM) } + // The Accounts section loads async and can push rows below the fold — scroll before asserting/clicking. + private val scrollableContainer: KNode = child { + hasTestTag(WalletSettingsScreenTestTags.SCREEN_CONTAINER) + } + + @OptIn(ExperimentalTestApi::class) + fun scrollToText(text: String) = scrollableContainer { performScrollToNode(withText(text)) } + + @OptIn(ExperimentalTestApi::class) + fun scrollToDeviceSettings() = scrollToText(getResourceString(R.string.card_settings_title)) + + @OptIn(ExperimentalTestApi::class) + fun scrollToLinkMoreCards() = scrollToText(getResourceString(R.string.details_row_title_create_backup)) + + @OptIn(ExperimentalTestApi::class) + fun scrollToReferralProgram() = scrollToText(getResourceString(R.string.details_referral_title)) + + @OptIn(ExperimentalTestApi::class) + fun scrollToForgetWallet() = scrollToText(getResourceString(R.string.settings_forget_wallet)) + + @OptIn(ExperimentalTestApi::class) + fun scrollToRenameButton() = scrollableContainer { + performScrollToNode(withTestTag(WalletSettingsScreenTestTags.RENAME_BUTTON)) + } + val linkMoreCardsButton: KNode = walletSettingsItem.child { hasText(getResourceString(R.string.details_row_title_create_backup)) } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt index d1f2559169..97730e72e3 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt @@ -1,11 +1,13 @@ package com.tangem.tests 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.isDisplayedSafely import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState -import com.tangem.domain.models.scan.ProductType import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.* import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId @@ -23,7 +25,7 @@ class AppCurrencyTest : BaseTestCase() { val appSettingsState = "AppSettings" val targetCurrency = "EUR" val targetSymbol = "€" - val token = "Polygon" + val token = "Bitcoin" setupHooks( additionalAfterSection = { resetWireMockScenarioState(currenciesScenario) }, @@ -32,8 +34,9 @@ class AppCurrencyTest : BaseTestCase() { setWireMockScenarioState(scenarioName = currenciesScenario, state = appSettingsState) } step("Open 'Main Screen'") { - openMainScreen(productType = ProductType.Wallet2) + openMainScreen() } + synchronizeAddresses() step("Open wallet details") { onMainScreenTopBar { moreButton.clickWithAssertion() } } @@ -53,18 +56,30 @@ class AppCurrencyTest : BaseTestCase() { onAppCurrencySelectorScreen { currencyItem(targetCurrency).performClick() } } step("Return to 'Main' screen") { - device.uiDevice.pressBack() - device.uiDevice.pressBack() - waitForIdle() + var displayed = false + var attempts = 0 + while (!displayed && attempts < 4) { + onMainScreen { displayed = screenContainer.isDisplayedSafely() } + if (!displayed) { + device.uiDevice.pressBack() + waitForIdle() + attempts++ + } + } } step("Assert total balance contains '$targetSymbol' on 'Main' screen") { - onMainScreen { totalBalanceText.assertTextContains(targetSymbol) } + // Balance re-loads in the new currency async after the switch — wait for the € equivalent. + composeTestRule.waitUntil(WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onMainScreen { totalBalanceText.assertTextContains(targetSymbol) } }.isSuccess + } } step("Click on token '$token'") { onMainScreen { tokenWithTitleAndAddress(token).clickWithAssertion() } } step("Assert token fiat balance contains '$targetSymbol'") { - onTokenDetailsScreen { fiatBalance.assertTextContains(targetSymbol) } + composeTestRule.waitUntil(WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onTokenDetailsScreen { fiatBalance.assertTextContains(targetSymbol) } }.isSuccess + } } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt index 3de6e2e1c6..a4b8e21a22 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt @@ -49,66 +49,19 @@ class DetailsTest : BaseTestCase() { } onWalletSettingsScreen { step("Assert 'Link more cards' button is visible") { + scrollToLinkMoreCards() linkMoreCardsButton.assertIsDisplayed() } step("Assert 'Card Settings' button is visible") { + scrollToDeviceSettings() deviceSettingsButton.assertIsDisplayed() } step("Assert 'Referral program' button is visible") { + scrollToReferralProgram() referralProgramButton.assertIsDisplayed() } step("Assert 'Forget wallet' button is visible") { - forgetWalletButton.assertIsDisplayed() - } - } - } - - @DisplayName("Details: (Wallet 2.0) fields") - @Test - fun wallet2DetailsTest() = - setupHooks().run { - step("Open 'Main Screen'") { - openMainScreen(productType = ProductType.Wallet2) - } - onMainScreenTopBar { - step("Open wallet details") { - moreButton.clickWithAssertion() - } - } - onDetailsScreen { - step("Assert 'Wallet connect' button is visible") { - walletConnectButton.assertIsDisplayed() - } - step("Assert 'Scan card' button is visible") { - scanCardButton.assertIsDisplayed() - } - step("Assert 'Buy Tangem card' button is visible") { - buyTangemButton.assertIsDisplayed() - } - step("Assert 'App settings' button is visible") { - appSettingsButton.assertIsDisplayed() - } - step("Assert 'Contact support' button is visible") { - contactSupportButton.assertIsDisplayed() - } - step("Assert 'Terms or service' button is visible") { - toSButton.assertIsDisplayed() - } - step("Open 'Wallet settings' screen") { - walletNameButton.clickWithAssertion() - } - } - onWalletSettingsScreen { - step("Assert 'Link more cards' button does not exist") { - linkMoreCardsButton.assertIsNotDisplayed() - } - step("Assert 'Card Settings' button is visible") { - deviceSettingsButton.assertIsDisplayed() - } - step("Assert 'Referral program' button is visible") { - referralProgramButton.assertIsDisplayed() - } - step("Assert 'Forget wallet' button is visible") { + scrollToForgetWallet() forgetWalletButton.assertIsDisplayed() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WalletRenameTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WalletRenameTest.kt index 7fb5933c60..2c3e1d848b 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/WalletRenameTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/WalletRenameTest.kt @@ -29,7 +29,10 @@ class WalletRenameTest : BaseTestCase() { onDetailsScreen { walletNameButton.clickWithAssertion() } } step("Click on 'Rename' button") { - onWalletSettingsScreen { renameWalletButton.clickWithAssertion() } + onWalletSettingsScreen { + scrollToRenameButton() + renameWalletButton.clickWithAssertion() + } } step("Enter new wallet name '$newWalletName'") { onDialog { inputField.performTextReplacement(newWalletName) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/DetailsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/DetailsScreenTestTags.kt index bbbeb91dd5..ec91e0b88a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/DetailsScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/DetailsScreenTestTags.kt @@ -4,4 +4,5 @@ object DetailsScreenTestTags { const val SCREEN_CONTAINER = "DETAILS_SCREEN_CONTAINER" const val SCREEN_ITEM = "DETAILS_SCREEN_ITEM" const val VERSION_NAME = "DETAILS_SCREEN_VERSION_NAME" + const val USER_WALLET_ITEM = "DETAILS_SCREEN_USER_WALLET_ITEM" } \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt index 2cff0e2d16..df93d20331 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -34,6 +35,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.DetailsScreenTestTags import com.tangem.features.details.component.UserWalletListComponent import com.tangem.features.details.component.preview.PreviewUserWalletListComponent import com.tangem.features.details.entity.UserWalletListUM @@ -69,7 +71,9 @@ internal fun UserWalletListBlock(state: UserWalletListUM, modifier: Modifier = M model = walletState, reorderableListState = reorderableListState, walletReorderUM = state.walletReorderUM, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag(DetailsScreenTestTags.USER_WALLET_ITEM), ) } item(key = "add_wallet_button") {