Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-15 11:02:36 +05:00
parent f86441df6a
commit 571bf358bd
11 changed files with 936 additions and 590 deletions

View file

@ -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<EarnTokensBatchingContext> = 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<CryptoCurrencyStatus>,
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<List<EarnTokenWithCurrency>>,
suggestions: List<EarnTokenWithCurrency> = 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<CryptoCurrencyStatus>,
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<AccountStatus.CryptoPortfolio> {
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<CryptoCurrency.Coin> {
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")
}
}

View file

@ -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<String> = emptySet(),
otherAssets: List<Pair<List<CryptoCurrencyStatus>, 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<List<CryptoCurrencyStatus>, BigDecimal> =
emptyList<CryptoCurrencyStatus>() 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<CryptoCurrency.Coin> {
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<CryptoCurrency.Token> {
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
}
}
}

View file

@ -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 {

View file

@ -148,12 +148,13 @@ internal fun createEarnStatusValue(
fiatAmount: BigDecimal = BigDecimal("100"),
yieldSupplyActive: Boolean? = null,
isStakingActive: Boolean = false,
stakingBalance: StakingBalance? = if (isStakingActive) mockk<StakingBalance.Data.P2PEthPool>() else null,
): CryptoCurrencyStatus.Loaded = mockk {
every { this@mockk.fiatAmount } returns fiatAmount
every { yieldSupplyStatus } returns yieldSupplyActive?.let { active ->
mockk<YieldSupplyStatus> { every { isActive } returns active }
}
every { stakingBalance } returns if (isStakingActive) mockk<StakingBalance.Data.P2PEthPool>() 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<CryptoCurrencyStatus, EarnApyInfo> = mapOf(
earnCurrencies: Map<CryptoCurrencyStatus, EarnApyInfo> = mapOf(
createStatus(createEarnCurrency()) to createEarnApyInfo(),
),
accountPotentialReward: BigDecimal = BigDecimal.ZERO,
): EarnOpportunities = EarnOpportunities(
account = account,
earnCurrencues = earnCurrencues,
earnCurrencies = earnCurrencies,
accountPotentialReward = accountPotentialReward,
)

View file

@ -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<String, BigDecimal> = emptyMap(),
yieldStakingAvailability: Map<CryptoCurrency, StakingAvailability> = 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<BalanceItem> { 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()

View file

@ -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

View file

@ -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),
),
)

View file

@ -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()))

View file

@ -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<CryptoCurrencyStatus>,
totalFiatBalance: BigDecimal,
): PortfolioReviewUM.Content =
createConverter().convert(accountStatusList(statuses, totalFiatBalance)) as PortfolioReviewUM.Content
private fun createConverter(
expandedAssetIds: Set<String> = emptySet(),
expandClick: (String) -> Unit = {},
onTokenClick: (UserWalletId, CryptoCurrency) -> Unit = { _, _ -> },
): ForYouPortfolioReviewConverter = ForYouPortfolioReviewConverter(
appCurrency = appCurrency,
expandedAssetIds = expandedAssetIds,
expandClick = expandClick,
onTokenClick = onTokenClick,
)
private fun accountStatusList(
currencies: List<CryptoCurrencyStatus>,
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<CryptoCurrency.Coin> {
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<CryptoCurrency.Token> {
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
}
}
}

View file

@ -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<UserWalletId, CryptoCurrency>? = 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<CryptoCurrency.Coin> {
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

View file

@ -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<String> = 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<CryptoCurrencyStatus>,
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<ForYouTokenListItemUM>(),
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<CryptoCurrency.Coin> {
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
}
}
}