Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-03 14:39:06 +03:00
parent df4186cca0
commit 1c1509640a
16 changed files with 889 additions and 20 deletions

View file

@ -24,6 +24,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.tap.MainActivity
@ -63,6 +64,9 @@ abstract class BaseTestCase : TestCase(
@Inject
lateinit var getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase
@Inject
lateinit var singleAccountListSupplier: SingleAccountListSupplier
private val hiltRule = HiltAndroidRule(this)
private val apiEnvironmentRule = ApiEnvironmentRule()
private val permissionRule = GrantPermissionRule.grant(

View file

@ -4,6 +4,8 @@ import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_SHORT
fun BaseTestCase.swipeVertical(
direction: SwipeDirection,
@ -96,6 +98,12 @@ fun BaseTestCase.restartApp(packageName: String) {
waitForIdle()
}
fun BaseTestCase.clickOnSystemButton(buttonName: String) {
device.uiDevice.wait(Until.hasObject(By.text(buttonName)), WAIT_UNTIL_TIMEOUT_SHORT)
device.uiDevice.findObject(By.text(buttonName))?.click()
?: throw AssertionError("System '$buttonName' button not found")
}
enum class SwipeDirection {
UP, DOWN
}

View file

@ -0,0 +1,20 @@
package com.tangem.common.utils
/**
* Helper for inspecting individual nodes of a BIP-44-style derivation path string
* (e.g. one read from `Network.derivationPath` of a token in the domain account model).
*/
object DerivationPathHelper {
/**
* Returns the [index1Based]-th node of a derivation path, ignoring the leading `m`.
* For "m/44'/0'/1'/0/0": node 1 = "44'", node 3 = "1'", node 5 = "0".
*/
fun nodeAt(derivationPath: String, index1Based: Int): String {
val nodes = derivationPath.removePrefix("m/").split("/")
require(index1Based in 1..nodes.size) {
"Node #$index1Based is out of range for path '$derivationPath' (${nodes.size} nodes)"
}
return nodes[index1Based - 1]
}
}

View file

@ -1,14 +1,25 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.domain.models.account.Account
import com.tangem.screens.accounts.onAccountDetailsScreen
import com.tangem.screens.accounts.onAccountInfoEditorScreen
import com.tangem.screens.accounts.onArchivedAccountsScreen
import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onDialog
import com.tangem.screens.onMainScreenTopBar
import com.tangem.screens.onWalletSettingsScreen
import com.tangem.utils.logging.TangemLogger
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
private const val ACCOUNT_POLL_INTERVAL_MS = 500L
fun BaseTestCase.openWalletSettingsScreen() {
step("Open 'Wallet details' screen") {
@ -19,6 +30,15 @@ fun BaseTestCase.openWalletSettingsScreen() {
}
}
fun BaseTestCase.startAccountCreation() {
step("Click on 'Add account' button") {
onWalletSettingsScreen { addAccountButton.clickWithAssertion() }
}
step("Assert 'Account info editor' screen is displayed") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
}
fun BaseTestCase.openAccountDetails(accountName: String) {
step("Click on account: '$accountName'") {
onWalletSettingsScreen { accountItem(accountName).clickWithAssertion() }
@ -28,6 +48,46 @@ fun BaseTestCase.openAccountDetails(accountName: String) {
}
}
fun BaseTestCase.checkUnsavedChangesCreationModal() {
step("Assert 'Unsaved changes' alert is displayed") {
onDialog { dialogContainer.assertIsDisplayed() }
}
step("Assert 'Unsaved changes' alert has proper title") {
onDialog { title.assertTextContains(getResourceString(R.string.account_unsaved_dialog_title)) }
}
step("Assert 'Unsaved changes' alert has proper description for account creation") {
onDialog {
text.assertTextContains(getResourceString(R.string.account_unsaved_dialog_message_create))
}
}
step("Assert 'Keep editing' button is displayed in alert with proper text") {
onDialog { keepEditButton.assertIsDisplayed() }
}
step("Assert 'Discard' button is displayed in alert") {
onDialog { discardButton.assertIsDisplayed() }
}
}
fun BaseTestCase.assertUnsavedChangesEditionModal() {
step("Assert 'Unsaved changes' alert is displayed") {
onDialog { dialogContainer.assertIsDisplayed() }
}
step("Assert 'Unsaved changes' alert has proper title") {
onDialog { title.assertTextContains(getResourceString(R.string.account_unsaved_dialog_title)) }
}
step("Assert 'Unsaved changes' alert has proper description for account creation") {
onDialog {
text.assertTextContains(getResourceString(R.string.account_unsaved_dialog_message_edit))
}
}
step("Assert 'Keep editing' button is displayed in alert with proper text") {
onDialog { keepEditButton.assertIsDisplayed() }
}
step("Assert 'Discard' button is displayed in alert") {
onDialog { discardButton.assertIsDisplayed() }
}
}
fun BaseTestCase.archiveAccount() {
step("Assert 'Archive' button is displayed") {
onAccountDetailsScreen { archiveAccountButton.assertIsDisplayed() }
@ -99,4 +159,53 @@ fun BaseTestCase.restoreArchivedAccount(accountName: String) {
.restoreButton.clickWithAssertion()
}
}
}
}
/**
* Polls [singleAccountListSupplier] for the selected wallet until a [Account.CryptoPortfolio] with the given
* [derivationIndex] appears with a non-empty token list, then returns it.
*
* Per-account token derivation paths live in the domain account model
* ([Account.CryptoPortfolio.cryptoCurrencies] [com.tangem.domain.models.network.Network.derivationPath]),
* not in the tester-menu "Addresses info" (which reads from the account-agnostic wallet managers store and
* only ever shows main/base derivations). Reading the model directly is the reliable source for asserting
* per-account derivations.
*/
fun BaseTestCase.awaitCryptoPortfolioAccount(derivationIndex: Int): Account.CryptoPortfolio {
val walletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId
?: error("No selected wallet found")
var account: Account.CryptoPortfolio? = null
runBlocking {
withTimeout(WAIT_UNTIL_TIMEOUT_VERY_LONG) {
while (true) {
val candidate = singleAccountListSupplier.getSyncOrNull(walletId)
?.accounts
?.filterIsInstance<Account.CryptoPortfolio>()
?.firstOrNull { it.derivationIndex.value == derivationIndex }
if (candidate != null && candidate.cryptoCurrencies.isNotEmpty()) {
TangemLogger.i(
"Account with derivation index $derivationIndex resolved: " +
"${candidate.cryptoCurrencies.size} token(s)",
)
account = candidate
return@withTimeout
}
delay(ACCOUNT_POLL_INTERVAL_MS)
}
}
}
return requireNotNull(account) {
"Account with derivation index $derivationIndex was not found for wallet $walletId"
}
}
/**
* Returns all derivation paths of tokens whose name equals [tokenName] (case-insensitive) within this account.
*/
fun Account.CryptoPortfolio.derivationPathsForToken(tokenName: String): List<String> = cryptoCurrencies
.filter { it.name.equals(tokenName, ignoreCase = true) }
.mapNotNull { it.network.derivationPath.value }

View file

@ -32,6 +32,11 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
useUnmergedTree = true
}
val gotItButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_got_it))
}
val cancelButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_cancel))
@ -52,6 +57,16 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasText(getResourceString(R.string.account_details_archive_action))
}
val discardButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.account_unsaved_dialog_action_second))
}
val keepEditButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.account_unsaved_dialog_action_first))
}
val continueButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_continue))

View file

@ -256,6 +256,22 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
useUnmergedTree = true
}
/**
* Empty-tokens placeholder shown under an expanded account that has no tokens.
*/
val emptyAccountTokensPlaceholder: KNode = child {
hasTestTag(MainScreenTestTags.EMPTY_TOKENS_PLACEHOLDER)
useUnmergedTree = true
}
/**
* 'Add tokens' button inside the empty-account placeholder. Click opens manage tokens for that account.
*/
val emptyAccountAddTokensButton: KNode = child {
hasTestTag(MainScreenTestTags.EMPTY_TOKENS_ADD_BUTTON)
useUnmergedTree = true
}
/**
* Main account header on the main screen. Click to expand/collapse its tokens list.
*/
@ -358,6 +374,46 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
}
}
/**
* Account row on the main screen. Tappable click to expand/collapse its tokens.
*/
@OptIn(ExperimentalTestApi::class)
fun findAccountSectionByName(accountName: String): KNode {
return lazyList.child {
hasTestTag(MainScreenTestTags.ACCOUNT_LIST_ITEM)
hasAnyDescendant(withText(accountName))
useUnmergedTree = true
}
}
/**
* Scrolls the account row into view and collapses the top bar so the account's tokens (or the
* empty placeholder) land within screen bounds after expansion. Click via [findAccountSectionByName].
*/
@OptIn(ExperimentalTestApi::class)
fun scrollToAccountSection(accountName: String) {
collapseHeader()
lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.ACCOUNT_LIST_ITEM)
hasAnyDescendant(withText(accountName))
useUnmergedTree = true
}
}
/**
* Find a token row on the main screen by token name. Tokens belonging to collapsed accounts
* are hidden from the semantics tree, so expanding a single account before calling this
* effectively scopes the lookup to that account's tokens.
*/
@OptIn(ExperimentalTestApi::class)
fun findTokenInAnyAccountByName(tokenName: String): KNode {
return lazyList.child {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyDescendant(withText(tokenName))
useUnmergedTree = true
}
}
fun KNode.assertIsUnreachable() {
this {
hasAnyAncestor(withText(getResourceString(R.string.common_unreachable)))
@ -370,16 +426,11 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
* Tests will fail if assertIsNotDisplayed() or assertDoesNotExist() are used instead.
*/
fun assertTokenDoesNotExist(tokenTitle: String) {
try {
tokenWithTitleAndAddress(tokenTitle).assertExists()
throw AssertionError("Token with title '$tokenTitle' should not exist but was found")
} catch (e: AssertionError) {
if (e.message?.contains("No node found") == true) {
return
} else {
throw e
}
}
lazyList.child<KNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyDescendant(withText(tokenTitle))
useUnmergedTree = true
}.assertDoesNotExist()
}
fun assertTokensCount(expectedCount: Int) {

View file

@ -7,6 +7,7 @@ import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.BaseSearchBarTestTags
import com.tangem.core.ui.test.ManageTokensScreenTestTags
import com.tangem.core.ui.test.SwitchTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
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
@ -20,6 +21,16 @@ import androidx.compose.ui.test.hasAnyAncestor as withAnyAncestor
class ManageTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ManageTokensPageObject>(semanticsProvider = semanticsProvider) {
val topAppBarBackButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}
val topAppBarTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(com.tangem.core.ui.R.string.add_tokens_title))
useUnmergedTree = true
}
val searchField: KNode = child {
hasTestTag(BaseSearchBarTestTags.SEARCH_BAR)
}

View file

@ -16,6 +16,10 @@ import androidx.compose.ui.test.hasText as withText
class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<WalletSettingsPageObject>(semanticsProvider = semanticsProvider) {
val screenContainer: KNode = child {
hasTestTag(WalletSettingsScreenTestTags.SCREEN_CONTAINER)
}
val topAppBarBackButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}

View file

@ -0,0 +1,45 @@
package com.tangem.screens.accounts
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.core.ui.test.accounts.AccountInfoEditScreenTestTags
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 AccountInfoEditPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AccountInfoEditPageObject>(semanticsProvider = semanticsProvider) {
val screenContainer: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.ACCOUNT_DETAILS_CONTAINER)
}
val accountNameField: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.NAME_FIELD)
}
val accountCurrentIcon: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.SELECTED_ICON)
}
val accountColorOption: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.COLOR_OPTION)
}
val accountTypeOption: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.TYPE_OPTION)
}
val saveAccountButton: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.SAVE_ACCOUNT_BUTTON)
}
val crossButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}
}
internal fun BaseTestCase.onAccountInfoEditorScreen(function: AccountInfoEditPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -3,6 +3,7 @@ package com.tangem.tests.accounts
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.REFERRAL_API_SCENARIO
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
@ -10,7 +11,9 @@ import com.tangem.core.ui.R
import com.tangem.scenarios.*
import com.tangem.screens.accounts.onAccountDetailsScreen
import com.tangem.screens.accounts.onArchivedAccountsScreen
import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onDialog
import com.tangem.screens.onMainScreen
import com.tangem.screens.onWalletSettingsScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
@ -164,8 +167,8 @@ class AccountArchivationsTest : BaseTestCase() {
@Test
@AllureId("5976")
@DisplayName("Accounts: restore an archived account")
fun restoreArchivedAccountTest() {
@DisplayName("Accounts: restore a simple archived account")
fun restoreSimpleArchivedAccountTest() {
val archivedAccountName = "Account 3"
val userAccountsInitialState = "TwoAccountsWithArchivedAccounts"
val userAccountsAfterArchivationState = "ReadyToRestore"
@ -202,6 +205,108 @@ class AccountArchivationsTest : BaseTestCase() {
}
}
@Test
@AllureId("5980")
@DisplayName("Accounts: restore archived account with custom token transfer")
fun restoreArchivedAccountWithCustomTokensTest() {
val mainAccountName = "Main account"
val archivedAccountName = "Account 2"
val customTokenName = "Ethereum"
val expectedArchivedTokensInfo = "1 token"
val userAccountsInitialState = "OneAccountWithArchivedCustomToken"
val userAccountsReadyToRestoreState = "ReadyToRestoreCustomToken"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(userTokensScenario, userAccountsInitialState)
},
additionalAfterSection = {
resetWireMockScenarioState(userTokensScenario)
},
).run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open wallet settings") { openWalletSettingsScreen() }
step("Open 'Archived accounts' screen") { openArchivedAccountsScreen() }
step("Verify archived account '$archivedAccountName' shows '$expectedArchivedTokensInfo'") {
onArchivedAccountsScreen {
val row = findArchivedAccountItemByName(archivedAccountName)
row.container.assertIsDisplayed()
row.subtitle.assertTextContains(expectedArchivedTokensInfo, substring = true)
}
}
step("Switch WireMock to '$userAccountsReadyToRestoreState'") {
setWireMockScenarioState(userTokensScenario, userAccountsReadyToRestoreState)
}
step("Click restore button for '$archivedAccountName'") {
onArchivedAccountsScreen {
findArchivedAccountItemByName(archivedAccountName)
.restoreButton.clickWithAssertion()
}
}
step("Assert custom token migration dialog is displayed") {
onDialog { dialogContainer.assertIsDisplayed() }
}
step("Assert dialog text mentions main account '$mainAccountName'") {
onDialog { text.assertTextContains(mainAccountName, substring = true) }
}
step("Assert dialog text mentions restoring account '$archivedAccountName'") {
onDialog { text.assertTextContains(archivedAccountName, substring = true) }
}
step("Confirm migration in dialog") {
onDialog { gotItButton.clickWithAssertion() }
}
step("Assert 'Wallet settings' screen is displayed") {
onWalletSettingsScreen { addAccountButton.assertIsDisplayed() }
}
step("Assert restored account '$archivedAccountName' is in active accounts list") {
onWalletSettingsScreen { accountItem(archivedAccountName).assertIsDisplayed() }
}
step("Navigate back to wallet details") {
onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Navigate back to main screen") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert main account '$mainAccountName' is visible on main screen") {
onMainScreen { findAccountSectionByName(mainAccountName).assertIsDisplayed() }
}
step("Assert restored account '$archivedAccountName' is visible on main screen") {
onMainScreen { findAccountSectionByName(archivedAccountName).assertIsDisplayed() }
}
step("Expand main account '$mainAccountName'") {
onMainScreen { findAccountSectionByName(mainAccountName).clickWithAssertion() }
}
step("Assert '$customTokenName' is NOT displayed under main account") {
onMainScreen { assertTokenDoesNotExist(customTokenName) }
}
step("Expand main account '$mainAccountName'") {
onMainScreen { findAccountSectionByName(mainAccountName).clickWithAssertion() }
}
step("Assert '$customTokenName' is NOT displayed under main account") {
onMainScreen {
assertTokenDoesNotExist(customTokenName)
}
}
step("Expand restored account '$archivedAccountName' and assert '$customTokenName' is displayed") {
onMainScreen {
findAccountSectionByName(archivedAccountName).clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onMainScreen { findTokenInAnyAccountByName(customTokenName).assertIsDisplayed() }
},
)
}
}
}
}
@Test
@AllureId("7962")
@DisplayName("Accounts: restore archived account error")
@ -250,4 +355,5 @@ class AccountArchivationsTest : BaseTestCase() {
}
}
}
}

View file

@ -0,0 +1,492 @@
package com.tangem.tests.accounts
import androidx.compose.ui.test.longClick
import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickOnSystemButton
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.DerivationPathHelper
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setClipboardText
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.*
import com.tangem.screens.*
import com.tangem.screens.accounts.onAccountInfoEditorScreen
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.Assert.assertTrue
import org.junit.Test
@HiltAndroidTest
class AccountCreationTest : BaseTestCase() {
private val userTokensScenario = "user_tokens_api"
@Test
@AllureId("5504")
@DisplayName("Accounts: account creation network error handling")
fun accountCreationErrorTest() {
val accountName = "Account 2"
val userAccountsGetErrorState = "AccountsGetError"
val userAccountsPutErrorState = "AccountsPutError"
val userAccountsBeforeCreationState = "AccountReadyToCreate"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(userTokensScenario, userAccountsGetErrorState)
},
additionalAfterSection = {
resetWireMockScenarioState(userTokensScenario)
},
).run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open wallet settings") { openWalletSettingsScreen() }
step("Start account creation") { startAccountCreation() }
step("Enter account name: '$accountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(accountName)
}
}
step("Click 'Add account' button (GET accounts is blocked)") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onDialog { dialogContainer.assertIsDisplayed() }
},
)
}
}
step("Assert error dialog details") {
assertErrorDialog(
expectedTitle = getResourceString(R.string.common_something_went_wrong),
expectedMessage = getResourceString(com.tangem.core.ui.R.string.account_generic_error_dialog_message),
)
}
step("Dismiss error dialog") { dismissErrorDialog() }
step("Assert still on account creation screen") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Unblock GET accounts, block PUT accounts") {
setWireMockScenarioState(userTokensScenario, userAccountsPutErrorState)
}
step("Click 'Add account' button again (PUT accounts is blocked)") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onDialog { dialogContainer.assertIsDisplayed() }
},
)
}
}
step("Assert still on account creation screen") {
assertErrorDialog(
expectedTitle = getResourceString(R.string.common_something_went_wrong),
expectedMessage = getResourceString(R.string.account_generic_error_dialog_message),
)
}
step("Unblock both 'accounts' requests") {
setWireMockScenarioState(userTokensScenario, userAccountsBeforeCreationState)
}
step("Dismiss error dialog") { dismissErrorDialog() }
step("Assert still on account creation screen") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Click 'Add account' button again (both requests unblocked)") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
},
)
}
}
step("Assert 'Manage Tokens' title is displayed") {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
}
step("Close 'Manage Tokens' screen") {
onManageTokensScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert 'Wallet settings' screen is displayed") {
onWalletSettingsScreen { addAccountButton.assertIsDisplayed() }
}
step("Assert new account '$accountName' appears in accounts list") {
onWalletSettingsScreen { accountItem(accountName).assertIsDisplayed() }
}
}
}
@Test
@AllureId("5507")
@DisplayName("Accounts: name field verifications")
fun accountsCreationNameFieldValidationTest() {
val accountName = "TestAccount12"
val longName = "A".repeat(21)
val emptyPlaceholderValue = "New account"
val editedName = "Edited"
val context = device.context
val pasteButtonName = "Paste"
setupHooks().run {
step("Set clipboard text '$longName'") {
setClipboardText(context,longName)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open 'Wallet settings' screen") { openWalletSettingsScreen() }
step("Click on 'Add account' button") {
onWalletSettingsScreen { addAccountButton.clickWithAssertion() }
}
step("Assert 'Edit account details' dialog screen appears") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Enter account name manually: '$accountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(accountName)
}
}
step("Assert name input is stable (keyboard doesn't flicker)") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(accountName)
}
}
step("Assert 'Add account' button is enabled") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsEnabled()
}
}
step("Clear the 'Edit name' field") {
onAccountInfoEditorScreen {
accountNameField.performTextClearance()
}
}
step("Assert 'Add account' button becomes inactive when field is empty") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsNotEnabled()
}
}
step("Paste name from clipboard: '$accountName'") {
onAccountInfoEditorScreen {
accountNameField.performTextReplacement(accountName)
}
}
step("Assert pasted text is displayed in 'Account name' field") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(accountName)
}
}
step("Assert 'Add account' button is enabled") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsEnabled()
}
}
step("Edit the entered name (clear and retype)") {
onAccountInfoEditorScreen {
accountNameField.performTextReplacement(editedName)
}
}
step("Assert edited name in 'Account name' field is displayed") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(editedName)
}
}
step("Delete all text and leave 'Account name' field empty") {
onAccountInfoEditorScreen {
accountNameField.performTextClearance()
}
}
step("Assert 'Add account' button is inactive") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsNotEnabled()
}
}
step("Type name with more than 20 symbols") {
onAccountInfoEditorScreen {
accountNameField.performTextReplacement(longName)
}
}
step("Assert text over 20 symbols was not pasted and placeholder remains empty") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(emptyPlaceholderValue, substring = true)
}
}
step("Assert 'Add account' button is inactive") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsNotEnabled()
}
}
step("Clear text field") {
onAccountInfoEditorScreen { accountNameField.performTextClearance() }
}
step("Paste text longer than 20 characters to 'Account name' field") {
onAccountInfoEditorScreen {
accountNameField.performTouchInput { longClick(durationMillis = 2_000L) }
}
}
step("Click on system 'Paste' button to paste clipboard text") {
clickOnSystemButton(pasteButtonName)
}
step("Assert text over 20 symbols was not pasted and placeholder remains empty") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(emptyPlaceholderValue, substring = true)
}
}
step("Assert 'Add account' button is inactive") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsNotEnabled()
}
}
}
}
@Test
@AllureId("5505")
@DisplayName(
"Accounts: check unsaved changes notification " +
"after attempt to close edited account creation form"
)
fun accountsCreationUnsavedChangesForNameFieldNotificationTest() {
val accountName = "Hikarik Test"
setupHooks().run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open 'Wallet settings' screen") { openWalletSettingsScreen() }
step("Click on 'Add account' button") {
onWalletSettingsScreen { addAccountButton.clickWithAssertion() }
}
step("Assert edit account details dialog screen appears") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Enter account name manually: '$accountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(accountName)
}
}
step("Tap 'Cross' button to attempt closing the screen") {
onAccountInfoEditorScreen {
crossButton.clickWithAssertion()
}
}
step("Verify 'Unsaved changes' screen parts") {
checkUnsavedChangesCreationModal()
}
step("Tap 'Keep Editing' button to stay on screen") {
onDialog { keepEditButton.clickWithAssertion() }
}
step("Assert app still on 'Create account' screen") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Assert previously entered data is preserved") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(accountName)
}
}
step("Tap 'Cross' button to attempt closing the screen") {
onAccountInfoEditorScreen { crossButton.clickWithAssertion() }
}
step("Assert 'Unsaved changes' alert is displayed again") {
onDialog { dialogContainer.assertIsDisplayed() }
}
step("Tap 'Discard' button to discard and close") {
onDialog { discardButton.clickWithAssertion() }
}
step("Assert 'Create account' screen is closed and 'Wallet settings' displayed again") {
onWalletSettingsScreen {
screenContainer.assertIsDisplayed()
}
}
step("Verify no new account has appeared in the list") {
onWalletSettingsScreen {
accountItem(accountName).assertDoesNotExist()
}
}
}
}
@Test
@AllureId("5502")
@DisplayName("Accounts: account creation, accounts mode and per-account token derivation")
fun accountCreationAndDerivationTest() {
val createdAccountName = "Account 2"
val accountReadyState = "AccountReadyToCreateDerivation"
val accountIndex = "1"
val btcTokenName = "Bitcoin"
val ethTokenName = "Ethereum"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(userTokensScenario, accountReadyState)
},
additionalAfterSection = {
resetWireMockScenarioState(userTokensScenario)
},
).run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open wallet settings") { openWalletSettingsScreen() }
step("Start account creation") { startAccountCreation() }
step("Enter account name: '$createdAccountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(createdAccountName)
}
}
step("Assert account creation screen with derivation hint is displayed") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Click 'Add account' and wait for 'Manage Tokens'") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
},
)
}
}
step("Close 'Manage Tokens' screen") {
onManageTokensScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert 'Wallet settings' screen is displayed") {
onWalletSettingsScreen { addAccountButton.assertIsDisplayed() }
}
step("Assert new account '$createdAccountName' appears (last) in accounts list") {
onWalletSettingsScreen { accountItem(createdAccountName).assertIsDisplayed() }
}
step("Navigate back to wallet details") {
onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Navigate back to main screen") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert accounts mode is on main: account '$createdAccountName' section is visible") {
onMainScreen { findAccountSectionByName(createdAccountName).assertIsDisplayed() }
}
step("Assert per-account token derivation paths from the domain account model") {
val account = awaitCryptoPortfolioAccount(derivationIndex = accountIndex.toInt())
val btcPaths = account.derivationPathsForToken(btcTokenName)
assertTrue(
"Expected a $btcTokenName derivation with 3rd node = $accountIndex' (account index). Paths: $btcPaths",
btcPaths.any { DerivationPathHelper.nodeAt(it, index1Based = 3) == "$accountIndex'" },
)
val ethPaths = account.derivationPathsForToken(ethTokenName)
assertTrue(
"Expected an $ethTokenName derivation with 5th node = $accountIndex (account index). Paths: $ethPaths",
ethPaths.any { DerivationPathHelper.nodeAt(it, index1Based = 5) == accountIndex },
)
}
}
}
@Test
@AllureId("8746")
@DisplayName("Accounts: empty account placeholder and 'Add tokens' entry to manage tokens")
fun emptyAccountPlaceholderTest() {
val createdAccountName = "Account 2"
val accountReadyState = "AccountReadyToCreateEmpty"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(userTokensScenario, accountReadyState)
},
additionalAfterSection = {
resetWireMockScenarioState(userTokensScenario)
},
).run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open wallet settings") { openWalletSettingsScreen() }
step("Start account creation") { startAccountCreation() }
step("Enter account name: '$createdAccountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(createdAccountName)
}
}
step("Click on 'Add account' and wait for 'Manage Tokens'") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
},
)
}
}
step("Close 'Manage Tokens' without adding any token") {
onManageTokensScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert 'Wallet settings' screen is displayed") {
onWalletSettingsScreen { addAccountButton.assertIsDisplayed() }
}
step("Assert new empty account '$createdAccountName' appears in accounts list") {
onWalletSettingsScreen { accountItem(createdAccountName).assertIsDisplayed() }
}
step("Navigate back to wallet details") {
onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Navigate back to main screen") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Expand empty account '$createdAccountName' section") {
onMainScreen {
scrollToAccountSection(createdAccountName)
findAccountSectionByName(createdAccountName).clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onMainScreen { emptyAccountTokensPlaceholder.assertIsDisplayed() }
},
)
}
}
step("Assert empty tokens placeholder is displayed") {
onMainScreen { emptyAccountTokensPlaceholder.assertIsDisplayed() }
}
step("Assert 'Add tokens' button is displayed under the placeholder") {
onMainScreen { emptyAccountAddTokensButton.assertIsDisplayed() }
}
step("Click on 'Add tokens' button") {
onMainScreen {
emptyAccountAddTokensButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
},
)
}
}
step("Assert 'Manage Tokens' screen is opened for the account") {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
}
}
}
}