Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-15 16:46:49 +05:00
commit 5c293e71ec
26 changed files with 1279 additions and 750 deletions

View file

@ -39,6 +39,8 @@ dependencies {
api(projects.domain.appCurrency)
api(projects.domain.common)
api(projects.domain.wallets)
api(projects.domain.earn)
api(projects.domain.yieldSupply)
implementation(projects.domain.account)
implementation(projects.domain.models)

View file

@ -14,6 +14,8 @@ internal data class ForYouUM(
val portfolioReviewUM: PortfolioReviewUM,
val earnOpportunities: EarnOpportunitiesUM,
val notifications: ImmutableList<ForYouNotification>,
val periodPickerUM: TangemSegmentedPickerUM,
val onPeriodClick: (tangemSegmentUM: TangemSegmentUM) -> Unit,
)
@Immutable
@ -29,20 +31,32 @@ internal sealed interface PortfolioReviewUM {
data class Content(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
override val marketChartUM: MarketChartUM,
val periodPickerUM: TangemSegmentedPickerUM,
val onPeriodClick: (TangemSegmentUM) -> Unit,
) : PortfolioReviewUM
}
/**
* State of the earn-opportunities section. [tokenList] holds either the user's earn-eligible holdings
* or top-earn suggestions, depending on which content state was picked (see
* `ForYouEarnOpportunitiesConverter` for the selection rules).
*/
@Immutable
internal sealed interface EarnOpportunitiesUM {
val tokenList: ImmutableList<ForYouTokenListItemUM>
/** Skeleton rows shown until the first real emission. */
data class Loading(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
) : EarnOpportunitiesUM
/**
* @property subtitleRes section subtitle matching the picked state (nothing eligible / all active /
* potential rewards)
* @property potentialReward header value: the total projected yearly reward for eligible holdings,
* or the best suggestion's rate when the user holds nothing eligible; `null` when not applicable
* @property potentialRewardType label of [potentialReward]'s rate kind (APR/APY); only set alongside
* a rate-based reward
*/
data class Content(
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
@param:StringRes val subtitleRes: Int,

View file

@ -2,25 +2,39 @@ package com.tangem.features.foryou.impl.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.right
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
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.EarnTokensBatchingContext
import com.tangem.domain.earn.model.EarnTokensListConfig
import com.tangem.domain.earn.usecase.GetEarnTokensBatchFlowUseCase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.EarnTopToken
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.ForYouTokenListItemUM
import com.tangem.features.foryou.impl.entity.ForYouUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.features.foryou.impl.entity.*
import com.tangem.features.foryou.impl.model.converter.TOP_EARN_TOKENS_BATCH_SIZE
import com.tangem.features.foryou.impl.model.converter.earnOpportunities.ForYouEarnOpportunitiesConverter
import com.tangem.features.foryou.impl.model.converter.portfolioReview.ForYouPortfolioReviewConverter
import com.tangem.features.foryou.impl.model.transformer.SetPortfolioReviewTransformer
import com.tangem.pagination.BatchAction
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.combine6
import com.tangem.utils.transformer.update
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@ -29,26 +43,34 @@ import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class ForYouModel @Inject constructor(
paramsContainer: ParamsContainer,
userWalletsListRepository: UserWalletsListRepository,
multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
override val dispatchers: CoroutineDispatcherProvider,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getEarnTokensBatchFlowUseCase: GetEarnTokensBatchFlowUseCase,
private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val earnErrorResolver: EarnErrorResolver,
) : Model() {
private val params = paramsContainer.require<ForYouComponent.Params>()
private val expandedAssetIds = MutableStateFlow<Set<String>>(value = emptySet())
private val expandedPortfolioReviewAssetIds = MutableStateFlow<Set<String>>(value = emptySet())
private val expandedEarnOpportunitiesAssetIds = MutableStateFlow<Set<String>>(value = emptySet())
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
val uiState: StateFlow<ForYouUM>
field = MutableStateFlow<ForYouUM>(
ForYouUM(
notifications = persistentListOf(),
periodPickerUM = TangemSegmentedPickerUM(persistentListOf()),
earnOpportunities = EarnOpportunitiesUM.Loading(
tokenList = buildList<ForYouTokenListItemUM> {
repeat(4) { index ->
repeat(5) { index ->
add(
ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Loading(
@ -62,6 +84,7 @@ internal class ForYouModel @Inject constructor(
}
}.toPersistentList(),
),
onPeriodClick = ::onPeriodClick,
portfolioReviewUM = PortfolioReviewUM.Loading(
marketChartUM = MarketChartUM.NoData,
tokenList = buildList<ForYouTokenListItemUM> {
@ -83,21 +106,51 @@ internal class ForYouModel @Inject constructor(
)
init {
combine(
flow = userWalletsListRepository.selectedUserWallet,
combine6(
flow1 = userWalletsListRepository.selectedUserWallet,
flow2 = multiAccountStatusListSupplier.invokeAsMap(),
flow3 = expandedAssetIds,
) { globalSelectedWallet, accountStatusList, expanded ->
flow3 = expandedPortfolioReviewAssetIds,
flow4 = expandedEarnOpportunitiesAssetIds,
flow5 = yieldSupplyApyFlowUseCase(),
flow6 = createTopEarnTokensFlow(),
) {
globalSelectedWallet, accountList,
expandedPortfolioReview, expandedEarnOpportunities,
yieldAvailability, topEarnTokens,
->
val stakingAvailability = accountList.flatMap { (userWalletId, accountStatusList) ->
stakingAvailabilityListUseCase.invokeSync(
userWalletId = userWalletId,
cryptoCurrencyList = accountStatusList.flattenCurrencies().map { it.currency },
).entries
}.associate { it.key to it.value }
// TODO For You add choose portfolio flow
val selectedWalletId = globalSelectedWallet?.walletId
val accountStatusList = accountList[globalSelectedWallet?.walletId]
val portfolioReviewUM = ForYouPortfolioReviewConverter(
appCurrency = selectedAppCurrencyFlow.value,
expandedAssetIds = expandedPortfolioReview,
expandClick = ::onExpandPortfolioReviewClick,
onTokenClick = ::onPortfolioReviewTokenClick,
).convert(accountStatusList)
val earnOpportunitiesUM = ForYouEarnOpportunitiesConverter(
appCurrency = selectedAppCurrencyFlow.value,
isAccountsModeEnabled = isAccountsModeEnabledUseCase.invokeSync(),
yieldSupplyAvailability = yieldAvailability,
yieldStakingAvailability = stakingAvailability,
topEarnTokens = topEarnTokens,
expandedAssetIds = expandedEarnOpportunities,
expandClick = ::onExpandEarnOpportunitiesClick,
).convert(accountStatusList)
uiState.update(
SetPortfolioReviewTransformer(
accountStatusList = accountStatusList[selectedWalletId],
appCurrency = selectedAppCurrencyFlow.value,
expandedAssetIds = expanded,
expandClick = ::onExpandClick,
onPeriodClick = ::onPeriodClick,
onTokenClick = { currency -> onTokenClick(selectedWalletId, currency) },
accountStatusList = accountStatusList,
portfolioReviewUM = portfolioReviewUM,
earnOpportunitiesUM = earnOpportunitiesUM,
),
)
}
@ -105,6 +158,40 @@ internal class ForYouModel @Inject constructor(
.launchIn(modelScope)
}
/**
* Fetches the top-earn suggestions as a single batch of [TOP_EARN_TOKENS_BATCH_SIZE] tokens (a
* one-shot [BatchAction.Reload]; no further paging). Emits `null` while the initial load is in
* flight, a resolved error on failure, and the flattened token list on success so the earn
* section can distinguish "not loaded yet" from "loaded empty".
*/
private fun createTopEarnTokensFlow(): Flow<EarnTopToken?> {
val actionsFlow = MutableSharedFlow<BatchAction<Int, EarnTokensListConfig, Nothing>>(replay = 1)
val batchFlow = getEarnTokensBatchFlowUseCase(
context = EarnTokensBatchingContext(
actionsFlow = actionsFlow,
coroutineScope = modelScope,
),
batchSize = TOP_EARN_TOKENS_BATCH_SIZE,
)
actionsFlow.tryEmit(
BatchAction.Reload(
requestParams = EarnTokensListConfig(type = null, networks = null, isForEarn = false),
),
)
return batchFlow.state.map { state ->
when (val status = state.status) {
is PaginationStatus.None,
is PaginationStatus.InitialLoading,
-> null
is PaginationStatus.InitialLoadingError -> earnErrorResolver.resolve(status.throwable).left()
else -> state.data.flatMap { batch -> batch.data }.right()
}
}
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
@ -115,13 +202,19 @@ internal class ForYouModel @Inject constructor(
)
}
private fun onTokenClick(selectedWalletId: UserWalletId?, currency: CryptoCurrency) {
private fun onPortfolioReviewTokenClick(selectedWalletId: UserWalletId?, currency: CryptoCurrency) {
val walletId = selectedWalletId ?: return
params.callbacks.onTokenClick(walletId, currency)
}
private fun onExpandClick(assetId: String) {
expandedAssetIds.update { ids ->
private fun onExpandPortfolioReviewClick(assetId: String) {
expandedPortfolioReviewAssetIds.update { ids ->
if (assetId in ids) ids - assetId else ids + assetId
}
}
private fun onExpandEarnOpportunitiesClick(assetId: String) {
expandedEarnOpportunitiesAssetIds.update { ids ->
if (assetId in ids) ids - assetId else ids + assetId
}
}
@ -129,11 +222,9 @@ internal class ForYouModel @Inject constructor(
private fun onPeriodClick(tangemSegmentUM: TangemSegmentUM) {
uiState.update { state ->
state.copy(
portfolioReviewUM = (state.portfolioReviewUM as? PortfolioReviewUM.Content)?.copy(
periodPickerUM = state.portfolioReviewUM.periodPickerUM.copy(
initialSelectedItem = tangemSegmentUM,
),
) ?: state.portfolioReviewUM,
periodPickerUM = state.periodPickerUM.copy(
initialSelectedItem = tangemSegmentUM,
),
)
}
}

View file

@ -15,7 +15,11 @@ import java.math.RoundingMode
/** Number of suggested top-earn tokens shown in the earn-opportunities block. */
internal const val FOR_YOU_TOP_EARN_TOKENS_COUNT = 5
/** Batch size for getting top earn tokens in single and only page */
/**
* Batch size for the top-earn-tokens request. Only the first batch is ever fetched (the section shows
* at most [FOR_YOU_TOP_EARN_TOKENS_COUNT] rows), but it is requested larger so that filtering out
* already-active tokens still leaves enough candidates to fill the list.
*/
internal const val TOP_EARN_TOKENS_BATCH_SIZE = 30
/** Divisor converting backend percent values (5.5) to fractions (0.055). */
@ -68,11 +72,11 @@ internal data class EarnApyInfo(
/**
* Earn-eligible currencies of one account with their resolved rates.
*
* @property accountPotentialReward sum of [EarnApyInfo.potentialRewards] over [earnCurrencues];
* @property accountPotentialReward sum of [EarnApyInfo.potentialRewards] over [earnCurrencies];
* accounts are ordered by it, descending
*/
internal data class EarnOpportunities(
val account: Account.CryptoPortfolio,
val earnCurrencues: Map<CryptoCurrencyStatus, EarnApyInfo>,
val earnCurrencies: Map<CryptoCurrencyStatus, EarnApyInfo>,
val accountPotentialReward: BigDecimal,
)

View file

@ -75,7 +75,7 @@ internal class ForYouEarnOpportunitiesConverter(
EarnOpportunities(
account = cryptoAccountStatus.account,
earnCurrencues = tokenList.toMap(),
earnCurrencies = tokenList.toMap(),
accountPotentialReward = accountPotentialReward,
)
}
@ -84,10 +84,14 @@ internal class ForYouEarnOpportunitiesConverter(
return when {
data.isEmpty() -> {
ForYouEarnOpportunitiesNoTokensConverter(topEarnTokens).convert(data)
ForYouEarnOpportunitiesNoTokensConverter(
topEarnTokens = topEarnTokens,
).convert(data)
}
data.all { earn -> earn.earnCurrencues.all { entry -> entry.value.isActive } } -> {
ForYouEarnOpportunitiesTokensActiveConverter(topEarnTokens).convert(data)
data.all { earn -> earn.earnCurrencies.all { entry -> entry.value.isActive } } -> {
ForYouEarnOpportunitiesTokensActiveConverter(
topEarnTokens = topEarnTokens,
).convert(data)
}
else -> {
ForYouEarnOpportunitiesPotentialRewardsConverter(
@ -134,7 +138,7 @@ internal class ForYouEarnOpportunitiesConverter(
currencyStatus = cryptoCurrencyStatus,
stakingApyMap = stakingApyMap,
)
if (stakingInfo.rate != null) {
if (stakingInfo != null) {
return EarnApyInfo(
isActive = stakingInfo.isActive,
apy = stakingInfo.rate,
@ -154,10 +158,9 @@ internal class ForYouEarnOpportunitiesConverter(
private fun findStakingRate(
currencyStatus: CryptoCurrencyStatus,
stakingApyMap: Map<CryptoCurrency, StakingAvailability>,
): StakingLocalInfo {
): StakingLocalInfo? {
val availability = stakingApyMap[currencyStatus.currency]
val option = availability?.optionOrNull
?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null)
val option = availability?.optionOrNull ?: return null
val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data
val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit
@ -166,7 +169,7 @@ internal class ForYouEarnOpportunitiesConverter(
// Full = no free capacity: show the badge only for tokens that already have a stake.
if (availability is StakingAvailability.Full && !isActive) {
return StakingLocalInfo(rate = null, isActive = false, rewardType = null)
return null
}
val rateInfo = when (option) {
@ -196,18 +199,18 @@ internal class ForYouEarnOpportunitiesConverter(
}
.maxByOrNull { it.rate }
}
}
} ?: return null
return StakingLocalInfo(
rate = rateInfo?.rate,
rate = rateInfo.rate,
isActive = isActive,
rewardType = rateInfo?.type,
rewardType = rateInfo.type,
)
}
private data class StakingLocalInfo(
val rate: BigDecimal?,
val rate: BigDecimal,
val isActive: Boolean,
val rewardType: RewardType?,
val rewardType: RewardType,
)
}

View file

@ -20,6 +20,15 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
/**
* Earn-opportunities state for a portfolio with tokens that could earn but don't yet: renders the
* user's earn-eligible holdings with their projected yearly rewards, headed by the total across
* accounts ([EarnOpportunitiesUM.Content.potentialReward]).
*
* With accounts mode on, each account becomes one expandable row (children delegated to
* [ForYouEarnOpportunitiesTokenRowConverter], expansion keyed by account id); with it off, the
* tokens are rendered as flat non-expandable rows.
*/
internal class ForYouEarnOpportunitiesPotentialRewardsConverter(
private val appCurrency: AppCurrency,
private val isAccountsModeEnabled: Boolean,
@ -51,16 +60,16 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverter(
tokenRowUM = createAssetRow(
account = earnData.account,
potentialReward = earnData.accountPotentialReward,
tokenCount = earnData.earnCurrencues.size,
tokenCount = earnData.earnCurrencies.size,
),
tokenList = rowConverter.convertList(earnData.earnCurrencues.toList())
tokenList = rowConverter.convertList(earnData.earnCurrencies.toList())
.toPersistentList(),
isExpanded = earnData.account.accountId.value in expandedAssetIds,
isExpandable = true,
),
)
} else {
earnData.earnCurrencues.map { token ->
earnData.earnCurrencies.map { token ->
ForYouTokenListItemUM(
tokenRowUM = rowConverter.convert(token.toPair()),
tokenList = persistentListOf(),

View file

@ -22,6 +22,13 @@ import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
/**
* Maps one earn-eligible portfolio holding to a token row: network subtitle, projected yearly earn
* (`fiat balance * rate`) as the top end and the rate itself as the styled bottom end.
*
* Non-resolved statuses degrade the same way as in the portfolio review: loading skeleton row,
* no-quote / no-address / unreachable dashes, stale cache error-sync icon on both ends.
*/
internal class ForYouEarnOpportunitiesTokenRowConverter(
private val appCurrency: AppCurrency,
) : Converter<Pair<CryptoCurrencyStatus, EarnApyInfo>, TangemTokenRowUM> {

View file

@ -24,7 +24,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverter(
override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM {
val activeAssetKeys = value
.flatMap { opportunities -> opportunities.earnCurrencues.keys }
.flatMap { opportunities -> opportunities.earnCurrencies.keys }
.map { status -> status.currency.forYouEarnAssetKey() }
.toSet()

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.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
@ -8,14 +8,21 @@ import com.tangem.core.ui.extensions.*
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.account.models.AccountStatusList
import com.tangem.domain.appcurrency.model.AppCurrency
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.wallet.UserWalletId
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.features.foryou.impl.model.converter.forYouGroupKey
import com.tangem.features.foryou.impl.model.converter.forYouPlaceholderBadge
import com.tangem.features.foryou.impl.model.converter.toForYouPercent
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
@ -25,41 +32,78 @@ import java.math.BigDecimal
* (see [forYouGroupKey]) and maps each group to a [ForYouTokenListItemUM] an aggregate asset row plus,
* when the asset spans more than one network, its per-network child rows.
*
* The child rows are grouped by network (delegated to [ForYouTokenRowConverter]) so a network appears
* The child rows are grouped by network (delegated to [ForYouPortfolioReviewTokenRowConverter]) so a network appears
* once per asset even if the asset is held on it in several accounts;
*
* Modelled on `TokenListStateConverter` (a list converter delegating to a per-item converter).
*/
internal class ForYouTokenListConverter(
internal class ForYouPortfolioReviewConverter(
private val appCurrency: AppCurrency,
private val totalFiatBalance: BigDecimal,
private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit,
private val otherAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>,
private val onTokenClick: (CryptoCurrency) -> Unit,
) : Converter<List<CryptoCurrencyStatus>, ImmutableList<ForYouTokenListItemUM>> {
private val onTokenClick: (UserWalletId, CryptoCurrency) -> Unit,
) : Converter<AccountStatusList?, PortfolioReviewUM> {
private val iconConverter = CryptoCurrencyToIconStateConverter()
private val rowConverter = ForYouTokenRowConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
onTokenClick = onTokenClick,
)
override fun convert(value: List<CryptoCurrencyStatus>): ImmutableList<ForYouTokenListItemUM> {
val assetItems = value
override fun convert(value: AccountStatusList?): PortfolioReviewUM {
val currencies = value?.flattenCurrencies().orEmpty()
val loadedBalance = value?.totalFiatBalance as? TotalFiatBalance.Loaded
val totalFiatBalance = loadedBalance?.amount.orZero()
// Drop only assets we positively know are empty — a resolved, priced zero fiat balance. Currencies
// whose fiat we couldn't determine (unreachable / no-address / no-quote / still-loading — i.e. any
// non-content status, which all carry a null fiatAmount) are kept so the converter can still render
// them with the appropriate treatment instead of hiding a token the user actually holds.
// Then aggregate the rest into assets (the same token across networks shares its forYouGroupKey)
// and rank assets by their *summed* fiat balance.
val rankedAssets = currencies
.filterNot { it.value.fiatAmount?.isZero() == true }
.groupBy { it.forYouGroupKey() }
.map { (assetId, currencies) -> createListItem(assetId, currencies) }
.map { (_, networks) -> networks to networks.sumOf { it.value.fiatAmount.orZero() } }
.sortedByDescending { (_, assetBalance) -> assetBalance }
// The top assets are shown individually (each flattened back to its networks so the converter can
// regroup them by network); the remaining assets are collapsed into a single "Other" row.
val topAssets = rankedAssets.take(TOP_HOLDINGS_COUNT)
val otherAssets = rankedAssets.drop(TOP_HOLDINGS_COUNT)
val topCurrencies = topAssets.flatMap { (networks, _) -> networks }
val assetItems = topCurrencies
.groupBy { it.forYouGroupKey() }
.map { (assetId, currencies) ->
createListItem(
userWalletId = value?.userWalletId,
assetId = assetId,
currencies = currencies,
totalFiatBalance = totalFiatBalance,
)
}
// Assets beyond the top ones are collapsed into a single non-expandable "Other" row at the bottom.
return if (otherAssets.count() > 0) {
assetItems + createOtherItem()
val tokenList = if (otherAssets.count() > 0) {
assetItems + createOtherItem(otherAssets, totalFiatBalance)
} else {
assetItems
}.toPersistentList()
val marketChartUM = ForYouPortfolioReviewMarketChartConverter(
appCurrency = appCurrency,
topAssets = topAssets,
).convert(value?.totalFiatBalance)
return PortfolioReviewUM.Content(
tokenList = tokenList,
marketChartUM = marketChartUM,
)
}
private fun createListItem(assetId: String, currencies: List<CryptoCurrencyStatus>): ForYouTokenListItemUM {
private fun createListItem(
userWalletId: UserWalletId?,
assetId: String,
currencies: List<CryptoCurrencyStatus>,
totalFiatBalance: BigDecimal,
): ForYouTokenListItemUM {
// Group the asset's holdings by blockchain (network.id.rawId, derivation-independent) so each
// network appears once even when the asset is held across several accounts/derivations on it,
// summing those balances. Order by balance so the expanded breakdown reads top-down.
@ -67,11 +111,21 @@ internal class ForYouTokenListConverter(
.groupBy { it.currency.network.id.rawId }
.values
.sortedByDescending { group -> group.sumOf { it.value.fiatAmount.orZero() } }
val rowConverter = ForYouPortfolioReviewTokenRowConverter(
userWalletId = userWalletId,
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
onTokenClick = onTokenClick,
)
return ForYouTokenListItemUM(
tokenRowUM = createAssetRow(
userWalletId = userWalletId,
assetId = assetId,
currencies = currencies,
networkCount = networkGroups.size,
totalFiatBalance = totalFiatBalance,
),
tokenList = networkGroups.map(rowConverter::convertNetworkGroup).toPersistentList(),
isExpanded = assetId in expandedAssetIds,
@ -81,8 +135,10 @@ internal class ForYouTokenListConverter(
private fun createAssetRow(
assetId: String,
userWalletId: UserWalletId?,
currencies: List<CryptoCurrencyStatus>,
networkCount: Int,
totalFiatBalance: BigDecimal,
): TangemTokenRowUM {
if (currencies.all { it.value is CryptoCurrencyStatus.Loading }) {
return TangemTokenRowUM.Loading(id = assetId)
@ -91,6 +147,12 @@ internal class ForYouTokenListConverter(
val asset = currencies.first()
val assetFiatBalance = currencies.sumOf { it.value.fiatAmount.orZero() }
val rowConverter = ForYouPortfolioReviewTokenRowConverter(
userWalletId = userWalletId,
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
onTokenClick = onTokenClick,
)
val endContent = rowConverter.toEndContent(statuses = currencies, fiatAmount = assetFiatBalance)
val onlyCryptoCurrency = currencies.firstOrNull()?.currency
@ -120,7 +182,10 @@ internal class ForYouTokenListConverter(
)
}
private fun createOtherItem(): ForYouTokenListItemUM {
private fun createOtherItem(
otherAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>,
totalFiatBalance: BigDecimal,
): ForYouTokenListItemUM {
val otherAssetsBalance = otherAssets.sumOf { (_, assetBalance) -> assetBalance }
return ForYouTokenListItemUM(
tokenRowUM = TangemTokenRowUM.Content(
@ -158,5 +223,6 @@ internal class ForYouTokenListConverter(
private companion object {
const val OTHER_ROW_ID = "for_you_other_assets"
const val TOP_HOLDINGS_COUNT = 4
}
}

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.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
@ -7,13 +7,18 @@ import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.foryou.impl.components.state.*
import com.tangem.features.foryou.impl.components.state.AiInsightUM
import com.tangem.features.foryou.impl.components.state.DonutChartUM
import com.tangem.features.foryou.impl.components.state.DonutSegmentColor
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.model.converter.toForYouPercent
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
internal class ForYouMarketChartConverter(
internal class ForYouPortfolioReviewMarketChartConverter(
private val appCurrency: AppCurrency,
private val topAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>,
) : Converter<TotalFiatBalance?, MarketChartUM> {

View file

@ -1,4 +1,4 @@
package com.tangem.features.foryou.impl.model.converter
package com.tangem.features.foryou.impl.model.converter.portfolioReview
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
@ -16,6 +16,9 @@ import com.tangem.domain.appcurrency.model.AppCurrency
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.wallet.UserWalletId
import com.tangem.features.foryou.impl.model.converter.forYouPlaceholderBadge
import com.tangem.features.foryou.impl.model.converter.toForYouPercent
import com.tangem.utils.StringsSigns
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.persistentListOf
@ -39,10 +42,11 @@ import java.math.BigDecimal
* classification (they contribute nothing yet). The cache/flicker indicators derive from the most
* conservative [CryptoCurrencyStatus.Sources.total] across the contributing statuses.
*/
internal class ForYouTokenRowConverter(
internal class ForYouPortfolioReviewTokenRowConverter(
private val appCurrency: AppCurrency,
private val userWalletId: UserWalletId?,
private val totalFiatBalance: BigDecimal,
private val onTokenClick: (CryptoCurrency) -> Unit,
private val onTokenClick: (UserWalletId, CryptoCurrency) -> Unit,
) {
private val iconConverter = CryptoCurrencyToIconStateConverter()
@ -66,7 +70,7 @@ internal class ForYouTokenRowConverter(
subtitleUM = toRowSubtitle(state, currency, cryptoAmount),
topEndContentUM = toRowTopEnd(state, fiatAmount),
bottomEndContentUM = toRowBottomEnd(state, fiatAmount),
onItemClick = { onTokenClick(currency) },
onItemClick = { if (userWalletId != null) onTokenClick(userWalletId, currency) },
onItemLongClick = null,
)
}
@ -88,9 +92,9 @@ internal class ForYouTokenRowConverter(
)
}
/** Title: For You always shows the asset symbol with the placeholder price-change badge. */
/** Title: For You always shows the asset name with the placeholder price-change badge. */
private fun toRowTitle(currency: CryptoCurrency): TangemTokenRowUM.TitleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference(currency.symbol),
text = stringReference(currency.name),
badge = forYouPlaceholderBadge(),
)

View file

@ -4,94 +4,48 @@ 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.features.foryou.impl.entity.EarnOpportunitiesUM
import com.tangem.features.foryou.impl.entity.ForYouUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.features.foryou.impl.model.ForYouNotification
import com.tangem.features.foryou.impl.model.converter.ForYouMarketChartConverter
import com.tangem.features.foryou.impl.model.converter.ForYouTokenListConverter
import com.tangem.features.foryou.impl.model.converter.forYouGroupKey
import com.tangem.utils.extensions.isZero
import com.tangem.utils.extensions.orZero
import com.tangem.features.foryou.impl.model.converter.portfolioReview.ForYouPortfolioReviewConverter
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
/**
* Builds the [ForYouUM] state for the For You screen: the outdated-data notifications plus the portfolio
* review (market chart, period picker and the grouped token list).
* Applies one combined emission to the [ForYouUM] state: sets the pre-built portfolio-review and
* earn-opportunities sections (see [ForYouPortfolioReviewConverter] and
* `ForYouEarnOpportunitiesConverter`) and derives the outdated-data notification from
* [accountStatusList]'s total-balance source.
*
* The token list is delegated to [ForYouTokenListConverter] and the market chart to
* [ForYouMarketChartConverter]; the period picker selection is carried over from the previous state so
* it is not reset on every balance refresh.
* subsequent refreshes the previous picker is carried over so the user's selection is not reset.
*
* Modelled on `SetTokenListTransformer` (a transformer that rebuilds the state while delegating the
* token-list construction to a dedicated converter).
* section construction to dedicated converters).
*/
@Suppress("LongParameterList")
internal class SetPortfolioReviewTransformer(
private val accountStatusList: AccountStatusList?,
private val appCurrency: AppCurrency,
private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit,
private val onPeriodClick: (TangemSegmentUM) -> Unit,
private val onTokenClick: (CryptoCurrency) -> Unit,
private val portfolioReviewUM: PortfolioReviewUM,
private val earnOpportunitiesUM: EarnOpportunitiesUM,
) : Transformer<ForYouUM> {
override fun transform(prevState: ForYouUM): ForYouUM {
val currencies = accountStatusList?.flattenCurrencies().orEmpty()
val loadedBalance = accountStatusList?.totalFiatBalance as? TotalFiatBalance.Loaded
val totalFiatBalance = loadedBalance?.amount.orZero()
// Drop only assets we positively know are empty — a resolved, priced zero fiat balance. Currencies
// whose fiat we couldn't determine (unreachable / no-address / no-quote / still-loading — i.e. any
// non-content status, which all carry a null fiatAmount) are kept so the converter can still render
// them with the appropriate treatment instead of hiding a token the user actually holds.
// Then aggregate the rest into assets (the same token across networks shares its forYouGroupKey)
// and rank assets by their *summed* fiat balance.
val rankedAssets = currencies
.filterNot { it.value.fiatAmount?.isZero() == true }
.groupBy { it.forYouGroupKey() }
.map { (_, networks) -> networks to networks.sumOf { it.value.fiatAmount.orZero() } }
.sortedByDescending { (_, assetBalance) -> assetBalance }
// The top assets are shown individually (each flattened back to its networks so the converter can
// regroup them by network); the remaining assets are collapsed into a single "Other" row.
val topAssets = rankedAssets.take(TOP_HOLDINGS_COUNT)
val otherAssets = rankedAssets.drop(TOP_HOLDINGS_COUNT)
val topCurrencies = topAssets.flatMap { (networks, _) -> networks }
val tokenList = ForYouTokenListConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
expandedAssetIds = expandedAssetIds,
expandClick = expandClick,
otherAssets = otherAssets,
onTokenClick = onTokenClick,
).convert(topCurrencies)
val marketChartUM = ForYouMarketChartConverter(
appCurrency = appCurrency,
topAssets = topAssets,
).convert(accountStatusList?.totalFiatBalance)
return prevState.copy(
notifications = if (loadedBalance?.source == StatusSource.ONLY_CACHE) {
persistentListOf(ForYouNotification.UsedOutdatedData)
} else {
persistentListOf()
},
portfolioReviewUM = PortfolioReviewUM.Content(
periodPickerUM = when (prevState.portfolioReviewUM) {
is PortfolioReviewUM.Content -> prevState.portfolioReviewUM.periodPickerUM
is PortfolioReviewUM.Loading -> createPeriodPicker()
},
tokenList = tokenList,
marketChartUM = marketChartUM,
onPeriodClick = onPeriodClick,
),
earnOpportunities = earnOpportunitiesUM,
portfolioReviewUM = portfolioReviewUM,
periodPickerUM = when (prevState.portfolioReviewUM) {
is PortfolioReviewUM.Loading -> createPeriodPicker()
is PortfolioReviewUM.Content -> prevState.periodPickerUM
},
)
}
@ -109,8 +63,4 @@ internal class SetPortfolioReviewTransformer(
isAltSurface = true,
)
}
private companion object {
const val TOP_HOLDINGS_COUNT = 4
}
}

View file

@ -19,8 +19,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -78,6 +81,15 @@ internal fun ForYouContent(
ForYouPortfolioReview(
portfolioReviewUM = forYouUM.portfolioReviewUM,
periodPickerUM = forYouUM.periodPickerUM,
onPeriodClick = forYouUM.onPeriodClick,
modifier = Modifier.padding(horizontal = 16.dp),
)
SpacerH(48.dp)
ForYouEarnOpportunities(
earnOpportunitiesUM = forYouUM.earnOpportunities,
modifier = Modifier.padding(horizontal = 16.dp),
)
@ -114,6 +126,17 @@ private class ForYouContentPreviewProvider : PreviewParameterProvider<ForYouUM>
notifications = persistentListOf(ForYouNotification.UsedOutdatedData),
earnOpportunities = ForYouEarnOpportunitiesPreviewData.tokensRewards,
portfolioReviewUM = ForYouPortfolioReviewPreviewData.reviewContent,
periodPickerUM = TangemSegmentedPickerUM(
items = persistentListOf(
TangemSegmentUM(id = "0", title = stringReference("Day")),
TangemSegmentUM(id = "1", title = stringReference("Week")),
TangemSegmentUM(id = "2", title = stringReference("Month")),
),
initialSelectedItem = TangemSegmentUM(id = "0", title = stringReference("Day")),
isFixed = true,
isAltSurface = true,
),
onPeriodClick = {},
),
)
}

View file

@ -14,7 +14,9 @@ import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
import com.tangem.core.ui.ds2.badge.TangemBadge
import com.tangem.core.ui.ds2.shimmers.TangemShimmer
import com.tangem.core.ui.extensions.stringReference
@ -34,7 +36,12 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Composable
internal fun ForYouPortfolioReview(portfolioReviewUM: PortfolioReviewUM, modifier: Modifier = Modifier) {
internal fun ForYouPortfolioReview(
periodPickerUM: TangemSegmentedPickerUM,
onPeriodClick: (TangemSegmentUM) -> Unit,
portfolioReviewUM: PortfolioReviewUM,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
@ -64,8 +71,8 @@ internal fun ForYouPortfolioReview(portfolioReviewUM: PortfolioReviewUM, modifie
when (portfolioReviewUM) {
is PortfolioReviewUM.Content -> {
TangemSegmentedPicker(
tangemSegmentedPickerUM = portfolioReviewUM.periodPickerUM,
onClick = portfolioReviewUM.onPeriodClick,
tangemSegmentedPickerUM = periodPickerUM,
onClick = onPeriodClick,
)
}
is PortfolioReviewUM.Loading -> TangemShimmer(
@ -91,6 +98,17 @@ private fun ForYouPortfolioReview_Review(
ForYouPortfolioReview(
portfolioReviewUM = params,
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
periodPickerUM = TangemSegmentedPickerUM(
items = persistentListOf(
TangemSegmentUM(id = "0", title = stringReference("Day")),
TangemSegmentUM(id = "1", title = stringReference("Week")),
TangemSegmentUM(id = "2", title = stringReference("Month")),
),
initialSelectedItem = TangemSegmentUM(id = "0", title = stringReference("Day")),
isFixed = true,
isAltSurface = true,
),
onPeriodClick = {},
)
}
}

View file

@ -7,8 +7,6 @@ import com.tangem.core.ui.ds.badge.TangemBadgeType
import com.tangem.core.ui.ds.badge.TangemBadgeUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
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.features.foryou.impl.components.state.DonutChartUM
import com.tangem.features.foryou.impl.components.state.DonutSegmentColor
@ -23,17 +21,6 @@ import java.math.BigDecimal
internal object ForYouPortfolioReviewPreviewData {
val reviewContent = PortfolioReviewUM.Content(
periodPickerUM = TangemSegmentedPickerUM(
items = persistentListOf(
TangemSegmentUM(id = "0", title = stringReference("Day")),
TangemSegmentUM(id = "1", title = stringReference("Week")),
TangemSegmentUM(id = "2", title = stringReference("Month")),
),
initialSelectedItem = TangemSegmentUM(id = "0", title = stringReference("Day")),
isFixed = true,
isAltSurface = true,
),
onPeriodClick = {},
marketChartUM = MarketChartUM.Loaded(
donutChart = DonutChartUM.Loaded(
totalAmount = "10000$",

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