Updated on 2026-08-14
This commit is contained in:
commit
89170505c5
24 changed files with 399 additions and 57 deletions
|
|
@ -189,10 +189,22 @@ abstract class BaseTestCase : TestCase(
|
|||
"ADD_AND_MANAGE_TOKENS_ENABLED" to true,
|
||||
"ASSETS_DISCOVERY_ENABLED" to true,
|
||||
"VISA_ONBOARDING_ENABLED" to true,
|
||||
"AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true,
|
||||
// Toggles released in 5.39+ — forced on so tests run against the actual build even when the
|
||||
// app version resolves to 1.0.0-SNAPSHOT on CI (then 1.0.0 < 5.39 would disable them).
|
||||
"STAKING_ETH_ENABLED" to true,
|
||||
"DYNAMIC_ADDRESSES_ENABLED" to true,
|
||||
"SOLANA_TX_HISTORY_ENABLED" to true,
|
||||
"SOLANA_SCALED_UI_AMOUNT_ENABLED" to true,
|
||||
"SWAP_AB_ENABLED" to true,
|
||||
"AND_15310_ADD_FUNDS_STAGE1" to true,
|
||||
"APP_REDESIGN_ENABLED" to true,
|
||||
"AND_15009_SWAP_PROVIDER_FILTER_ENABLED" to true,
|
||||
"AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true,
|
||||
"AND_15402_ADI_MAIN_SCREEN_DEFAULT_ENABLED" to true,
|
||||
"AND_15103_SWAP_RATE_EXPERIENCE_ENABLED" to true,
|
||||
"AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED" to true,
|
||||
"TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED" to true,
|
||||
// Toggles released in 5.40
|
||||
"APP_REDESIGN_ENABLED" to true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package com.tangem.scenarios
|
|||
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.waitUntilAtLeastOneExists
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
|
||||
import com.tangem.common.extensions.clickWithAssertion
|
||||
|
|
|
|||
|
|
@ -436,7 +436,7 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
|
|||
|
||||
fun assertTokensCount(expectedCount: Int) {
|
||||
semanticsProvider
|
||||
.onAllNodes(withTestTag(TokenElementsTestTags.TOKEN_PRICE))
|
||||
.onAllNodes(withTestTag(MainScreenTestTags.TOKEN_LIST_ITEM), useUnmergedTree = true)
|
||||
.assertCountEquals(expectedCount)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.tangem.screens
|
||||
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags
|
||||
|
|
@ -65,7 +67,8 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
|
|||
}
|
||||
|
||||
val fiatBalance: KNode = child {
|
||||
hasTestTag(TokenDetailsScreenTestTags.BALANCE_FIAT)
|
||||
hasAnyAncestor(withTestTag(TokenDetailsScreenTestTags.BALANCE_FIAT))
|
||||
addSemanticsMatcher(SemanticsMatcher.keyIsDefined(SemanticsProperties.Text))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.WarningBottomSheetTestTags
|
||||
import io.github.kakaocup.compose.node.element.ComposeScreen
|
||||
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
|
||||
|
|
@ -31,27 +30,23 @@ class WarningBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsP
|
|||
}
|
||||
|
||||
val okGotItButton: KNode = child {
|
||||
hasTestTag(BaseButtonTestTags.TEXT)
|
||||
hasTestTag(WarningBottomSheetTestTags.BUTTON_SECONDARY)
|
||||
hasText(getResourceString(R.string.warning_button_ok))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val gotItButton: KNode = child {
|
||||
hasTestTag(BaseButtonTestTags.TEXT)
|
||||
hasTestTag(WarningBottomSheetTestTags.BUTTON_PRIMARY)
|
||||
hasText(getResourceString(R.string.common_got_it))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val cancelButton: KNode = child {
|
||||
hasTestTag(BaseButtonTestTags.TEXT)
|
||||
hasTestTag(WarningBottomSheetTestTags.BUTTON_PRIMARY)
|
||||
hasText(getResourceString(R.string.common_cancel))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val connectAnywayButton: KNode = child {
|
||||
hasTestTag(BaseButtonTestTags.TEXT)
|
||||
hasTestTag(WarningBottomSheetTestTags.BUTTON_SECONDARY)
|
||||
hasText(getResourceString(R.string.wc_alert_connect_anyway))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -383,14 +383,14 @@ class MainScreenActionButtonsTest : BaseTestCase() {
|
|||
step("Open 'Main Screen' on '$cardName' card") {
|
||||
openMainScreen(mockContent = cardType, isTwinsCard = true)
|
||||
}
|
||||
step("Assert 'Buy' button is displayed") {
|
||||
onMainScreen { buyButton.assertIsDisplayed() }
|
||||
step("Assert 'Add funds' button is displayed") {
|
||||
onMainScreen { addFundsButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Click on 'Buy' button") {
|
||||
onMainScreen { buyButton.performClick() }
|
||||
step("Click on 'Add funds' button") {
|
||||
onMainScreen { addFundsButton.performClick() }
|
||||
}
|
||||
step("Click on token: '$tokenTitle'") {
|
||||
onBuyTokenScreen { tokenWithTitleAndFiatAmount(tokenTitle).performClick() }
|
||||
step("Click on 'Buy' button in bottom sheet") {
|
||||
onAddFundsBottomSheet { buyButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Confirm' button in 'Dialog'") {
|
||||
waitForIdle()
|
||||
|
|
|
|||
|
|
@ -88,14 +88,12 @@ class TotalBalanceUpdateTest : BaseTestCase() {
|
|||
}
|
||||
step("Click on 'Add' button in 'Add token' bottom sheet") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT) {
|
||||
onAddTokenBottomSheet {
|
||||
addButton.performClick()
|
||||
}
|
||||
onAddTokenBottomSheet { laterButton.assertIsDisplayed() }
|
||||
onAddTokenBottomSheet { addButton.performClick() }
|
||||
}
|
||||
}
|
||||
step("Click on 'Later' button") {
|
||||
onAddTokenBottomSheet { laterButton.performClick() }
|
||||
step("Press 'Back' button") {
|
||||
waitForIdle()
|
||||
device.uiDevice.pressBack()
|
||||
}
|
||||
step("Press 'Back' button") {
|
||||
waitForIdle()
|
||||
|
|
@ -201,8 +199,11 @@ class TotalBalanceUpdateTest : BaseTestCase() {
|
|||
step("Assert 'Token details screen' open") {
|
||||
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
|
||||
}
|
||||
step("Click 'More' button") {
|
||||
onTokenDetailsTopBar { backButton.clickWithAssertion() }
|
||||
step("Click on 'Back' button") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT) {
|
||||
onTokenDetailsTopBar { backButton.clickWithAssertion() }
|
||||
onMainScreen { screenContainer.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
step("Assert $TOTAL_BALANCE is displayed in total balance") {
|
||||
onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) }
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ class AssetsDiscoveryTest : BaseTestCase() {
|
|||
val tetherTitle = "Tether"
|
||||
val ethereumNetworkTitle = "ETHEREUM"
|
||||
val accountName = getResourceString(CoreUiR.string.account_main_account_title)
|
||||
val expectedTokensCount = 4
|
||||
val expectedTokensCount = 2
|
||||
|
||||
setupHooks(
|
||||
additionalBeforeAppLaunchSection = {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package com.tangem.tests.main
|
|||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
|
||||
import com.tangem.common.extensions.SwipeDirection
|
||||
import com.tangem.common.extensions.swipeVertical
|
||||
import com.tangem.common.extensions.clickWithAssertion
|
||||
import com.tangem.common.extensions.swipeVertical
|
||||
import com.tangem.common.utils.resetWireMockScenarioState
|
||||
import com.tangem.common.utils.setWireMockScenarioState
|
||||
import com.tangem.scenarios.openMainScreen
|
||||
|
|
@ -86,10 +86,10 @@ class MainScreenTest : BaseTestCase() {
|
|||
openMainScreen()
|
||||
}
|
||||
step("Assert 'Add & Manage' button is displayed") {
|
||||
onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
|
||||
onMainScreen { addAndManageButton().assertIsDisplayed() }
|
||||
}
|
||||
step("Click 'Add & Manage' button") {
|
||||
onMainScreen { addAndManageButtonNode.clickWithAssertion() }
|
||||
onMainScreen { addAndManageButton().clickWithAssertion() }
|
||||
}
|
||||
step("Assert 'Organize tokens' option is not displayed (nothing to organize)") {
|
||||
onAddAndManageBottomSheet { organizeTokensButton.assertIsNotDisplayed() }
|
||||
|
|
|
|||
|
|
@ -231,7 +231,9 @@ class GaslessSendViaSwapTest : BaseTestCase() {
|
|||
onSendConfirmScreen { secondaryAmount.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert the network fee is paid in '$currencySymbol'") {
|
||||
onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
step("Sign, send and open the 'Transaction sent' screen") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ 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.scenarios.checkSendWarning
|
||||
import com.tangem.scenarios.openSendConfirmScreenViaNextButton
|
||||
import com.tangem.scenarios.openSendScreen
|
||||
import com.tangem.screens.onSendAddressScreen
|
||||
import com.tangem.screens.onSendScreen
|
||||
|
|
@ -20,8 +22,8 @@ import org.junit.Test
|
|||
@HiltAndroidTest
|
||||
class DogecoinWarningsTest : BaseTestCase() {
|
||||
private val tokenName = "Dogecoin"
|
||||
private val amountToLeaveLessThanDust = "5.78654978"
|
||||
private val amountToLeaveMoreThanDust = "5.7"
|
||||
private val amountToLeaveLessThanDust = "5.7045"
|
||||
private val amountToLeaveMoreThanDust = "5.6"
|
||||
private val amountGreaterThanDust = "0.02"
|
||||
private val amountLessThanDust = "0.005"
|
||||
private val dustAmount = "DOGE 0.01"
|
||||
|
|
@ -56,8 +58,10 @@ class DogecoinWarningsTest : BaseTestCase() {
|
|||
step("Type address in input text field") {
|
||||
onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_RECIPIENT_ADDRESS) }
|
||||
}
|
||||
step("Click on 'Next' button") {
|
||||
onSendAddressScreen { nextButton.clickWithAssertion() }
|
||||
step("Open 'Send confirm' screen via 'Next' button") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
openSendConfirmScreenViaNextButton()
|
||||
}
|
||||
}
|
||||
step("Assert 'Invalid amount warning' is displayed") {
|
||||
checkSendWarning(
|
||||
|
|
@ -93,8 +97,10 @@ class DogecoinWarningsTest : BaseTestCase() {
|
|||
step("Type address in input text field") {
|
||||
onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_RECIPIENT_ADDRESS) }
|
||||
}
|
||||
step("Click on 'Next' button") {
|
||||
onSendAddressScreen { nextButton.clickWithAssertion() }
|
||||
step("Open 'Send confirm' screen via 'Next' button") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
openSendConfirmScreenViaNextButton()
|
||||
}
|
||||
}
|
||||
step("Assert 'Invalid amount warning' is not displayed") {
|
||||
checkSendWarning(
|
||||
|
|
|
|||
|
|
@ -177,12 +177,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
|
|||
val splashScreen = installSplashScreen()
|
||||
TangemLogger.i("Splash screen installed")
|
||||
|
||||
enableEdgeToEdge(
|
||||
navigationBarStyle = SystemBarStyle.auto(
|
||||
Color.Transparent.toArgb(),
|
||||
Color.Transparent.toArgb(),
|
||||
),
|
||||
)
|
||||
applyEdgeToEdge()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
|
@ -200,7 +195,10 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
|
|||
}
|
||||
|
||||
splashScreen.setKeepOnScreenCondition { viewModel.isSplashScreenShown }
|
||||
splashScreen.setOnExitAnimationListener { provider -> provider.remove() }
|
||||
splashScreen.setOnExitAnimationListener { provider ->
|
||||
provider.remove()
|
||||
applyEdgeToEdge()
|
||||
}
|
||||
|
||||
installActivityDependencies()
|
||||
observeAppThemeModeUpdates()
|
||||
|
|
@ -224,6 +222,15 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
|
|||
}
|
||||
}
|
||||
|
||||
private fun applyEdgeToEdge() {
|
||||
enableEdgeToEdge(
|
||||
navigationBarStyle = SystemBarStyle.auto(
|
||||
Color.Transparent.toArgb(),
|
||||
Color.Transparent.toArgb(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun setRootContent() {
|
||||
// for now activity is singleTop and after going to ChromeCustomTab it calls onCreate but onDestroy
|
||||
// doesn't calls. It lead to issue that decompose nav stack is not saved in bundle and to restore it
|
||||
|
|
|
|||
|
|
@ -78,6 +78,23 @@ internal class DefaultAssetsDiscoveryRepository(
|
|||
assetsDiscoveryStore.clear()
|
||||
}
|
||||
|
||||
override suspend fun removeAppliedCurrencies(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
|
||||
if (currencies.isEmpty()) return
|
||||
|
||||
val appliedIds = currencies.mapTo(hashSetOf(), CryptoCurrency::id)
|
||||
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
|
||||
val assetsDiscoveryStore = assetsDiscoveryStoreFactory.provide(userWalletId)
|
||||
|
||||
assetsDiscoveryStore.removeMatching { token ->
|
||||
val currency = responseCryptoCurrenciesFactory.createCurrency(
|
||||
responseToken = token,
|
||||
userWallet = userWallet,
|
||||
accountIndex = DerivationIndex.Main,
|
||||
)
|
||||
currency != null && currency.id in appliedIds
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearPendingFlag(userWalletId: UserWalletId) {
|
||||
setPendingFlag(userWalletId, value = false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ interface AssetsDiscoveryStore {
|
|||
|
||||
suspend fun append(tokens: List<UserTokensResponse.Token>)
|
||||
|
||||
suspend fun removeMatching(predicate: (UserTokensResponse.Token) -> Boolean)
|
||||
|
||||
suspend fun clear()
|
||||
}
|
||||
|
|
@ -18,6 +18,12 @@ internal class DefaultAssetsDiscoveryStore(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun removeMatching(predicate: (UserTokensResponse.Token) -> Boolean) {
|
||||
persistenceStore.updateData { existing ->
|
||||
existing.filterNot(predicate)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
persistenceStore.updateData { emptyList() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ android {
|
|||
namespace = "com.tangem.domain.assetsdiscovery"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.domain.core)
|
||||
implementation(projects.domain.models)
|
||||
|
|
@ -19,4 +23,10 @@ dependencies {
|
|||
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
|
||||
// region Tests
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -22,4 +22,6 @@ interface AssetsDiscoveryRepository {
|
|||
suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List<CryptoCurrency>
|
||||
|
||||
suspend fun clearDiscoveredTokens(userWalletId: UserWalletId)
|
||||
|
||||
suspend fun removeAppliedCurrencies(userWalletId: UserWalletId, currencies: List<CryptoCurrency>)
|
||||
}
|
||||
|
|
@ -28,9 +28,10 @@ class StartAssetsDiscoveryUseCase(
|
|||
try {
|
||||
analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncStarted())
|
||||
assetsDiscoveryRepository.runDiscovery(userWalletId)
|
||||
applyDiscoveredTokens(userWalletId)
|
||||
assetsDiscoveryRepository.completeDiscovery(userWalletId)
|
||||
analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncCompleted())
|
||||
if (applyDiscoveredTokens(userWalletId)) {
|
||||
assetsDiscoveryRepository.completeDiscovery(userWalletId)
|
||||
analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncCompleted())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("Token sync failed for wallet: $userWalletId", e)
|
||||
} finally {
|
||||
|
|
@ -51,7 +52,13 @@ class StartAssetsDiscoveryUseCase(
|
|||
val pendingIds = assetsDiscoveryRepository.getPendingDiscoveryWalletIds()
|
||||
for (walletId in pendingIds) {
|
||||
val isApplied = applyDiscoveredTokens(walletId)
|
||||
if (isApplied) {
|
||||
// Clear the pending flag only when discovery is not actively running for this wallet.
|
||||
// While runDiscovery is in progress it keeps appending tokens after our snapshot, so the
|
||||
// store may still hold un-applied tokens even though applyDiscoveredTokens returned true.
|
||||
// Clearing the flag now would strand those tokens if the app is killed before discovery
|
||||
// finishes; in that case invoke()/completeDiscovery owns clearing the flag once everything
|
||||
// has been applied.
|
||||
if (isApplied && !activeSyncJobs.containsKey(walletId)) {
|
||||
assetsDiscoveryRepository.clearPendingFlag(walletId)
|
||||
}
|
||||
}
|
||||
|
|
@ -72,7 +79,7 @@ class StartAssetsDiscoveryUseCase(
|
|||
add = currencies,
|
||||
).fold(
|
||||
ifRight = {
|
||||
assetsDiscoveryRepository.clearDiscoveredTokens(userWalletId)
|
||||
assetsDiscoveryRepository.removeAppliedCurrencies(userWalletId, currencies)
|
||||
true
|
||||
},
|
||||
ifLeft = { error ->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
package com.tangem.domain.assetsdiscovery.usecase
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
|
||||
import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress
|
||||
import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.test.core.TestAppCoroutineScope
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class StartAssetsDiscoveryUseCaseTest {
|
||||
|
||||
private val userWalletId = UserWalletId("011")
|
||||
|
||||
private val currencyFactory = MockCryptoCurrencyFactory()
|
||||
private val appliedCurrency = currencyFactory.ethereum
|
||||
private val lateDiscoveredCurrency = currencyFactory.createCoin(Blockchain.Polygon)
|
||||
|
||||
private val manageCryptoCurrenciesUseCase = mockk<ManageCryptoCurrenciesUseCase>()
|
||||
private val analyticsEventHandler = mockk<AnalyticsEventHandler>(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(manageCryptoCurrenciesUseCase, analyticsEventHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduces AND race: `WalletModel.init` triggers [StartAssetsDiscoveryUseCase.applyPendingAssetsDiscovery]
|
||||
* while [AssetsDiscoveryRepository.runDiscovery] is still appending tokens. A network discovered during the
|
||||
* (slow) apply window must NOT be wiped by the apply's store cleanup.
|
||||
*/
|
||||
@Test
|
||||
fun `apply keeps tokens discovered concurrently while applying the snapshot`() = runTest {
|
||||
val repository = FakeAssetsDiscoveryRepository(
|
||||
initialCurrencies = listOf(appliedCurrency),
|
||||
pendingWalletIds = listOf(userWalletId),
|
||||
)
|
||||
// Simulate an in-flight discovery batch appending a new network during the apply call.
|
||||
coEvery { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) } coAnswers {
|
||||
repository.store.add(lateDiscoveredCurrency)
|
||||
Unit.right()
|
||||
}
|
||||
|
||||
val useCase = createUseCase(repository)
|
||||
useCase.applyPendingAssetsDiscovery()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(repository.store.map { it.id }).contains(lateDiscoveredCurrency.id)
|
||||
assertThat(repository.store.map { it.id }).doesNotContain(appliedCurrency.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply removes every applied currency when nothing is discovered concurrently`() = runTest {
|
||||
val repository = FakeAssetsDiscoveryRepository(
|
||||
initialCurrencies = listOf(appliedCurrency, lateDiscoveredCurrency),
|
||||
pendingWalletIds = listOf(userWalletId),
|
||||
)
|
||||
coEvery { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) } returns Unit.right()
|
||||
|
||||
val useCase = createUseCase(repository)
|
||||
useCase.applyPendingAssetsDiscovery()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(repository.store).isEmpty()
|
||||
coVerify(exactly = 1) { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) }
|
||||
assertThat(repository.clearedPendingFlagFor).containsExactly(userWalletId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply retains tokens and keeps pending flag when applying fails`() = runTest {
|
||||
val repository = FakeAssetsDiscoveryRepository(
|
||||
initialCurrencies = listOf(appliedCurrency),
|
||||
pendingWalletIds = listOf(userWalletId),
|
||||
)
|
||||
coEvery {
|
||||
manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any())
|
||||
} returns IllegalStateException("apply failed").left()
|
||||
|
||||
val useCase = createUseCase(repository)
|
||||
useCase.applyPendingAssetsDiscovery()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(repository.store.map { it.id }).containsExactly(appliedCurrency.id)
|
||||
assertThat(repository.clearedPendingFlagFor).isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards against premature pending-flag clearing: while `runDiscovery` is still in flight (the wallet
|
||||
* has an active sync job), `applyPendingAssetsDiscovery` must NOT clear the flag, otherwise tokens
|
||||
* discovered after the applied snapshot would be stranded if the app is killed before discovery finishes.
|
||||
*/
|
||||
@Test
|
||||
fun `pending flag is not cleared while discovery is still running`() = runTest {
|
||||
val discoveryGate = CompletableDeferred<Unit>()
|
||||
val repository = FakeAssetsDiscoveryRepository(
|
||||
initialCurrencies = listOf(appliedCurrency),
|
||||
pendingWalletIds = listOf(userWalletId),
|
||||
onRunDiscovery = { discoveryGate.await() },
|
||||
)
|
||||
coEvery { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) } returns Unit.right()
|
||||
|
||||
val useCase = createUseCase(repository)
|
||||
useCase(userWalletId) // registers an active sync job and parks inside runDiscovery
|
||||
runCurrent()
|
||||
useCase.applyPendingAssetsDiscovery()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(repository.clearedPendingFlagFor).isEmpty()
|
||||
|
||||
discoveryGate.complete(Unit) // let the discovery job finish so the test can complete cleanly
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
/**
|
||||
* invoke() must not mark the discovery complete when the apply fails: completeDiscovery clears the
|
||||
* pending flag, so calling it on failure would strand the still-unapplied tokens (no retry possible).
|
||||
*/
|
||||
@Test
|
||||
fun `discovery is not marked complete when apply fails during invoke`() = runTest {
|
||||
val repository = FakeAssetsDiscoveryRepository(
|
||||
initialCurrencies = listOf(appliedCurrency),
|
||||
pendingWalletIds = emptyList(),
|
||||
)
|
||||
coEvery {
|
||||
manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any())
|
||||
} returns IllegalStateException("apply failed").left()
|
||||
|
||||
val useCase = createUseCase(repository)
|
||||
useCase(userWalletId)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(repository.completedFor).isEmpty()
|
||||
assertThat(repository.store.map { it.id }).containsExactly(appliedCurrency.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discovery is marked complete when apply succeeds during invoke`() = runTest {
|
||||
val repository = FakeAssetsDiscoveryRepository(
|
||||
initialCurrencies = listOf(appliedCurrency),
|
||||
pendingWalletIds = emptyList(),
|
||||
)
|
||||
coEvery { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) } returns Unit.right()
|
||||
|
||||
val useCase = createUseCase(repository)
|
||||
useCase(userWalletId)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(repository.completedFor).containsExactly(userWalletId)
|
||||
assertThat(repository.store).isEmpty()
|
||||
}
|
||||
|
||||
private fun TestScope.createUseCase(
|
||||
repository: AssetsDiscoveryRepository,
|
||||
): StartAssetsDiscoveryUseCase = StartAssetsDiscoveryUseCase(
|
||||
assetsDiscoveryRepository = repository,
|
||||
manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase,
|
||||
appCoroutineScope = TestAppCoroutineScope(this),
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
)
|
||||
|
||||
private class FakeAssetsDiscoveryRepository(
|
||||
initialCurrencies: List<CryptoCurrency>,
|
||||
private val pendingWalletIds: List<UserWalletId>,
|
||||
private val onRunDiscovery: suspend () -> Unit = {},
|
||||
) : AssetsDiscoveryRepository {
|
||||
|
||||
val store: MutableList<CryptoCurrency> = initialCurrencies.toMutableList()
|
||||
val clearedPendingFlagFor: MutableList<UserWalletId> = mutableListOf()
|
||||
val completedFor: MutableList<UserWalletId> = mutableListOf()
|
||||
|
||||
override suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List<CryptoCurrency> = store.toList()
|
||||
|
||||
override suspend fun removeAppliedCurrencies(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
|
||||
val appliedIds = currencies.mapTo(hashSetOf(), CryptoCurrency::id)
|
||||
store.removeAll { it.id in appliedIds }
|
||||
}
|
||||
|
||||
override suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) {
|
||||
store.clear()
|
||||
}
|
||||
|
||||
override suspend fun getPendingDiscoveryWalletIds(): List<UserWalletId> = pendingWalletIds
|
||||
|
||||
override suspend fun clearPendingFlag(userWalletId: UserWalletId) {
|
||||
clearedPendingFlagFor.add(userWalletId)
|
||||
}
|
||||
|
||||
override suspend fun runDiscovery(userWalletId: UserWalletId) = onRunDiscovery()
|
||||
|
||||
override suspend fun completeDiscovery(userWalletId: UserWalletId) {
|
||||
completedFor.add(userWalletId)
|
||||
}
|
||||
|
||||
override fun observeDiscoveryProgress(userWalletId: UserWalletId): Flow<AssetsDiscoveryProgress> = emptyFlow()
|
||||
|
||||
override fun acknowledgeCompletion(userWalletId: UserWalletId) = Unit
|
||||
}
|
||||
}
|
||||
|
|
@ -68,11 +68,10 @@ internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier:
|
|||
.hazeEffectTangem { blurRadius = ACTION_BLUR_RADIUS }
|
||||
TangemTopBar(
|
||||
modifier = modifier
|
||||
.statusBarsPadding()
|
||||
.testTag(TokenDetailsTopBarTestTags.BACK_BUTTON),
|
||||
.statusBarsPadding(),
|
||||
startContent = {
|
||||
TangemTopBarActionContent(
|
||||
modifier = actionModifier,
|
||||
modifier = actionModifier.testTag(TokenDetailsTopBarTestTags.BACK_BUTTON),
|
||||
actionUM = TangemTopBarActionUM(
|
||||
iconRes = R.drawable.ic_arrow_back_28,
|
||||
onClick = topAppBarUM.onBackClick,
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
|
|||
is WalletNotificationUM.TangemPayRefreshNeeded,
|
||||
WalletNotificationUM.TangemPayUnreachable,
|
||||
is WalletNotificationUM.YieldBoostPromo,
|
||||
is WalletNotificationUM.AssetsDiscoveryCompleted,
|
||||
-> null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import com.tangem.core.decompose.di.ModelScoped
|
|||
import com.tangem.core.ui.ds.message.TangemMessageEffect
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
|
||||
import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress
|
||||
import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase
|
||||
import com.tangem.domain.card.CardTypesResolver
|
||||
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||
import com.tangem.domain.demo.IsDemoCardUseCase
|
||||
|
|
@ -22,6 +24,7 @@ import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
|
|||
import com.tangem.feature.wallet.impl.R
|
||||
import com.tangem.feature.wallet.presentation.account.AccountDependencies
|
||||
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotificationUM
|
||||
import com.tangem.features.hotwallet.HotWalletFeatureToggles
|
||||
import com.tangem.hot.sdk.model.HotWalletId
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.utils.extensions.addIf
|
||||
|
|
@ -36,7 +39,7 @@ import javax.inject.Inject
|
|||
* Factory for creating a list of notifications that can be shown on the wallet screen.
|
||||
* These notifications are critical and should be shown separately from each other.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@Suppress("LongParameterList", "LargeClass")
|
||||
@ModelScoped
|
||||
internal class GetWalletNotificationsFactory @Inject constructor(
|
||||
private val isDemoCardUseCase: IsDemoCardUseCase,
|
||||
|
|
@ -45,6 +48,8 @@ internal class GetWalletNotificationsFactory @Inject constructor(
|
|||
private val accountDependencies: AccountDependencies,
|
||||
private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase,
|
||||
private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase,
|
||||
private val observeAssetsDiscoveryUseCase: ObserveAssetsDiscoveryUseCase,
|
||||
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
|
||||
) {
|
||||
fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotificationUM>> {
|
||||
val cardTypesResolver = (userWallet as? UserWallet.Cold)?.scanResponse?.cardTypesResolver
|
||||
|
|
@ -52,11 +57,19 @@ internal class GetWalletNotificationsFactory @Inject constructor(
|
|||
val params = SingleAccountStatusListProducer.Params(userWallet.walletId)
|
||||
val accountStatusListFlow = accountDependencies.singleAccountStatusListSupplier(params)
|
||||
|
||||
val assetsDiscoveryProgressFlow =
|
||||
if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled && userWallet is UserWallet.Hot) {
|
||||
observeAssetsDiscoveryUseCase(userWallet.walletId).distinctUntilChanged()
|
||||
} else {
|
||||
flowOf(AssetsDiscoveryProgress.Idle)
|
||||
}
|
||||
|
||||
return combine(
|
||||
flow = accountStatusListFlow,
|
||||
flow2 = isNeedToBackupUseCase(userWallet.walletId).distinctUntilChanged(),
|
||||
flow3 = getAccessCodeSkippedUseCase(userWallet.walletId).distinctUntilChanged(),
|
||||
) { accountList, isNeedToBackup, shouldAccessCodeSkipped ->
|
||||
flow4 = assetsDiscoveryProgressFlow,
|
||||
) { accountList, isNeedToBackup, shouldAccessCodeSkipped, assetsDiscoveryProgress ->
|
||||
val totalFiatBalance = accountList.totalFiatBalance
|
||||
val flattenCurrencies = accountList.flattenCurrencies()
|
||||
|
||||
|
|
@ -101,6 +114,12 @@ internal class GetWalletNotificationsFactory @Inject constructor(
|
|||
clickIntents = clickIntents,
|
||||
)
|
||||
|
||||
addAssetsDiscoveryCompletedNotification(
|
||||
userWallet = userWallet,
|
||||
assetsDiscoveryProgress = assetsDiscoveryProgress,
|
||||
clickIntents = clickIntents,
|
||||
)
|
||||
|
||||
if (paymentAccountStatus != null) {
|
||||
addTangemPayWarnings(
|
||||
status = paymentAccountStatus,
|
||||
|
|
@ -311,6 +330,20 @@ internal class GetWalletNotificationsFactory @Inject constructor(
|
|||
return any { it.value is CryptoCurrencyStatus.Unreachable }
|
||||
}
|
||||
|
||||
private fun MutableList<WalletNotificationUM>.addAssetsDiscoveryCompletedNotification(
|
||||
userWallet: UserWallet,
|
||||
assetsDiscoveryProgress: AssetsDiscoveryProgress,
|
||||
clickIntents: WalletClickIntents,
|
||||
) {
|
||||
addIf(
|
||||
element = WalletNotificationUM.AssetsDiscoveryCompleted(
|
||||
onCloseClick = { clickIntents.onDismissAssetsDiscoveryNotification(userWallet.walletId) },
|
||||
onManageTokensClick = { clickIntents.onAssetsDiscoveryManageClick(userWallet.walletId) },
|
||||
),
|
||||
condition = assetsDiscoveryProgress is AssetsDiscoveryProgress.Completed,
|
||||
)
|
||||
}
|
||||
|
||||
private fun MutableList<WalletNotificationUM>.addFinishWalletActivationNotification(
|
||||
userWallet: UserWallet,
|
||||
totalFiatBalance: TotalFiatBalance,
|
||||
|
|
|
|||
|
|
@ -526,5 +526,30 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t
|
|||
),
|
||||
type = WalletNotificationType.Informational,
|
||||
)
|
||||
|
||||
data class AssetsDiscoveryCompleted(
|
||||
val onCloseClick: () -> Unit,
|
||||
val onManageTokensClick: () -> Unit,
|
||||
) : WalletNotificationUM(
|
||||
messageUM = TangemMessageUM(
|
||||
id = "AssetsDiscoveryCompletedNotification",
|
||||
title = resourceReference(R.string.initial_wallet_sync_banner_title),
|
||||
subtitle = resourceReference(R.string.initial_wallet_sync_banner_description),
|
||||
iconUM = TangemIconUM.Icon(
|
||||
iconRes = R.drawable.ic_check_circle_24,
|
||||
tintReference = { TangemTheme.colors2.graphic.status.accent },
|
||||
),
|
||||
messageEffect = TangemMessageEffect.None,
|
||||
onCloseClick = onCloseClick,
|
||||
buttonsUM = persistentListOf(
|
||||
TangemMessageButtonUM(
|
||||
text = resourceReference(R.string.main_manage_tokens),
|
||||
onClick = onManageTokensClick,
|
||||
type = TangemButtonType.Secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
type = WalletNotificationType.Informational,
|
||||
)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -134,6 +134,7 @@ private fun SubtitleRow(walletBalanceUM: WalletBalanceUM, modifier: Modifier = M
|
|||
).resolveReference(),
|
||||
style = TangemTheme.typography2.bodyRegular14,
|
||||
color = TangemTheme.colors2.text.neutral.tertiary,
|
||||
modifier = Modifier.testTag(MainScreenTestTags.SYNC_PROGRESS_TEXT),
|
||||
)
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(19.dp),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue