diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt index 8b5a850ce7..2dd1deb193 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt @@ -7,23 +7,49 @@ import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.earn.EarnErrorResolver +import com.tangem.domain.earn.model.EarnTokensBatchFlow +import com.tangem.domain.earn.model.EarnTokensBatchingContext +import com.tangem.domain.earn.model.EarnTokensListConfig +import com.tangem.domain.earn.usecase.GetEarnTokensBatchFlowUseCase import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.earn.EarnError +import com.tangem.domain.models.earn.EarnRewardType +import com.tangem.domain.models.earn.EarnToken +import com.tangem.domain.models.earn.EarnTokenWithCurrency +import com.tangem.domain.models.earn.EarnType import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase import com.tangem.features.foryou.ForYouComponent import com.tangem.features.foryou.impl.components.state.MarketChartUM +import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM import com.tangem.features.foryou.impl.entity.PortfolioReviewUM +import com.tangem.features.foryou.impl.model.converter.TOP_EARN_TOKENS_BATCH_SIZE +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchAction +import com.tangem.pagination.BatchListState +import com.tangem.pagination.PaginationStatus +import com.tangem.test.mock.MockAccounts import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.CapturingSlot +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -41,6 +67,11 @@ internal class ForYouModelTest { private val userWalletsListRepository: UserWalletsListRepository = mockk() private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier = mockk() private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() + private val yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase = mockk() + private val getEarnTokensBatchFlowUseCase: GetEarnTokensBatchFlowUseCase = mockk() + private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase = mockk() + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk() + private val earnErrorResolver: EarnErrorResolver = mockk() private var model: ForYouModel? = null @@ -49,6 +80,10 @@ internal class ForYouModelTest { // Default: a real, non-empty emission so the model's `getOrElse { Default }` mapping path is // actually exercised in every test, not bypassed by an empty flow. every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right()) + every { yieldSupplyApyFlowUseCase() } returns flowOf(emptyMap()) + coEvery { stakingAvailabilityListUseCase.invokeSync(any(), any()) } returns emptyMap() + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false + stubTopEarnTokens(status = PaginationStatus.None) } @AfterEach @@ -75,6 +110,21 @@ internal class ForYouModelTest { assertThat(loading.tokenList.all { it.tokenRowUM is TangemTokenRowUM.Loading }).isTrue() assertThat(loading.marketChartUM).isEqualTo(MarketChartUM.NoData) } + + @Test + fun `GIVEN model created WHEN not yet advanced THEN earn section is Loading with skeleton rows`() = runTest { + // Arrange + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf()) + + // Act + val model = createModel(testScope = this) + + // Assert + val loading = model.uiState.value.earnOpportunities as EarnOpportunitiesUM.Loading + assertThat(loading.tokenList).hasSize(5) + assertThat(loading.tokenList.all { it.tokenRowUM is TangemTokenRowUM.Loading }).isTrue() + } } @Nested @@ -84,10 +134,7 @@ internal class ForYouModelTest { fun `GIVEN selected wallet and statuses emitted WHEN advanced THEN uiState becomes Content`() = runTest { // Arrange val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") - stubSelectedWallet( - currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), - totalFiatBalance = BigDecimal("100"), - ) + stubSelectedWallet(currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100"))))) // Act val model = createModel(testScope = this) @@ -107,7 +154,6 @@ internal class ForYouModelTest { val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") stubSelectedWallet( currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), - totalFiatBalance = BigDecimal("100"), source = StatusSource.ONLY_CACHE, ) @@ -118,6 +164,71 @@ internal class ForYouModelTest { // Assert assertThat(model.uiState.value.notifications).containsExactly(ForYouNotification.UsedOutdatedData) } + + @Test + fun `GIVEN nothing earn-eligible and loaded suggestions WHEN advanced THEN earn section suggests them`() = + runTest { + // Arrange — the portfolio coin has no earn option; the top-earn batch has one suggestion + val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") + stubSelectedWallet(currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100"))))) + stubTopEarnTokens( + status = PaginationStatus.EndOfPagination, + suggestions = listOf(createTopEarnSuggestion()), + ) + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + val earn = model.uiState.value.earnOpportunities as EarnOpportunitiesUM.Content + assertThat(earn.tokenList.map { it.tokenRowUM.id }).containsExactly("coin-solana") + } + + @Test + fun `GIVEN model created WHEN advanced THEN top-earn tokens requested as one full-config reload`() = runTest { + // Arrange + val contextSlot: CapturingSlot = slot() + val batchFlow: EarnTokensBatchFlow = mockk { + every { state } returns MutableStateFlow( + BatchListState(data = emptyList(), status = PaginationStatus.None), + ) + } + every { getEarnTokensBatchFlowUseCase(capture(contextSlot), any()) } returns batchFlow + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf()) + + // Act + createModel(testScope = this) + advanceUntilIdle() + + // Assert — a single Reload action with the unfiltered config, sized for one batch + verify { getEarnTokensBatchFlowUseCase(any(), TOP_EARN_TOKENS_BATCH_SIZE) } + val action = contextSlot.captured.actionsFlow.first() as BatchAction.Reload + assertThat(action.requestParams).isEqualTo( + EarnTokensListConfig(type = null, networks = null, isForEarn = false), + ) + } + + @Test + fun `GIVEN top-earn batch fails to load WHEN advanced THEN error resolved and no suggestions shown`() = + runTest { + // Arrange + val failure = RuntimeException("network down") + every { earnErrorResolver.resolve(failure) } returns EarnError.NotHttpError() + stubTopEarnTokens(status = PaginationStatus.InitialLoadingError(failure)) + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf()) + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + verify { earnErrorResolver.resolve(failure) } + val earn = model.uiState.value.earnOpportunities as EarnOpportunitiesUM.Content + assertThat(earn.tokenList).isEmpty() + } } @Nested @@ -127,10 +238,7 @@ internal class ForYouModelTest { fun `GIVEN asset row clicked WHEN clicked again THEN isExpanded toggles back to false`() = runTest { // Arrange val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") - stubSelectedWallet( - currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), - totalFiatBalance = BigDecimal("100"), - ) + stubSelectedWallet(currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100"))))) val model = createModel(testScope = this) advanceUntilIdle() val initialContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content @@ -162,22 +270,19 @@ internal class ForYouModelTest { runTest { // Arrange val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") - stubSelectedWallet( - currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), - totalFiatBalance = BigDecimal("100"), - ) + stubSelectedWallet(currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100"))))) val model = createModel(testScope = this) advanceUntilIdle() - val contentBefore = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content - val weekItem = contentBefore.periodPickerUM.items[1] + val stateBefore = model.uiState.value + val weekItem = stateBefore.periodPickerUM.items[1] // Act - contentBefore.onPeriodClick(weekItem) + stateBefore.onPeriodClick(weekItem) // Assert - val contentAfter = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content - assertThat(contentAfter.periodPickerUM.initialSelectedItem).isEqualTo(weekItem) - assertThat(contentAfter.tokenList).isEqualTo(contentBefore.tokenList) + val stateAfter = model.uiState.value + assertThat(stateAfter.periodPickerUM.initialSelectedItem).isEqualTo(weekItem) + assertThat(stateAfter.portfolioReviewUM).isEqualTo(stateBefore.portfolioReviewUM) } } @@ -187,18 +292,51 @@ internal class ForYouModelTest { /** Wires the repository + supplier so the model derives Content from a single selected wallet. */ private fun stubSelectedWallet( currencies: List, - totalFiatBalance: BigDecimal, source: StatusSource = StatusSource.ACTUAL, ) { - val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01")) + val wallet = MockUserWalletFactory.create().copy(walletId = WALLET_ID) every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( linkedMapOf( - wallet.walletId to createAccountStatusList(currencies, totalFiatBalance, source), + wallet.walletId to createAccountStatusList(currencies, source), ), ) } + private fun stubTopEarnTokens( + status: PaginationStatus>, + suggestions: List = emptyList(), + ) { + val batches = if (suggestions.isEmpty()) emptyList() else listOf(Batch(key = 0, data = suggestions)) + val batchFlow: EarnTokensBatchFlow = mockk { + every { state } returns MutableStateFlow(BatchListState(data = batches, status = status)) + } + every { getEarnTokensBatchFlowUseCase(any(), any()) } returns batchFlow + } + + /** A yield-type suggestion (7.5% on Solana) the user does not hold yet. */ + private fun createTopEarnSuggestion(): EarnTokenWithCurrency = EarnTokenWithCurrency( + networkName = "Solana", + earnToken = EarnToken( + apy = "7.5", + networkId = "solana", + rewardType = EarnRewardType.APY, + type = EarnType.YIELD, + tokenId = "solana", + tokenSymbol = "SOL", + tokenName = "Solana", + tokenAddress = null, + decimalCount = null, + ), + cryptoCurrency = createCoin( + rawCurrencyId = "solana", + symbol = "SOL", + name = "Solana", + networkRawId = "solana", + decimals = 9, + ), + ) + private fun createModel(testScope: TestScope): ForYouModel { return ForYouModel( paramsContainer = MutableParamsContainer( @@ -210,8 +348,13 @@ internal class ForYouModelTest { ), userWalletsListRepository = userWalletsListRepository, multiAccountStatusListSupplier = multiAccountStatusListSupplier, + yieldSupplyApyFlowUseCase = yieldSupplyApyFlowUseCase, dispatchers = testScope.createTestingCoroutineDispatcherProvider(), getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + getEarnTokensBatchFlowUseCase = getEarnTokensBatchFlowUseCase, + stakingAvailabilityListUseCase = stakingAvailabilityListUseCase, + isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, + earnErrorResolver = earnErrorResolver, ).also { model = it } } @@ -228,14 +371,20 @@ internal class ForYouModelTest { private fun createAccountStatusList( currencies: List, - totalFiatBalance: BigDecimal, source: StatusSource = StatusSource.ACTUAL, ): AccountStatusList = mockk { every { flattenCurrencies() } returns currencies every { this@mockk.totalFiatBalance } returns TotalFiatBalance.Loaded( - amount = totalFiatBalance, + amount = currencies.sumOf { it.value.fiatAmount ?: BigDecimal.ZERO }, source = source, ) + every { userWalletId } returns WALLET_ID + every { accountStatuses } returns listOf( + mockk { + every { flattenCurrencies() } returns currencies + every { account } returns MockAccounts.createAccount(derivationIndex = 1) + }, + ) } private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( @@ -248,14 +397,18 @@ internal class ForYouModelTest { every { this@mockk.fiatAmount } returns fiatAmount every { isError } returns false every { sources } returns CryptoCurrencyStatus.Sources() + every { yieldSupplyStatus } returns null + every { stakingBalance } returns null } - private fun createCoin(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin { - val network: Network = mockk { - every { name } returns "Network" - every { isTestnet } returns false - every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) } - } + private fun createCoin( + rawCurrencyId: String, + symbol: String, + name: String = symbol, + networkRawId: String = rawCurrencyId, + decimals: Int = 8, + ): CryptoCurrency.Coin { + val network = createNetwork(networkRawId) val currencyId: CryptoCurrency.ID = mockk { every { value } returns "coin-$rawCurrencyId" every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId) @@ -263,11 +416,31 @@ internal class ForYouModelTest { return mockk { every { this@mockk.id } returns currencyId every { this@mockk.symbol } returns symbol - every { this@mockk.name } returns symbol + every { this@mockk.name } returns name every { this@mockk.network } returns network - every { this@mockk.decimals } returns 8 + every { this@mockk.decimals } returns decimals every { isCustom } returns false every { iconUrl } returns null } } + + private fun createNetwork(networkRawId: String): Network { + val networkId: Network.ID = mockk { + every { rawId } returns Network.RawID(networkRawId) + } + val networkStandardType: Network.StandardType = mockk { + every { name } returns "ERC20" + } + return mockk { + every { name } returns "Network" + every { isTestnet } returns false + every { rawId } returns networkRawId + every { id } returns networkId + every { standardType } returns networkStandardType + } + } + + private companion object { + val WALLET_ID = UserWalletId("01") + } } \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt deleted file mode 100644 index 192e969293..0000000000 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt +++ /dev/null @@ -1,300 +0,0 @@ -package com.tangem.features.foryou.impl.model.converter - -import com.google.common.truth.Truth.assertThat -import com.tangem.core.ui.ds.row.token.TangemTokenRowUM -import com.tangem.core.ui.extensions.pluralReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.network.Network -import com.tangem.features.foryou.impl.R -import io.mockk.every -import io.mockk.mockk -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import java.math.BigDecimal - -internal class ForYouTokenListConverterTest { - - private val appCurrency: AppCurrency = AppCurrency.Default - - @Nested - inner class Convert { - - @Test - fun `GIVEN single-network coin WHEN convert THEN subtitle is common main network`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter(totalFiatBalance = BigDecimal("100")) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - val row = result.single().tokenRowUM as TangemTokenRowUM.Content - val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(resourceReference(R.string.common_main_network)) - } - - @Test - fun `GIVEN single-network token WHEN convert THEN subtitle is the network standard type name`() { - // Arrange - val currency = createToken( - rawCurrencyId = "usdc", - symbol = "USDC", - networkId = "ethereum", - standardTypeName = "ERC20", - ) - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter(totalFiatBalance = BigDecimal("100")) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - val row = result.single().tokenRowUM as TangemTokenRowUM.Content - val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(stringReference("ERC20")) - } - - @Test - fun `GIVEN asset spans multiple networks WHEN convert THEN subtitle shows network count`() { - // Arrange — same asset (shared rawCurrencyId) on two different networks - val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum") - val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana") - val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("200"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("300"), - ) - - // Act - val result = converter.convert(listOf(statusEth, statusSol)) - - // Assert - val item = result.single() - val row = item.tokenRowUM as TangemTokenRowUM.Content - val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_networks_count, count = 2)) - assertThat(item.tokenList).hasSize(2) - } - - @Test - fun `GIVEN multi-network asset WHEN convert THEN child rows ordered by descending fiat balance`() { - // Arrange - val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum") - val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana") - val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("500"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("600"), - ) - - // Act - val result = converter.convert(listOf(statusEth, statusSol)) - - // Assert — Solana holding (500) ranks above Ethereum holding (100) - val childIds = result.single().tokenList.map { it.id } - assertThat(childIds).containsExactly("token-usdc-solana", "token-usdc-ethereum").inOrder() - } - - @Test - fun `GIVEN all statuses of an asset are Loading WHEN convert THEN asset row is Loading`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, CryptoCurrencyStatus.Loading) - val converter = createConverter(totalFiatBalance = BigDecimal.ZERO) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - assertThat(result.single().tokenRowUM).isInstanceOf(TangemTokenRowUM.Loading::class.java) - } - - @Test - fun `GIVEN no other assets WHEN convert THEN no Other row is appended`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("100"), - otherAssets = emptyList(), - ) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - assertThat(result).hasSize(1) - } - - @Test - fun `GIVEN a single other asset WHEN convert THEN Other row subtitle is singular`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("100"), - otherAssets = listOf(otherAsset(BigDecimal("50"))), - ) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content - assertThat(otherRow.id).isEqualTo("for_you_other_assets") - val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo( - pluralReference(R.plurals.market_chart_assets_android, count = 1, formatArgs = wrappedList(1)), - ) - } - - @Test - fun `GIVEN more than one other asset WHEN convert THEN Other row subtitle is plural`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("100"), - otherAssets = listOf( - otherAsset(BigDecimal("30")), - otherAsset(BigDecimal("15")), - otherAsset(BigDecimal("5")), - ), - ) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content - val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo( - pluralReference(R.plurals.market_chart_assets_android, count = 3, formatArgs = wrappedList(3)), - ) - } - - @Test - fun `GIVEN asset id in expandedAssetIds WHEN convert THEN item isExpanded is true`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("100"), - expandedAssetIds = setOf("bitcoin"), - ) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - assertThat(result.single().isExpanded).isTrue() - } - - @Test - fun `GIVEN asset id not in expandedAssetIds WHEN convert THEN item isExpanded is false`() { - // Arrange - val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") - val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100"))) - val converter = createConverter( - totalFiatBalance = BigDecimal("100"), - expandedAssetIds = emptySet(), - ) - - // Act - val result = converter.convert(listOf(status)) - - // Assert - assertThat(result.single().isExpanded).isFalse() - } - } - - private fun createConverter( - totalFiatBalance: BigDecimal, - expandedAssetIds: Set = emptySet(), - otherAssets: List, BigDecimal>> = emptyList(), - ): ForYouTokenListConverter = ForYouTokenListConverter( - appCurrency = appCurrency, - totalFiatBalance = totalFiatBalance, - expandedAssetIds = expandedAssetIds, - expandClick = {}, - otherAssets = otherAssets, - onTokenClick = {}, - ) - - /** - * Builds an "other" asset entry — only its summed [balance] and the number of entries drive the - * collapsed "Other" row, so the currency list is left empty. - */ - private fun otherAsset(balance: BigDecimal): Pair, BigDecimal> = - emptyList() to balance - - private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( - currency = currency, - value = value, - ) - - private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk { - every { this@mockk.amount } returns amount - every { this@mockk.fiatAmount } returns fiatAmount - every { isError } returns false - every { sources } returns CryptoCurrencyStatus.Sources() - } - - private fun createCoin(rawCurrencyId: String, symbol: String, networkId: String): CryptoCurrency.Coin { - val network = createNetwork(networkId = networkId, standardTypeName = "MAIN") - val currencyId = createCurrencyId(idValue = "coin-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId) - return mockk { - every { this@mockk.id } returns currencyId - every { this@mockk.symbol } returns symbol - every { this@mockk.network } returns network - every { this@mockk.decimals } returns 8 - every { isCustom } returns false - every { iconUrl } returns null - } - } - - private fun createToken( - rawCurrencyId: String, - symbol: String, - networkId: String, - standardTypeName: String = "ERC20", - ): CryptoCurrency.Token { - val network = createNetwork(networkId = networkId, standardTypeName = standardTypeName) - val currencyId = createCurrencyId(idValue = "token-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId) - return mockk { - every { this@mockk.id } returns currencyId - every { this@mockk.symbol } returns symbol - every { this@mockk.network } returns network - every { this@mockk.decimals } returns 6 - every { isCustom } returns false - every { iconUrl } returns null - every { contractAddress } returns "0xCONTRACT" - } - } - - private fun createCurrencyId(idValue: String, rawCurrencyId: String): CryptoCurrency.ID = mockk { - every { value } returns idValue - every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId) - } - - private fun createNetwork(networkId: String, standardTypeName: String): Network { - val standardType: Network.StandardType = mockk { - every { name } returns standardTypeName - } - return mockk { - every { id } returns mockk { - every { rawId } returns Network.RawID(networkId) - } - every { name } returns networkId - every { isTestnet } returns false - every { this@mockk.standardType } returns standardType - } - } -} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouUtilsTest.kt similarity index 99% rename from features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt rename to features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouUtilsTest.kt index 1766aa4a97..114b1accbe 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouUtilsTest.kt @@ -10,7 +10,7 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import java.math.BigDecimal -internal class ForYouPortfolioFormattersTest { +internal class ForYouUtilsTest { @Nested inner class ForYouGroupKey { diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt index ec6c995036..9bbc02f746 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt @@ -148,12 +148,13 @@ internal fun createEarnStatusValue( fiatAmount: BigDecimal = BigDecimal("100"), yieldSupplyActive: Boolean? = null, isStakingActive: Boolean = false, + stakingBalance: StakingBalance? = if (isStakingActive) mockk() else null, ): CryptoCurrencyStatus.Loaded = mockk { every { this@mockk.fiatAmount } returns fiatAmount every { yieldSupplyStatus } returns yieldSupplyActive?.let { active -> mockk { every { isActive } returns active } } - every { stakingBalance } returns if (isStakingActive) mockk() else null + every { this@mockk.stakingBalance } returns stakingBalance every { isError } returns false every { sources } returns CryptoCurrencyStatus.Sources() } @@ -165,13 +166,13 @@ internal fun createStatus( internal fun createEarnOpportunities( account: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 1), - earnCurrencues: Map = mapOf( + earnCurrencies: Map = mapOf( createStatus(createEarnCurrency()) to createEarnApyInfo(), ), accountPotentialReward: BigDecimal = BigDecimal.ZERO, ): EarnOpportunities = EarnOpportunities( account = account, - earnCurrencues = earnCurrencues, + earnCurrencies = earnCurrencies, accountPotentialReward = accountPotentialReward, ) diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt index e38e2b23dc..25a6c7a14d 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt @@ -2,17 +2,27 @@ package com.tangem.features.foryou.impl.model.converter.earnOpportunities import com.google.common.truth.Truth.assertThat import com.tangem.common.ui.R +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.earn.EarnTopToken +import com.tangem.domain.models.staking.BalanceItem +import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.StakingOption +import com.tangem.domain.staking.model.common.RewardInfo +import com.tangem.domain.staking.model.common.RewardType +import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM +import com.tangem.test.mock.MockAccounts import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Nested @@ -193,6 +203,74 @@ internal class ForYouEarnOpportunitiesConverterTest { .isEqualTo(expectedPerYearReward(fiat = BigDecimal("100"), yieldPercent = BigDecimal("10.00"))) } + @Test + fun `GIVEN active StakeKit stake WHEN convert THEN the staked validator's rate wins over best preferred`() { + // Arrange — the user stakes with v2 (4%), while the best preferred validator offers 10% + val staked = createEarnCurrency(tokenId = "ethereum", currencyId = "coin-staked") + val stakedStatus = createStatus( + staked, + createEarnStatusValue(fiatAmount = BigDecimal("100"), stakingBalance = stakeKitBalance("v2")), + ) + val fresh = createEarnCurrency(tokenId = "solana", currencyId = "coin-fresh") + val freshStatus = createStatus(fresh, createEarnStatusValue(fiatAmount = BigDecimal("100"))) + val converter = createConverter( + yieldStakingAvailability = mapOf( + staked to stakeKitAvailable( + validator(address = "v1", preferred = true, rate = BigDecimal("0.10")), + validator(address = "v2", preferred = false, rate = BigDecimal("0.04")), + ), + fresh to stakingAvailable(apy = BigDecimal("0.05")), + ), + ) + + // Act + val result = converter.convert( + createAccountStatusList(createPortfolioStatus(listOf(stakedStatus, freshStatus))), + ) + + // Assert — the staked token's row shows the 4% of the validator actually staked with + assertThat((result as EarnOpportunitiesUM.Content).rateOfRow("coin-staked")) + .isEqualTo(BigDecimal("0.04").format { percent() }) + } + + @Test + fun `GIVEN stake with unknown validator WHEN convert THEN falls back to best preferred validator rate`() { + // Arrange — the staked validator is not among the option's validators + val staked = createEarnCurrency(tokenId = "ethereum", currencyId = "coin-staked") + val stakedStatus = createStatus( + staked, + createEarnStatusValue(fiatAmount = BigDecimal("100"), stakingBalance = stakeKitBalance("unknown")), + ) + val fresh = createEarnCurrency(tokenId = "solana", currencyId = "coin-fresh") + val freshStatus = createStatus(fresh, createEarnStatusValue(fiatAmount = BigDecimal("100"))) + val converter = createConverter( + yieldStakingAvailability = mapOf( + staked to stakeKitAvailable( + validator(address = "v1", preferred = true, rate = BigDecimal("0.10")), + validator(address = "v2", preferred = true, rate = BigDecimal("0.12")), + validator(address = "v3", preferred = false, rate = BigDecimal("0.50")), + ), + fresh to stakingAvailable(apy = BigDecimal("0.05")), + ), + ) + + // Act + val result = converter.convert( + createAccountStatusList(createPortfolioStatus(listOf(stakedStatus, freshStatus))), + ) + + // Assert — the best *preferred* rate (12%) is used; the non-preferred 50% is ignored + assertThat((result as EarnOpportunitiesUM.Content).rateOfRow("coin-staked")) + .isEqualTo(BigDecimal("0.12").format { percent() }) + } + + /** Extracts the rendered rate (the styled bottom-end text) of the row with the given [id]. */ + private fun EarnOpportunitiesUM.Content.rateOfRow(id: String): String { + val row = tokenList.first { it.tokenRowUM.id == id }.tokenRowUM as TangemTokenRowUM.Content + val bottomEnd = row.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content + return (bottomEnd.text as TextReference.StyledStr).value + } + @Test fun `GIVEN staking-only token WHEN convert THEN staking rate is used for the reward`() { // Arrange @@ -212,6 +290,39 @@ internal class ForYouEarnOpportunitiesConverterTest { } } + @Nested + inner class AccountOrdering { + + @Test + fun `GIVEN several accounts WHEN convert THEN accounts ordered by potential reward descending`() { + // Arrange — same 5% rate; the second account holds more fiat (200 vs 100), so it earns more + val smallHolding = createEarnCurrency(tokenId = "ethereum", currencyId = "coin-eth") + val largeHolding = createEarnCurrency(tokenId = "solana", currencyId = "coin-sol") + val smallAccount = createPortfolioStatus( + currencies = listOf(createStatus(smallHolding, createEarnStatusValue(fiatAmount = BigDecimal("100")))), + account = MockAccounts.createAccount(derivationIndex = 1), + ) + val largeAccount = createPortfolioStatus( + currencies = listOf(createStatus(largeHolding, createEarnStatusValue(fiatAmount = BigDecimal("200")))), + account = MockAccounts.createAccount(derivationIndex = 2), + ) + val converter = createConverter( + yieldStakingAvailability = mapOf( + smallHolding to stakingAvailable(apy = BigDecimal("0.05")), + largeHolding to stakingAvailable(apy = BigDecimal("0.05")), + ), + ) + + // Act + val result = converter.convert(createAccountStatusList(smallAccount, largeAccount)) + + // Assert — the higher-earning account's token leads the flat list + assertThat((result as EarnOpportunitiesUM.Content).tokenList.map { it.tokenRowUM.id }) + .containsExactly("coin-sol", "coin-eth") + .inOrder() + } + } + private fun createConverter( yieldSupplyAvailability: Map = emptyMap(), yieldStakingAvailability: Map = emptyMap(), @@ -234,6 +345,39 @@ internal class ForYouEarnOpportunitiesConverterTest { private fun stakingAvailable(apy: BigDecimal): StakingAvailability = StakingAvailability.Available(option = stakingOption(apy)) + private fun stakeKitAvailable(vararg validatorList: Yield.Validator): StakingAvailability { + // A real StakeKit option with a real integration id: stubbing `integrationId` on a mock would + // make mockk instrument StakingIntegrationID.StakeKit, whose implementations are enums that the + // JVM refuses to retransform ("cannot change the class modifiers"). + val yieldModel: Yield = mockk { + every { validators } returns validatorList.toList() + every { apy } returns BigDecimal("0.10") + every { token } returns mockk() + every { isAvailable } returns true + } + return StakingAvailability.Available( + option = StakingOption.StakeKit( + integrationId = StakingIntegrationID.StakeKit.Coin.Ton, + yield = yieldModel, + ), + ) + } + + private fun validator(address: String, preferred: Boolean, rate: BigDecimal): Yield.Validator = mockk { + every { this@mockk.address } returns address + every { this@mockk.preferred } returns preferred + every { rewardInfo } returns RewardInfo(rate = rate, type = RewardType.APY) + } + + /** An active StakeKit balance whose items point at the given validator addresses. */ + private fun stakeKitBalance(vararg validatorAddresses: String?): StakingBalance.Data.StakeKit = mockk { + every { balance } returns mockk { + every { items } returns validatorAddresses.map { address -> + mockk { every { validatorAddress } returns address } + } + } + } + /** Mirrors the production reward computation: `fiat * (yieldPercent / 100)`, rendered per year. */ private fun expectedPerYearReward(fiat: BigDecimal, yieldPercent: BigDecimal) = fiat.multiply(yieldPercent.divide(BigDecimal("100"), RoundingMode.HALF_UP)).expectedPerYearText() diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt index 614e819c4b..b34fbcc926 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt @@ -54,7 +54,9 @@ internal class ForYouEarnOpportunitiesNoTokensConverterTest { @Test fun `GIVEN no top tokens loaded WHEN convert THEN suggestions are empty and reward type is absent`() { // Arrange - val converter = ForYouEarnOpportunitiesNoTokensConverter(topEarnTokens = null) + val converter = ForYouEarnOpportunitiesNoTokensConverter( + topEarnTokens = null, + ) // Act val result = converter.convert(emptyList()) as EarnOpportunitiesUM.Content diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt index 98cc7f3b16..32d1e4ed99 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt @@ -21,7 +21,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest { fun `GIVEN accounts mode off WHEN convert THEN one flat row per earn currency`() { // Arrange val earnData = createEarnOpportunities( - earnCurrencues = listOf("token-a", "token-b").associate { currencyId -> + earnCurrencies = listOf("token-a", "token-b").associate { currencyId -> createStatus( createEarnCurrency(tokenId = currencyId, currencyId = currencyId), createRowLoadedValue(), @@ -45,7 +45,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest { val account = MockAccounts.createAccount(derivationIndex = 1, name = "Earn account") val earnData = createEarnOpportunities( account = account, - earnCurrencues = listOf("token-a", "token-b").associate { currencyId -> + earnCurrencies = listOf("token-a", "token-b").associate { currencyId -> createStatus( createEarnCurrency(tokenId = currencyId, currencyId = currencyId), createRowLoadedValue(), @@ -71,7 +71,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest { val account = MockAccounts.createAccount(derivationIndex = 1) val earnData = createEarnOpportunities( account = account, - earnCurrencues = mapOf( + earnCurrencies = mapOf( createStatus(createEarnCurrency(), createRowLoadedValue()) to createEarnApyInfo(isActive = false), ), ) @@ -93,7 +93,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest { val account = MockAccounts.createAccount(derivationIndex = 1) val earnData = createEarnOpportunities( account = account, - earnCurrencues = mapOf( + earnCurrencies = mapOf( createStatus(createEarnCurrency(), createRowLoadedValue()) to createEarnApyInfo(isActive = false), ), ) diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt index 6d5ff554ef..11341b1c94 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt @@ -15,7 +15,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { fun `GIVEN top tokens contain active portfolio assets WHEN convert THEN active ones are excluded`() { // Arrange val activePortfolio = createEarnOpportunities( - earnCurrencues = mapOf( + earnCurrencies = mapOf( createStatus(createEarnCurrency(tokenId = "ethereum", networkRawId = "ETH")) to createEarnApyInfo(), ), ) @@ -37,7 +37,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { fun `GIVEN more suggestions than the cap WHEN convert THEN filtering happens before the top-5 cut`() { // Arrange — two of the first candidates are active; the cap must still be filled from the tail val activePortfolio = createEarnOpportunities( - earnCurrencues = listOf("token-0", "token-1").associate { tokenId -> + earnCurrencies = listOf("token-0", "token-1").associate { tokenId -> createStatus(createEarnCurrency(tokenId = tokenId, networkRawId = "NET")) to createEarnApyInfo() }, ) @@ -60,7 +60,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { fun `GIVEN asset active on another network WHEN convert THEN suggestion on a new network is kept`() { // Arrange — matching is per asset AND network, not per asset val activePortfolio = createEarnOpportunities( - earnCurrencues = mapOf( + earnCurrencies = mapOf( createStatus(createEarnCurrency(tokenId = "usd-coin", networkRawId = "ETH")) to createEarnApyInfo(), ), ) @@ -81,7 +81,9 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { @Test fun `GIVEN no top tokens loaded WHEN convert THEN content with empty suggestions`() { // Arrange - val converter = ForYouEarnOpportunitiesTokensActiveConverter(topEarnTokens = null) + val converter = ForYouEarnOpportunitiesTokensActiveConverter( + topEarnTokens = null, + ) // Act val result = converter.convert(listOf(createEarnOpportunities())) diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewConverterTest.kt new file mode 100644 index 0000000000..02e1a1dabc --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewConverterTest.kt @@ -0,0 +1,458 @@ +package com.tangem.features.foryou.impl.model.converter.portfolioReview + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.ds.row.token.TangemTokenRowUM +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.foryou.impl.R +import com.tangem.features.foryou.impl.components.state.MarketChartUM +import com.tangem.features.foryou.impl.entity.PortfolioReviewUM +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class ForYouPortfolioReviewConverterTest { + + private val appCurrency: AppCurrency = AppCurrency.Default + + @Nested + inner class AssetRanking { + + @Test + fun `GIVEN currency with resolved zero fiat balance WHEN convert THEN it is dropped from the list`() { + // Arrange + val zeroBalance = createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin") + val nonZeroBalance = createCoin(rawCurrencyId = "eth", symbol = "ETH", networkId = "ethereum") + val statuses = listOf( + createStatus(zeroBalance, loadedValue(BigDecimal.ONE, BigDecimal.ZERO)), + createStatus(nonZeroBalance, loadedValue(BigDecimal.ONE, BigDecimal("100"))), + ) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("100")) + + // Assert — only the ETH asset survives; the zero-fiat BTC is dropped + assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth") + } + + @Test + fun `GIVEN non-content status with null fiat WHEN convert THEN it is kept not dropped`() { + // Arrange — a non-content status (Unreachable) carries a null fiatAmount, not a resolved zero; + // it must still be shown so the user sees the token they hold, with the appropriate treatment. + val unreachable = createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin") + val loaded = createCoin(rawCurrencyId = "eth", symbol = "ETH", networkId = "ethereum") + val statuses = listOf( + createStatus(unreachable, unreachableValue()), + createStatus(loaded, loadedValue(BigDecimal.ONE, BigDecimal("100"))), + ) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("100")) + + // Assert — both assets kept, ranked by summed fiat (eth 100 > btc 0) + assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth", "btc").inOrder() + } + + @Test + fun `GIVEN same asset across networks WHEN convert THEN aggregated into one asset ranked by summed fiat`() { + // Arrange — the same asset (shared rawCurrencyId "usdc") aggregates into one asset + val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum") + val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana") + val other = createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin") + val statuses = listOf( + createStatus(onEth, loadedValue(BigDecimal.ONE, BigDecimal("50"))), + createStatus(onSol, loadedValue(BigDecimal.ONE, BigDecimal("60"))), + createStatus(other, loadedValue(BigDecimal.ONE, BigDecimal("10"))), + ) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("120")) + + // Assert — 2 ranked assets: usdc (110 total) ahead of btc (10) + assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("usdc", "btc").inOrder() + } + + @Test + fun `GIVEN more than four assets WHEN convert THEN excess assets collapse into Other`() { + // Arrange — 5 distinct assets, top 4 kept individually, 5th collapsed into "Other" + val statuses = (1..5).map { index -> + createStatus( + createCoin(rawCurrencyId = "asset-$index", symbol = "A$index", networkId = "net-$index"), + loadedValue(BigDecimal.ONE, BigDecimal(100 - index)), + ) + } + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("470")) + + // Assert — 4 top asset rows + 1 "Other" row + assertThat(result.tokenList).hasSize(5) + assertThat(result.tokenList.last().tokenRowUM.id).isEqualTo("for_you_other_assets") + assertThat(result.tokenList.last().isExpandable).isFalse() + } + + @Test + fun `GIVEN exactly four assets WHEN convert THEN no Other row is appended`() { + // Arrange + val statuses = (1..4).map { index -> + createStatus( + createCoin(rawCurrencyId = "asset-$index", symbol = "A$index", networkId = "net-$index"), + loadedValue(BigDecimal.ONE, BigDecimal(100 - index)), + ) + } + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("394")) + + // Assert + assertThat(result.tokenList).hasSize(4) + } + + @Test + fun `GIVEN a single other asset WHEN convert THEN Other row subtitle is singular`() { + // Arrange — 5 assets: the lowest-balance one collapses into an "Other" row of count 1 + val statuses = (1..5).map { index -> + createStatus( + createCoin(rawCurrencyId = "asset-$index", symbol = "A$index", networkId = "net-$index"), + loadedValue(BigDecimal.ONE, BigDecimal(100 - index)), + ) + } + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("470")) + + // Assert + val otherRow = result.tokenList.last().tokenRowUM as TangemTokenRowUM.Content + val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo( + pluralReference(R.plurals.market_chart_assets_android, count = 1, formatArgs = wrappedList(1)), + ) + } + + @Test + fun `GIVEN several other assets WHEN convert THEN Other row subtitle is plural`() { + // Arrange — 7 assets: three lowest-balance ones collapse into an "Other" row of count 3 + val statuses = (1..7).map { index -> + createStatus( + createCoin(rawCurrencyId = "asset-$index", symbol = "A$index", networkId = "net-$index"), + loadedValue(BigDecimal.ONE, BigDecimal(100 - index)), + ) + } + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("658")) + + // Assert + val otherRow = result.tokenList.last().tokenRowUM as TangemTokenRowUM.Content + val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo( + pluralReference(R.plurals.market_chart_assets_android, count = 3, formatArgs = wrappedList(3)), + ) + } + + @Test + fun `GIVEN null account status list WHEN convert THEN token list is empty`() { + // Act + val result = createConverter().convert(null) as PortfolioReviewUM.Content + + // Assert + assertThat(result.tokenList).isEmpty() + } + } + + @Nested + inner class AssetRow { + + @Test + fun `GIVEN single-network coin WHEN convert THEN subtitle is common main network`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val statuses = listOf(createStatus(currency, loadedValue(BigDecimal.ONE, BigDecimal("100")))) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("100")) + + // Assert + val row = result.tokenList.single().tokenRowUM as TangemTokenRowUM.Content + val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo(resourceReference(R.string.common_main_network)) + } + + @Test + fun `GIVEN single-network token WHEN convert THEN subtitle is the network standard type name`() { + // Arrange + val currency = createToken( + rawCurrencyId = "usdc", + symbol = "USDC", + networkId = "ethereum", + standardTypeName = "ERC20", + ) + val statuses = listOf(createStatus(currency, loadedValue(BigDecimal.ONE, BigDecimal("100")))) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("100")) + + // Assert + val row = result.tokenList.single().tokenRowUM as TangemTokenRowUM.Content + val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo(stringReference("ERC20")) + } + + @Test + fun `GIVEN asset spans multiple networks WHEN convert THEN subtitle shows network count with child rows`() { + // Arrange — same asset (shared rawCurrencyId) on two different networks + val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum") + val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana") + val statuses = listOf( + createStatus(onEth, loadedValue(BigDecimal.ONE, BigDecimal("100"))), + createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("200"))), + ) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("300")) + + // Assert + val item = result.tokenList.single() + val row = item.tokenRowUM as TangemTokenRowUM.Content + val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content + assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_networks_count, count = 2)) + assertThat(item.tokenList).hasSize(2) + } + + @Test + fun `GIVEN multi-network asset WHEN convert THEN child rows ordered by descending fiat balance`() { + // Arrange + val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum") + val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana") + val statuses = listOf( + createStatus(onEth, loadedValue(BigDecimal.ONE, BigDecimal("100"))), + createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("500"))), + ) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("600")) + + // Assert — Solana holding (500) ranks above Ethereum holding (100) + val childIds = result.tokenList.single().tokenList.map { it.id } + assertThat(childIds).containsExactly("token-usdc-solana", "token-usdc-ethereum").inOrder() + } + + @Test + fun `GIVEN all statuses of an asset are Loading WHEN convert THEN asset row is Loading`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val statuses = listOf(createStatus(currency, CryptoCurrencyStatus.Loading)) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal.ZERO) + + // Assert + assertThat(result.tokenList.single().tokenRowUM).isInstanceOf(TangemTokenRowUM.Loading::class.java) + } + + @Test + fun `GIVEN asset id in expandedAssetIds WHEN convert THEN item isExpanded is true`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val statuses = listOf(createStatus(currency, loadedValue(BigDecimal.ONE, BigDecimal("100")))) + val converter = createConverter(expandedAssetIds = setOf("bitcoin")) + + // Act + val result = converter.convert( + accountStatusList(statuses, BigDecimal("100")), + ) as PortfolioReviewUM.Content + + // Assert + assertThat(result.tokenList.single().isExpanded).isTrue() + } + + @Test + fun `GIVEN asset row clicked WHEN convert THEN expand callback receives the asset id`() { + // Arrange + val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin") + val statuses = listOf(createStatus(currency, loadedValue(BigDecimal.ONE, BigDecimal("100")))) + var clickedAssetId: String? = null + val converter = createConverter(expandClick = { clickedAssetId = it }) + + // Act + val result = converter.convert( + accountStatusList(statuses, BigDecimal("100")), + ) as PortfolioReviewUM.Content + (result.tokenList.single().tokenRowUM as TangemTokenRowUM.Content).onItemClick?.invoke() + + // Assert + assertThat(clickedAssetId).isEqualTo("bitcoin") + } + } + + @Nested + inner class MarketChart { + + @Test + fun `GIVEN loaded total balance WHEN convert THEN market chart is Loaded with one segment per top asset`() { + // Arrange + val statuses = listOf( + createStatus( + createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin"), + loadedValue(BigDecimal.ONE, BigDecimal("70")), + ), + createStatus( + createCoin(rawCurrencyId = "eth", symbol = "ETH", networkId = "ethereum"), + loadedValue(BigDecimal.ONE, BigDecimal("30")), + ), + ) + + // Act + val result = convert(statuses, totalFiatBalance = BigDecimal("100")) + + // Assert + val marketChart = result.marketChartUM as MarketChartUM.Loaded + assertThat(marketChart.assetCount).isEqualTo(2) + } + + @Test + fun `GIVEN non-loaded total balance WHEN convert THEN market chart is NoData`() { + // Arrange + val statuses = listOf( + createStatus( + createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin"), + loadedValue(BigDecimal.ONE, BigDecimal("100")), + ), + ) + val statusList: AccountStatusList = mockk { + every { flattenCurrencies() } returns statuses + every { totalFiatBalance } returns TotalFiatBalance.Loading + every { userWalletId } returns UserWalletId("01") + } + + // Act + val result = createConverter().convert(statusList) as PortfolioReviewUM.Content + + // Assert + assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData) + } + + @Test + fun `GIVEN null account status list WHEN convert THEN market chart is NoData`() { + // Act + val result = createConverter().convert(null) as PortfolioReviewUM.Content + + // Assert + assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData) + } + } + + private fun convert( + statuses: List, + totalFiatBalance: BigDecimal, + ): PortfolioReviewUM.Content = + createConverter().convert(accountStatusList(statuses, totalFiatBalance)) as PortfolioReviewUM.Content + + private fun createConverter( + expandedAssetIds: Set = emptySet(), + expandClick: (String) -> Unit = {}, + onTokenClick: (UserWalletId, CryptoCurrency) -> Unit = { _, _ -> }, + ): ForYouPortfolioReviewConverter = ForYouPortfolioReviewConverter( + appCurrency = appCurrency, + expandedAssetIds = expandedAssetIds, + expandClick = expandClick, + onTokenClick = onTokenClick, + ) + + private fun accountStatusList( + currencies: List, + totalFiatBalance: BigDecimal, + source: StatusSource = StatusSource.ACTUAL, + ): AccountStatusList = mockk { + every { flattenCurrencies() } returns currencies + every { this@mockk.totalFiatBalance } returns TotalFiatBalance.Loaded( + amount = totalFiatBalance, + source = source, + ) + every { userWalletId } returns UserWalletId("01") + } + + private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( + currency = currency, + value = value, + ) + + private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk { + every { this@mockk.amount } returns amount + every { this@mockk.fiatAmount } returns fiatAmount + every { isError } returns false + every { sources } returns CryptoCurrencyStatus.Sources() + } + + /** A non-content status: carries a null fiatAmount (unknown balance), not a resolved zero. */ + private fun unreachableValue(): CryptoCurrencyStatus.Unreachable = CryptoCurrencyStatus.Unreachable( + priceChange = null, + fiatRate = null, + networkAddress = null, + ) + + private fun createCoin(rawCurrencyId: String, symbol: String, networkId: String): CryptoCurrency.Coin { + val network = createNetwork(networkId = networkId, standardTypeName = "MAIN") + val currencyId = createCurrencyId(idValue = "coin-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId) + return mockk { + every { this@mockk.id } returns currencyId + every { this@mockk.symbol } returns symbol + every { this@mockk.name } returns symbol + every { this@mockk.network } returns network + every { this@mockk.decimals } returns 8 + every { isCustom } returns false + every { iconUrl } returns null + } + } + + private fun createToken( + rawCurrencyId: String, + symbol: String, + networkId: String, + standardTypeName: String = "ERC20", + ): CryptoCurrency.Token { + val network = createNetwork(networkId = networkId, standardTypeName = standardTypeName) + val currencyId = createCurrencyId(idValue = "token-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId) + return mockk { + every { this@mockk.id } returns currencyId + every { this@mockk.symbol } returns symbol + every { this@mockk.name } returns symbol + every { this@mockk.network } returns network + every { this@mockk.decimals } returns 6 + every { isCustom } returns false + every { iconUrl } returns null + every { contractAddress } returns "0xCONTRACT" + } + } + + private fun createCurrencyId(idValue: String, rawCurrencyId: String): CryptoCurrency.ID = mockk { + every { value } returns idValue + every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId) + } + + private fun createNetwork(networkId: String, standardTypeName: String): Network { + val standardType: Network.StandardType = mockk { + every { name } returns standardTypeName + } + return mockk { + every { id } returns mockk { + every { rawId } returns Network.RawID(networkId) + } + every { name } returns networkId + every { isTestnet } returns false + every { this@mockk.standardType } returns standardType + } + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewTokenRowConverterTest.kt similarity index 82% rename from features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt rename to features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewTokenRowConverterTest.kt index 01d8de1102..15447c10cb 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/portfolioReview/ForYouPortfolioReviewTokenRowConverterTest.kt @@ -1,4 +1,4 @@ -package com.tangem.features.foryou.impl.model.converter +package com.tangem.features.foryou.impl.model.converter.portfolioReview import com.google.common.truth.Truth.assertThat import com.tangem.core.ui.ds.row.token.TangemTokenRowUM @@ -12,13 +12,15 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.foryou.impl.model.converter.toForYouPercent import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import java.math.BigDecimal -internal class ForYouTokenRowConverterTest { +internal class ForYouPortfolioReviewTokenRowConverterTest { private val appCurrency: AppCurrency = AppCurrency.Default @@ -194,6 +196,51 @@ internal class ForYouTokenRowConverterTest { assertThat(bottomEnd.endIcons).hasSize(1) } + @Test + fun `GIVEN wallet id present WHEN row clicked THEN token callback receives wallet id and currency`() { + // Arrange + val currency = createCurrency(id = "coin-eth", symbol = "ETH") + val statuses = listOf( + createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))), + ) + val walletId = UserWalletId("01") + var clicked: Pair? = null + val converter = createConverter( + totalFiatBalance = BigDecimal("1000"), + userWalletId = walletId, + onTokenClick = { id, clickedCurrency -> clicked = id to clickedCurrency }, + ) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + result.onItemClick?.invoke() + + // Assert + assertThat(clicked).isEqualTo(walletId to currency) + } + + @Test + fun `GIVEN no wallet id WHEN row clicked THEN token callback is not invoked`() { + // Arrange — without a selected wallet there is nowhere to navigate, so the click is a no-op + val currency = createCurrency(id = "coin-eth", symbol = "ETH") + val statuses = listOf( + createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))), + ) + var clicked = false + val converter = createConverter( + totalFiatBalance = BigDecimal("1000"), + userWalletId = null, + onTokenClick = { _, _ -> clicked = true }, + ) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + result.onItemClick?.invoke() + + // Assert + assertThat(clicked).isFalse() + } + @Test fun `GIVEN mixed MissedDerivation and Unreachable WHEN convertNetworkGroup THEN missed-derivation wins`() { // Arrange — missed derivation is the most severe terminal state and dominates @@ -213,18 +260,23 @@ internal class ForYouTokenRowConverterTest { } } - private fun createConverter(totalFiatBalance: BigDecimal) = ForYouTokenRowConverter( + private fun createConverter( + totalFiatBalance: BigDecimal, + userWalletId: UserWalletId? = UserWalletId("01"), + onTokenClick: (UserWalletId, CryptoCurrency) -> Unit = { _, _ -> }, + ) = ForYouPortfolioReviewTokenRowConverter( appCurrency = appCurrency, + userWalletId = userWalletId, totalFiatBalance = totalFiatBalance, - onTokenClick = {}, + onTokenClick = onTokenClick, ) - /** Mirrors the production fiat rendering used by [ForYouTokenRowConverter] for a resolved row. */ + /** Mirrors the production fiat rendering used by [ForYouPortfolioReviewTokenRowConverter] for a resolved row. */ private fun BigDecimal.expectedFiatText(): TextReference = stringReference( format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, ) - /** Mirrors the production percent-share rendering used by [ForYouTokenRowConverter] for a resolved row. */ + /** Mirrors the production percent-share rendering of [ForYouPortfolioReviewTokenRowConverter]. */ private fun BigDecimal.expectedPercentText(total: BigDecimal): TextReference = stringReference( toForYouPercent(total).format { percent() }, ) @@ -278,6 +330,7 @@ internal class ForYouTokenRowConverterTest { return mockk { every { this@mockk.id } returns currencyId every { this@mockk.symbol } returns symbol + every { this@mockk.name } returns symbol every { this@mockk.network } returns network every { this@mockk.decimals } returns 8 every { isCustom } returns false diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt index 450b82515c..0b2cbd80b5 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt @@ -1,17 +1,14 @@ package com.tangem.features.foryou.impl.model.transformer import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.ds.tabs.TangemSegmentUM +import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.account.models.AccountStatusList -import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.network.Network import com.tangem.features.foryou.impl.components.state.MarketChartUM import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM -import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM import com.tangem.features.foryou.impl.entity.ForYouUM import com.tangem.features.foryou.impl.entity.PortfolioReviewUM import com.tangem.features.foryou.impl.model.ForYouNotification @@ -24,163 +21,25 @@ import java.math.BigDecimal internal class SetPortfolioReviewTransformerTest { - private val appCurrency: AppCurrency = AppCurrency.Default - @Nested - inner class TokenList { + inner class Sections { @Test - fun `GIVEN currency with resolved zero fiat balance WHEN transform THEN it is dropped from the list`() { + fun `GIVEN pre-built section UMs WHEN transform THEN both are set on the state`() { // Arrange - val zeroBalance = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val nonZeroBalance = createCurrency(rawCurrencyId = "eth", symbol = "ETH") - val currencies = listOf( - createStatus(zeroBalance, loadedValue(BigDecimal.ZERO)), - createStatus(nonZeroBalance, loadedValue(BigDecimal("100"))), + val portfolioReview = contentPortfolioReview() + val earnOpportunities = contentEarnOpportunities() + val transformer = createTransformer( + portfolioReviewUM = portfolioReview, + earnOpportunitiesUM = earnOpportunities, ) - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100")))) // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert — only the ETH asset survives; the zero-fiat BTC is dropped - assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth") - } - - @Test - fun `GIVEN non-content status with null fiat WHEN transform THEN it is kept not dropped`() { - // Arrange — a non-content status (Unreachable) carries a null fiatAmount, not a resolved zero; - // it must still be shown so the user sees the token they hold, with the appropriate treatment. - val unreachable = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val loaded = createCurrency(rawCurrencyId = "eth", symbol = "ETH") - val currencies = listOf( - createStatus(unreachable, unreachableValue()), - createStatus(loaded, loadedValue(BigDecimal("100"))), - ) - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100")))) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert — both assets kept, ranked by summed fiat (eth 100 > btc 0) - assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth", "btc").inOrder() - } - - @Test - fun `GIVEN same asset across networks WHEN transform THEN aggregated into one asset ranked by summed fiat`() { - // Arrange — the same asset (shared rawCurrencyId "usdc") aggregates into one asset - val onEth = createCurrency(rawCurrencyId = "usdc", symbol = "USDC") - val onSol = createCurrency(rawCurrencyId = "usdc", symbol = "USDC") - val other = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val currencies = listOf( - createStatus(onEth, loadedValue(BigDecimal("50"))), - createStatus(onSol, loadedValue(BigDecimal("60"))), - createStatus(other, loadedValue(BigDecimal("10"))), - ) - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("120")))) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert — 2 ranked assets: usdc (110 total) ahead of btc (10) - assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("usdc", "btc").inOrder() - } - - @Test - fun `GIVEN more than TOP_HOLDINGS_COUNT assets WHEN transform THEN excess assets collapse into Other`() { - // Arrange — 5 distinct assets, top 4 kept individually, 5th collapsed into "Other" - val currencies = (1..5).map { index -> - createStatus( - createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"), - loadedValue(BigDecimal(100 - index)), - ) - } - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("470")))) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert — 4 top asset rows + 1 "Other" row - assertThat(result.tokenList).hasSize(5) - assertThat(result.tokenList.last().tokenRowUM.id).isEqualTo("for_you_other_assets") - } - - @Test - fun `GIVEN exactly TOP_HOLDINGS_COUNT assets WHEN transform THEN no Other row is appended`() { - // Arrange - val currencies = (1..4).map { index -> - createStatus( - createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"), - loadedValue(BigDecimal(100 - index)), - ) - } - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("394")))) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content + val result = transformer.transform(loadingState()) // Assert - assertThat(result.tokenList).hasSize(4) - } - - @Test - fun `GIVEN null account status list WHEN transform THEN token list is empty`() { - // Arrange - val transformer = createTransformer(accountStatusList = null) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert - assertThat(result.tokenList).isEmpty() - } - } - - @Nested - inner class MarketChart { - - @Test - fun `GIVEN loaded total balance WHEN transform THEN market chart is Loaded with one segment per top asset`() { - // Arrange - val currencies = listOf( - createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("70"))), - createStatus(createCurrency(rawCurrencyId = "eth", symbol = "ETH"), loadedValue(BigDecimal("30"))), - ) - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100")))) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert - val marketChart = result.marketChartUM as MarketChartUM.Loaded - assertThat(marketChart.assetCount).isEqualTo(2) - } - - @Test - fun `GIVEN non-loaded total balance WHEN transform THEN market chart is NoData`() { - // Arrange - val currencies = listOf( - createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("100"))), - ) - val transformer = createTransformer(accountStatusList(currencies, TotalFiatBalance.Loading)) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert - assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData) - } - - @Test - fun `GIVEN null account status list WHEN transform THEN market chart is NoData`() { - // Arrange - val transformer = createTransformer(accountStatusList = null) - - // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - - // Assert - assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData) + assertThat(result.portfolioReviewUM).isEqualTo(portfolioReview) + assertThat(result.earnOpportunities).isEqualTo(earnOpportunities) } } @@ -188,45 +47,37 @@ internal class SetPortfolioReviewTransformerTest { inner class PeriodPicker { @Test - fun `GIVEN prev state is Loading WHEN transform THEN period picker is freshly created with Day selected`() { + fun `GIVEN previous state is Loading WHEN transform THEN period picker is created with Day selected`() { // Arrange - val currencies = listOf( - createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), - ) - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("10")))) + val transformer = createTransformer() // Act - val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content + val result = transformer.transform(loadingState()) // Assert - assertThat(result.periodPickerUM.items.map { it.title }).containsExactly( - stringReference("Day"), - stringReference("Week"), - stringReference("Month"), - ).inOrder() - assertThat(result.periodPickerUM.initialSelectedItem?.title).isEqualTo(stringReference("Day")) + assertThat(result.periodPickerUM.items).hasSize(3) + assertThat(result.periodPickerUM.initialSelectedItem).isEqualTo(result.periodPickerUM.items.first()) } @Test - fun `GIVEN prev state is Content WHEN transform THEN period picker selection is preserved`() { - // Arrange - val currencies = listOf( - createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), + fun `GIVEN previous state is Content WHEN transform THEN user's picker selection is carried over`() { + // Arrange — the user has already switched to the "Week" segment + val week = TangemSegmentUM(id = "1", title = stringReference("Week")) + val pickerWithSelection = TangemSegmentedPickerUM( + items = persistentListOf(TangemSegmentUM(id = "0", title = stringReference("Day")), week), + initialSelectedItem = week, ) - val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("10")))) - val prevContent = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - val weekItem = prevContent.periodPickerUM.items[1] val prevState = loadingState().copy( - portfolioReviewUM = prevContent.copy( - periodPickerUM = prevContent.periodPickerUM.copy(initialSelectedItem = weekItem), - ), + portfolioReviewUM = contentPortfolioReview(), + periodPickerUM = pickerWithSelection, ) + val transformer = createTransformer() // Act - val result = transformer.transform(prevState).portfolioReviewUM as PortfolioReviewUM.Content + val result = transformer.transform(prevState) - // Assert - assertThat(result.periodPickerUM.initialSelectedItem).isEqualTo(weekItem) + // Assert — a balance refresh must not reset the selection back to Day + assertThat(result.periodPickerUM).isEqualTo(pickerWithSelection) } } @@ -236,11 +87,8 @@ internal class SetPortfolioReviewTransformerTest { @Test fun `GIVEN total balance from outdated source WHEN transform THEN outdated-data notification is emitted`() { // Arrange - val currencies = listOf( - createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), - ) val transformer = createTransformer( - accountStatusList(currencies, loaded(BigDecimal("10"), source = StatusSource.ONLY_CACHE)), + accountStatusList = accountStatusList(loaded(BigDecimal("10"), source = StatusSource.ONLY_CACHE)), ) // Act @@ -253,11 +101,8 @@ internal class SetPortfolioReviewTransformerTest { @Test fun `GIVEN total balance from actual source WHEN transform THEN no notification is emitted`() { // Arrange - val currencies = listOf( - createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), - ) val transformer = createTransformer( - accountStatusList(currencies, loaded(BigDecimal("10"), source = StatusSource.ACTUAL)), + accountStatusList = accountStatusList(loaded(BigDecimal("10"), source = StatusSource.ACTUAL)), ) // Act @@ -281,22 +126,28 @@ internal class SetPortfolioReviewTransformerTest { } private fun createTransformer( - accountStatusList: AccountStatusList?, - expandedAssetIds: Set = emptySet(), + accountStatusList: AccountStatusList? = null, + portfolioReviewUM: PortfolioReviewUM = contentPortfolioReview(), + earnOpportunitiesUM: EarnOpportunitiesUM = contentEarnOpportunities(), ) = SetPortfolioReviewTransformer( accountStatusList = accountStatusList, - appCurrency = appCurrency, - expandedAssetIds = expandedAssetIds, - expandClick = {}, - onPeriodClick = {}, - onTokenClick = {}, + portfolioReviewUM = portfolioReviewUM, + earnOpportunitiesUM = earnOpportunitiesUM, ) - private fun accountStatusList( - currencies: List, - totalFiatBalance: TotalFiatBalance, - ): AccountStatusList = mockk { - every { flattenCurrencies() } returns currencies + private fun contentPortfolioReview(): PortfolioReviewUM.Content = PortfolioReviewUM.Content( + tokenList = persistentListOf(), + marketChartUM = MarketChartUM.NoData, + ) + + private fun contentEarnOpportunities(): EarnOpportunitiesUM.Content = EarnOpportunitiesUM.Content( + tokenList = persistentListOf(), + subtitleRes = 0, + potentialReward = null, + potentialRewardType = null, + ) + + private fun accountStatusList(totalFiatBalance: TotalFiatBalance): AccountStatusList = mockk { every { this@mockk.totalFiatBalance } returns totalFiatBalance } @@ -305,50 +156,12 @@ internal class SetPortfolioReviewTransformerTest { private fun loadingState(): ForYouUM = ForYouUM( portfolioReviewUM = PortfolioReviewUM.Loading( - tokenList = persistentListOf(), + tokenList = persistentListOf(), marketChartUM = MarketChartUM.NoData, ), earnOpportunities = EarnOpportunitiesUM.Loading(tokenList = persistentListOf()), notifications = persistentListOf(), + periodPickerUM = TangemSegmentedPickerUM(persistentListOf()), + onPeriodClick = {}, ) - - private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( - currency = currency, - value = value, - ) - - private fun loadedValue(fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk { - every { amount } returns BigDecimal.ONE - every { this@mockk.fiatAmount } returns fiatAmount - every { isError } returns false - every { sources } returns CryptoCurrencyStatus.Sources() - } - - /** A non-content status: carries a null fiatAmount (unknown balance), not a resolved zero. */ - private fun unreachableValue(): CryptoCurrencyStatus.Unreachable = CryptoCurrencyStatus.Unreachable( - priceChange = null, - fiatRate = null, - networkAddress = null, - ) - - private fun createCurrency(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin { - val network: Network = mockk { - every { name } returns "Network" - every { isTestnet } returns false - every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) } - } - val currencyId: CryptoCurrency.ID = mockk { - every { value } returns "coin-$rawCurrencyId" - every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId) - } - return mockk { - every { this@mockk.id } returns currencyId - every { this@mockk.symbol } returns symbol - every { this@mockk.name } returns symbol - every { this@mockk.network } returns network - every { this@mockk.decimals } returns 8 - every { isCustom } returns false - every { iconUrl } returns null - } - } } \ No newline at end of file