Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-15 11:16:38 +05:00
parent eec91a9b4d
commit 7e51309355
11 changed files with 1040 additions and 2 deletions

View file

@ -70,4 +70,5 @@ dependencies {
/** Tests */
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testImplementation(projects.test.mock)
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.foryou.impl.model.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Nested
@ -54,6 +55,62 @@ internal class ForYouPortfolioFormattersTest {
)
}
@Nested
inner class ForYouEarnAssetKey {
@Test
fun `GIVEN currency with raw id WHEN forYouEarnAssetKey THEN key is raw id to network raw id`() {
// Arrange
val currency = createCurrency(rawCurrencyId = "usd-coin", currencyId = "token-usdc", networkRawId = "ETH")
// Act
val result = currency.forYouEarnAssetKey()
// Assert
assertThat(result).isEqualTo("usd-coin" to "ETH")
}
@Test
fun `GIVEN custom token with no raw id WHEN forYouEarnAssetKey THEN falls back to currency id value`() {
// Arrange
val currency = createCurrency(rawCurrencyId = null, currencyId = "custom-token-id", networkRawId = "ETH")
// Act
val result = currency.forYouEarnAssetKey()
// Assert
assertThat(result).isEqualTo("custom-token-id" to "ETH")
}
@Test
fun `GIVEN same asset on different networks WHEN forYouEarnAssetKey THEN keys differ`() {
// Arrange
val onEthereum = createCurrency(rawCurrencyId = "usd-coin", currencyId = "usdc-eth", networkRawId = "ETH")
val onSolana = createCurrency(rawCurrencyId = "usd-coin", currencyId = "usdc-sol", networkRawId = "SOL")
// Act & Assert
assertThat(onEthereum.forYouEarnAssetKey()).isNotEqualTo(onSolana.forYouEarnAssetKey())
}
private fun createCurrency(
rawCurrencyId: String?,
currencyId: String,
networkRawId: String,
): CryptoCurrency {
val id: CryptoCurrency.ID = mockk {
every { this@mockk.rawCurrencyId } returns rawCurrencyId?.let { CryptoCurrency.RawID(it) }
every { value } returns currencyId
}
val network: Network = mockk {
every { rawId } returns networkRawId
}
return mockk {
every { this@mockk.id } returns id
every { this@mockk.network } returns network
}
}
}
@Nested
inner class ToForYouPercent {

View file

@ -5,6 +5,7 @@ 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
@ -149,7 +150,9 @@ internal class ForYouTokenListConverterTest {
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))
assertThat(subtitle.text).isEqualTo(
pluralReference(R.plurals.market_chart_assets_android, count = 1, formatArgs = wrappedList(1)),
)
}
@Test
@ -172,7 +175,9 @@ internal class ForYouTokenListConverterTest {
// 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))
assertThat(subtitle.text).isEqualTo(
pluralReference(R.plurals.market_chart_assets_android, count = 3, formatArgs = wrappedList(3)),
)
}
@Test

View file

@ -0,0 +1,194 @@
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.Account
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.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.staking.StakingBalance
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.features.foryou.impl.model.converter.EarnApyInfo
import com.tangem.features.foryou.impl.model.converter.EarnOpportunities
import com.tangem.test.mock.MockAccounts
import io.mockk.every
import io.mockk.mockk
import java.math.BigDecimal
/**
* Factories for the earn-opportunities converter tests. Every argument is defaulted so a test
* overrides only the fields it asserts on.
*/
internal fun createEarnCurrency(
tokenId: String? = "ethereum",
currencyId: String = "coin-ethereum",
name: String = "Ethereum",
networkRawId: String = "ETH",
networkName: String = "Ethereum",
): CryptoCurrency {
val networkId: Network.ID = mockk {
every { rawId } returns Network.RawID(networkRawId)
}
val network: Network = mockk {
every { this@mockk.name } returns networkName
every { isTestnet } returns false
every { rawId } returns networkRawId
every { this@mockk.id } returns networkId
}
val id: CryptoCurrency.ID = mockk {
every { rawCurrencyId } returns tokenId?.let { CryptoCurrency.RawID(it) }
every { value } returns currencyId
}
return mockk<CryptoCurrency.Coin> {
every { this@mockk.id } returns id
every { this@mockk.name } returns name
every { this@mockk.network } returns network
every { isCustom } returns false
every { iconUrl } returns null
}
}
/** A token currency whose `yieldSupplyKey()` is `"<networkRawId>_<contractAddress>"`. */
internal fun createEarnTokenCurrency(
contractAddress: String = "0xabc",
tokenId: String? = "usd-coin",
currencyId: String = "token-usdc",
name: String = "USD Coin",
networkRawId: String = "ETH",
networkName: String = "Ethereum",
): CryptoCurrency.Token {
val networkId: Network.ID = mockk {
every { rawId } returns Network.RawID(networkRawId)
}
val network: Network = mockk {
every { this@mockk.name } returns networkName
every { isTestnet } returns false
every { rawId } returns networkRawId
every { this@mockk.id } returns networkId
}
val id: CryptoCurrency.ID = mockk {
every { rawCurrencyId } returns tokenId?.let { CryptoCurrency.RawID(it) }
every { value } returns currencyId
}
return mockk {
every { this@mockk.id } returns id
every { this@mockk.name } returns name
every { this@mockk.network } returns network
every { this@mockk.contractAddress } returns contractAddress
every { isCustom } returns false
every { iconUrl } returns null
}
}
internal fun createEarnToken(
apy: String = "5.5",
networkId: String = "ethereum",
rewardType: EarnRewardType = EarnRewardType.APY,
type: EarnType = EarnType.STAKING,
tokenId: String = "ethereum",
tokenSymbol: String = "ETH",
tokenName: String = "Ethereum",
tokenAddress: String? = null,
decimalCount: Int? = null,
): EarnToken = EarnToken(
apy = apy,
networkId = networkId,
rewardType = rewardType,
type = type,
tokenId = tokenId,
tokenSymbol = tokenSymbol,
tokenName = tokenName,
tokenAddress = tokenAddress,
decimalCount = decimalCount,
)
/** A top-earn suggestion whose row id becomes `"<tokenId>-<networkRawId>"`. */
internal fun createTopEarnToken(
tokenId: String = "ethereum",
networkRawId: String = "ETH",
networkName: String = "Ethereum",
name: String = "Ethereum",
apy: String = "5.5",
type: EarnType = EarnType.STAKING,
rewardType: EarnRewardType = EarnRewardType.APY,
): EarnTokenWithCurrency = EarnTokenWithCurrency(
networkName = networkName,
earnToken = createEarnToken(apy = apy, tokenId = tokenId, type = type, rewardType = rewardType),
cryptoCurrency = createEarnCurrency(
tokenId = tokenId,
currencyId = "$tokenId-$networkRawId",
name = name,
networkRawId = networkRawId,
networkName = networkName,
),
)
/** A fully resolved status value suitable for rendering rows (fiat amount, sources, no error). */
internal fun createRowLoadedValue(
fiatAmount: BigDecimal = BigDecimal("100"),
source: StatusSource = StatusSource.ACTUAL,
): CryptoCurrencyStatus.Loaded = mockk {
every { this@mockk.fiatAmount } returns fiatAmount
every { isError } returns false
every { sources } returns CryptoCurrencyStatus.Sources(
networkSource = source,
quoteSource = source,
stakingBalanceSource = source,
)
}
/** A status value carrying the earn-related fields read by `ForYouEarnOpportunitiesConverter`. */
internal fun createEarnStatusValue(
fiatAmount: BigDecimal = BigDecimal("100"),
yieldSupplyActive: Boolean? = null,
isStakingActive: Boolean = false,
): 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 { isError } returns false
every { sources } returns CryptoCurrencyStatus.Sources()
}
internal fun createStatus(
currency: CryptoCurrency,
value: CryptoCurrencyStatus.Value = CryptoCurrencyStatus.Loading,
): CryptoCurrencyStatus = CryptoCurrencyStatus(currency = currency, value = value)
internal fun createEarnOpportunities(
account: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 1),
earnCurrencues: Map<CryptoCurrencyStatus, EarnApyInfo> = mapOf(
createStatus(createEarnCurrency()) to createEarnApyInfo(),
),
accountPotentialReward: BigDecimal = BigDecimal.ZERO,
): EarnOpportunities = EarnOpportunities(
account = account,
earnCurrencues = earnCurrencues,
accountPotentialReward = accountPotentialReward,
)
internal fun createEarnApyInfo(
isActive: Boolean = true,
apy: BigDecimal? = BigDecimal("0.05"),
potentialRewards: BigDecimal? = null,
): EarnApyInfo = EarnApyInfo(isActive = isActive, apy = apy, potentialRewards = potentialRewards)
internal fun createPortfolioStatus(
currencies: List<CryptoCurrencyStatus>,
account: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 1),
): AccountStatus.CryptoPortfolio = mockk {
every { flattenCurrencies() } returns currencies
every { this@mockk.account } returns account
}
internal fun createAccountStatusList(vararg statuses: AccountStatus): AccountStatusList = mockk {
every { accountStatuses } returns statuses.toList()
}

View file

@ -0,0 +1,245 @@
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.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.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.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingOption
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.math.RoundingMode
internal class ForYouEarnOpportunitiesConverterTest {
private val appCurrency: AppCurrency = AppCurrency.Default
@Nested
inner class StateSelection {
@Test
fun `GIVEN null account status list WHEN convert THEN no-tokens state`() {
// Arrange
val converter = createConverter()
// Act
val result = converter.convert(null) as EarnOpportunitiesUM.Content
// Assert
assertThat(result.subtitleRes).isEqualTo(R.string.for_you_earn_opportunities_no_available_tokens)
}
@Test
fun `GIVEN currencies without any earn option WHEN convert THEN no-tokens state`() {
// Arrange — no yield map entries and no staking availability → nothing is earn-eligible
val status = createStatus(createEarnCurrency(), createEarnStatusValue())
val converter = createConverter()
// Act
val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
// Assert
assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
.isEqualTo(R.string.for_you_earn_opportunities_no_available_tokens)
}
@Test
fun `GIVEN every earn-eligible token already staked WHEN convert THEN all-active state`() {
// Arrange
val currency = createEarnCurrency()
val status = createStatus(currency, createEarnStatusValue(isStakingActive = true))
val converter = createConverter(
yieldStakingAvailability = mapOf(currency to stakingAvailable(apy = BigDecimal("0.05"))),
)
// Act
val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
// Assert
assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
.isEqualTo(R.string.for_you_earn_opportunities_all_tokens_active)
}
@Test
fun `GIVEN earn-eligible token not yet earning WHEN convert THEN potential-rewards state`() {
// Arrange
val currency = createEarnCurrency()
val status = createStatus(currency, createEarnStatusValue(fiatAmount = BigDecimal("100")))
val converter = createConverter(
yieldStakingAvailability = mapOf(currency to stakingAvailable(apy = BigDecimal("0.05"))),
)
// Act
val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
// Assert
assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
.isEqualTo(R.string.for_you_earn_opportunities_tokens_rewards)
}
}
@Nested
inner class EarnEligibility {
@Test
fun `GIVEN zero balance and inactive earn WHEN convert THEN token is not eligible`() {
// Arrange — nothing to earn on: no balance and not already earning
val currency = createEarnCurrency()
val status = createStatus(currency, createEarnStatusValue(fiatAmount = BigDecimal.ZERO))
val converter = createConverter(
yieldStakingAvailability = mapOf(currency to stakingAvailable(apy = BigDecimal("0.05"))),
)
// Act
val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
// Assert
assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
.isEqualTo(R.string.for_you_earn_opportunities_no_available_tokens)
}
@Test
fun `GIVEN zero balance but active stake WHEN convert THEN token stays visible as active`() {
// Arrange
val currency = createEarnCurrency()
val status = createStatus(
currency,
createEarnStatusValue(fiatAmount = BigDecimal.ZERO, isStakingActive = true),
)
val converter = createConverter(
yieldStakingAvailability = mapOf(currency to stakingAvailable(apy = BigDecimal("0.05"))),
)
// Act
val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
// Assert
assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
.isEqualTo(R.string.for_you_earn_opportunities_all_tokens_active)
}
@Test
fun `GIVEN full staking pool without existing stake WHEN convert THEN token is not eligible`() {
// Arrange — Full = no free capacity: new stakes are not offered
val currency = createEarnCurrency()
val status = createStatus(currency, createEarnStatusValue(fiatAmount = BigDecimal("100")))
val converter = createConverter(
yieldStakingAvailability = mapOf(
currency to StakingAvailability.Full(option = stakingOption(apy = BigDecimal("0.05"))),
),
)
// Act
val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
// Assert
assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
.isEqualTo(R.string.for_you_earn_opportunities_no_available_tokens)
}
@Test
fun `GIVEN full staking pool with existing stake WHEN convert THEN token stays visible as active`() {
// Arrange
val currency = createEarnCurrency()
val status = createStatus(
currency,
createEarnStatusValue(fiatAmount = BigDecimal("100"), isStakingActive = true),
)
val converter = createConverter(
yieldStakingAvailability = mapOf(
currency to StakingAvailability.Full(option = stakingOption(apy = BigDecimal("0.05"))),
),
)
// Act
val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
// Assert
assertThat((result as EarnOpportunitiesUM.Content).subtitleRes)
.isEqualTo(R.string.for_you_earn_opportunities_all_tokens_active)
}
}
@Nested
inner class ApyResolution {
@Test
fun `GIVEN token eligible for both yield and staking WHEN convert THEN yield rate wins`() {
// Arrange — yield 10.00% vs staking 50%: the reward must be computed from the yield rate
val token = createEarnTokenCurrency()
val status = createStatus(token, createEarnStatusValue(fiatAmount = BigDecimal("100")))
val converter = createConverter(
yieldSupplyAvailability = mapOf(token.yieldSupplyKey() to BigDecimal("10.00")),
yieldStakingAvailability = mapOf<CryptoCurrency, StakingAvailability>(
token to stakingAvailable(apy = BigDecimal("0.50")),
),
)
// Act
val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
// Assert — 100 * (10.00 / 100) = 10.00 per year, not 50
assertThat((result as EarnOpportunitiesUM.Content).potentialReward)
.isEqualTo(expectedPerYearReward(fiat = BigDecimal("100"), yieldPercent = BigDecimal("10.00")))
}
@Test
fun `GIVEN staking-only token WHEN convert THEN staking rate is used for the reward`() {
// Arrange
val currency = createEarnCurrency()
val status = createStatus(currency, createEarnStatusValue(fiatAmount = BigDecimal("200")))
val converter = createConverter(
yieldStakingAvailability = mapOf(currency to stakingAvailable(apy = BigDecimal("0.04"))),
)
// Act
val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status))))
// Assert — 200 * 0.04 = 8 per year
val expectedTotal = BigDecimal("200").multiply(BigDecimal("0.04"))
assertThat((result as EarnOpportunitiesUM.Content).potentialReward)
.isEqualTo(expectedTotal.expectedPerYearText())
}
}
private fun createConverter(
yieldSupplyAvailability: Map<String, BigDecimal> = emptyMap(),
yieldStakingAvailability: Map<CryptoCurrency, StakingAvailability> = emptyMap(),
topEarnTokens: EarnTopToken? = null,
isAccountsModeEnabled: Boolean = false,
) = ForYouEarnOpportunitiesConverter(
appCurrency = appCurrency,
isAccountsModeEnabled = isAccountsModeEnabled,
expandedAssetIds = emptySet(),
expandClick = {},
yieldSupplyAvailability = yieldSupplyAvailability,
yieldStakingAvailability = yieldStakingAvailability,
topEarnTokens = topEarnTokens,
)
private fun stakingOption(apy: BigDecimal): StakingOption.P2PEthPool = mockk {
every { this@mockk.apy } returns apy
}
private fun stakingAvailable(apy: BigDecimal): StakingAvailability =
StakingAvailability.Available(option = stakingOption(apy))
/** 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()
private fun BigDecimal.expectedPerYearText() = resourceReference(
R.string.for_you_earn_per_year,
wrappedList(format { fiat(fiatCurrencySymbol = appCurrency.symbol, fiatCurrencyCode = appCurrency.code) }),
)
}

View file

@ -0,0 +1,66 @@
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.domain.models.earn.EarnRewardType
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
import org.junit.jupiter.api.Test
internal class ForYouEarnOpportunitiesNoTokensConverterTest {
@Test
fun `GIVEN more top tokens than the cap WHEN convert THEN only first five are suggested`() {
// Arrange
val converter = ForYouEarnOpportunitiesNoTokensConverter(
topEarnTokens = List(7) { index ->
createTopEarnToken(tokenId = "token-$index", networkRawId = "NET")
}.right(),
)
// Act
val result = converter.convert(emptyList()) as EarnOpportunitiesUM.Content
// Assert
assertThat(result.tokenList.map { it.tokenRowUM.id })
.containsExactly("token-0-NET", "token-1-NET", "token-2-NET", "token-3-NET", "token-4-NET")
.inOrder()
}
@Test
fun `GIVEN top tokens WHEN convert THEN header shows first suggestion's rate and reward type`() {
// Arrange
val converter = ForYouEarnOpportunitiesNoTokensConverter(
topEarnTokens = listOf(
createTopEarnToken(apy = "7.25", rewardType = EarnRewardType.APR),
createTopEarnToken(tokenId = "solana", apy = "99.9", rewardType = EarnRewardType.APY),
).right(),
)
// Act
val result = converter.convert(emptyList()) as EarnOpportunitiesUM.Content
// Assert — mirrors the production rate rendering for the first (best) suggestion
val expectedRate = "7.25".parseBigDecimalOrNull().format { percent() }
assertThat(result.potentialReward).isEqualTo(stringReference(expectedRate))
assertThat(result.potentialRewardType).isEqualTo(stringReference("APR"))
assertThat(result.subtitleRes).isEqualTo(R.string.for_you_earn_opportunities_no_available_tokens)
}
@Test
fun `GIVEN no top tokens loaded WHEN convert THEN suggestions are empty and reward type is absent`() {
// Arrange
val converter = ForYouEarnOpportunitiesNoTokensConverter(topEarnTokens = null)
// Act
val result = converter.convert(emptyList()) as EarnOpportunitiesUM.Content
// Assert
assertThat(result.tokenList).isEmpty()
assertThat(result.potentialRewardType).isNull()
}
}

View file

@ -0,0 +1,146 @@
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.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.domain.appcurrency.model.AppCurrency
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
import com.tangem.test.mock.MockAccounts
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest {
private val appCurrency: AppCurrency = AppCurrency.Default
@Test
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 ->
createStatus(
createEarnCurrency(tokenId = currencyId, currencyId = currencyId),
createRowLoadedValue(),
) to createEarnApyInfo(isActive = false)
},
)
val converter = createConverter(isAccountsModeEnabled = false)
// Act
val result = converter.convert(listOf(earnData)) as EarnOpportunitiesUM.Content
// Assert — flat, non-expandable token rows
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("token-a", "token-b").inOrder()
assertThat(result.tokenList.map { it.isExpandable }).containsExactly(false, false)
assertThat(result.tokenList.flatMap { it.tokenList }).isEmpty()
}
@Test
fun `GIVEN accounts mode on WHEN convert THEN one expandable account row with token children`() {
// Arrange
val account = MockAccounts.createAccount(derivationIndex = 1, name = "Earn account")
val earnData = createEarnOpportunities(
account = account,
earnCurrencues = listOf("token-a", "token-b").associate { currencyId ->
createStatus(
createEarnCurrency(tokenId = currencyId, currencyId = currencyId),
createRowLoadedValue(),
) to createEarnApyInfo(isActive = false)
},
)
val converter = createConverter(isAccountsModeEnabled = true)
// Act
val result = converter.convert(listOf(earnData)) as EarnOpportunitiesUM.Content
// Assert — a single account row hosting both token rows as children
val item = result.tokenList.single()
assertThat(item.tokenRowUM.id).isEqualTo(account.accountId.value)
assertThat(item.isExpandable).isTrue()
assertThat(item.isExpanded).isFalse()
assertThat(item.tokenList.map { it.id }).containsExactly("token-a", "token-b")
}
@Test
fun `GIVEN account id in expanded set WHEN convert THEN account row is expanded`() {
// Arrange
val account = MockAccounts.createAccount(derivationIndex = 1)
val earnData = createEarnOpportunities(
account = account,
earnCurrencues = mapOf(
createStatus(createEarnCurrency(), createRowLoadedValue()) to createEarnApyInfo(isActive = false),
),
)
val converter = createConverter(
isAccountsModeEnabled = true,
expandedAssetIds = setOf(account.accountId.value),
)
// Act
val result = converter.convert(listOf(earnData)) as EarnOpportunitiesUM.Content
// Assert
assertThat(result.tokenList.single().isExpanded).isTrue()
}
@Test
fun `GIVEN account row clicked WHEN convert THEN expand callback receives account id`() {
// Arrange
val account = MockAccounts.createAccount(derivationIndex = 1)
val earnData = createEarnOpportunities(
account = account,
earnCurrencues = mapOf(
createStatus(createEarnCurrency(), createRowLoadedValue()) to createEarnApyInfo(isActive = false),
),
)
var clickedAssetId: String? = null
val converter = createConverter(isAccountsModeEnabled = true, expandClick = { clickedAssetId = it })
// Act
val result = converter.convert(listOf(earnData)) as EarnOpportunitiesUM.Content
(result.tokenList.single().tokenRowUM as TangemTokenRowUM.Content).onItemClick?.invoke()
// Assert
assertThat(clickedAssetId).isEqualTo(account.accountId.value)
}
@Test
fun `GIVEN several accounts WHEN convert THEN header reward is the sum across accounts`() {
// Arrange
val first = createEarnOpportunities(
account = MockAccounts.createAccount(derivationIndex = 1),
accountPotentialReward = BigDecimal("10"),
)
val second = createEarnOpportunities(
account = MockAccounts.createAccount(derivationIndex = 2),
accountPotentialReward = BigDecimal("2.5"),
)
val converter = createConverter(isAccountsModeEnabled = false)
// Act
val result = converter.convert(listOf(first, second)) as EarnOpportunitiesUM.Content
// Assert — mirrors the production per-year fiat rendering of the 12.5 total
val expectedTotal = BigDecimal("12.5").format {
fiat(fiatCurrencySymbol = appCurrency.symbol, fiatCurrencyCode = appCurrency.code)
}
assertThat(result.potentialReward)
.isEqualTo(resourceReference(R.string.for_you_earn_per_year, wrappedList(expectedTotal)))
assertThat(result.subtitleRes).isEqualTo(R.string.for_you_earn_opportunities_tokens_rewards)
}
private fun createConverter(
isAccountsModeEnabled: Boolean,
expandedAssetIds: Set<String> = emptySet(),
expandClick: (String) -> Unit = {},
) = ForYouEarnOpportunitiesPotentialRewardsConverter(
appCurrency = appCurrency,
isAccountsModeEnabled = isAccountsModeEnabled,
expandedAssetIds = expandedAssetIds,
expandClick = expandClick,
)
}

View file

@ -0,0 +1,116 @@
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
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.StatusSource
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.utils.StringsSigns
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class ForYouEarnOpportunitiesTokenRowConverterTest {
private val appCurrency: AppCurrency = AppCurrency.Default
private val converter = ForYouEarnOpportunitiesTokenRowConverter(appCurrency = appCurrency)
@Test
fun `GIVEN loading status WHEN convert THEN row is Loading with currency id`() {
// Arrange
val status = createStatus(createEarnCurrency(currencyId = "coin-eth"), CryptoCurrencyStatus.Loading)
// Act
val result = converter.convert(status to createEarnApyInfo())
// Assert
assertThat(result).isEqualTo(TangemTokenRowUM.Loading(id = "coin-eth"))
}
@Test
fun `GIVEN loaded status WHEN convert THEN top end is the yearly earn from balance and rate`() {
// Arrange — 200 fiat at 5% → +10.00/year
val status = createStatus(
createEarnCurrency(currencyId = "coin-eth"),
createRowLoadedValue(fiatAmount = BigDecimal("200")),
)
// Act
val result = converter.convert(status to createEarnApyInfo(apy = BigDecimal("0.05")))
as TangemTokenRowUM.Content
// Assert — mirrors the production "+<fiat>/year" rendering
val expectedEarn = BigDecimal("200").multiply(BigDecimal("0.05")).format {
fiat(fiatCurrencySymbol = appCurrency.symbol, fiatCurrencyCode = appCurrency.code)
}
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
assertThat(topEnd.text).isEqualTo(
combinedReference(
stringReference(StringsSigns.PLUS),
resourceReference(R.string.for_you_earn_per_year, wrappedList(expectedEarn)),
),
)
}
@Test
fun `GIVEN loaded status WHEN convert THEN bottom end is the styled percent rate`() {
// Arrange
val status = createStatus(createEarnCurrency(), createRowLoadedValue())
// Act
val result = converter.convert(status to createEarnApyInfo(apy = BigDecimal("0.05")))
as TangemTokenRowUM.Content
// Assert
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
val styled = bottomEnd.text as TextReference.StyledStr
assertThat(styled.value).isEqualTo(BigDecimal("0.05").format { percent() })
}
@Test
fun `GIVEN loaded status from stale cache WHEN convert THEN error-sync icon shown on both ends`() {
// Arrange
val status = createStatus(
createEarnCurrency(),
createRowLoadedValue(source = StatusSource.ONLY_CACHE),
)
// Act
val result = converter.convert(status to createEarnApyInfo()) as TangemTokenRowUM.Content
// Assert
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
assertThat(topEnd.startIcons).hasSize(1)
assertThat(bottomEnd.startIcons).hasSize(1)
}
@Test
fun `GIVEN unreachable status WHEN convert THEN both ends are dashes`() {
// Arrange
val unreachable: CryptoCurrencyStatus.Unreachable = mockk {
every { fiatAmount } returns null
every { isError } returns true
}
val status = createStatus(createEarnCurrency(), unreachable)
// Act
val result = converter.convert(status to createEarnApyInfo()) as TangemTokenRowUM.Content
// Assert
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
assertThat(topEnd.text).isEqualTo(stringReference(StringsSigns.DASH_SIGN))
assertThat(bottomEnd.text).isEqualTo(stringReference(StringsSigns.DASH_SIGN))
}
}

View file

@ -0,0 +1,112 @@
package com.tangem.features.foryou.impl.model.converter.earnOpportunities
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.R
import com.tangem.domain.models.earn.EarnError
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
internal class ForYouEarnOpportunitiesTokensActiveConverterTest {
@Test
fun `GIVEN top tokens contain active portfolio assets WHEN convert THEN active ones are excluded`() {
// Arrange
val activePortfolio = createEarnOpportunities(
earnCurrencues = mapOf(
createStatus(createEarnCurrency(tokenId = "ethereum", networkRawId = "ETH")) to createEarnApyInfo(),
),
)
val converter = ForYouEarnOpportunitiesTokensActiveConverter(
topEarnTokens = listOf(
createTopEarnToken(tokenId = "ethereum", networkRawId = "ETH"),
createTopEarnToken(tokenId = "solana", networkRawId = "SOL"),
).right(),
)
// Act
val result = converter.convert(listOf(activePortfolio)) as EarnOpportunitiesUM.Content
// Assert
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("solana-SOL")
}
@Test
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 ->
createStatus(createEarnCurrency(tokenId = tokenId, networkRawId = "NET")) to createEarnApyInfo()
},
)
val converter = ForYouEarnOpportunitiesTokensActiveConverter(
topEarnTokens = List(8) { index ->
createTopEarnToken(tokenId = "token-$index", networkRawId = "NET")
}.right(),
)
// Act
val result = converter.convert(listOf(activePortfolio)) as EarnOpportunitiesUM.Content
// Assert
assertThat(result.tokenList.map { it.tokenRowUM.id })
.containsExactly("token-2-NET", "token-3-NET", "token-4-NET", "token-5-NET", "token-6-NET")
.inOrder()
}
@Test
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(
createStatus(createEarnCurrency(tokenId = "usd-coin", networkRawId = "ETH")) to createEarnApyInfo(),
),
)
val converter = ForYouEarnOpportunitiesTokensActiveConverter(
topEarnTokens = listOf(
createTopEarnToken(tokenId = "usd-coin", networkRawId = "ETH"),
createTopEarnToken(tokenId = "usd-coin", networkRawId = "SOL"),
).right(),
)
// Act
val result = converter.convert(listOf(activePortfolio)) as EarnOpportunitiesUM.Content
// Assert
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("usd-coin-SOL")
}
@Test
fun `GIVEN no top tokens loaded WHEN convert THEN content with empty suggestions`() {
// Arrange
val converter = ForYouEarnOpportunitiesTokensActiveConverter(topEarnTokens = null)
// Act
val result = converter.convert(listOf(createEarnOpportunities()))
// Assert
val expected = EarnOpportunitiesUM.Content(
tokenList = persistentListOf(),
subtitleRes = R.string.for_you_earn_opportunities_all_tokens_active,
potentialReward = null,
potentialRewardType = null,
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `GIVEN top tokens failed to load WHEN convert THEN suggestions are empty`() {
// Arrange
val converter = ForYouEarnOpportunitiesTokensActiveConverter(
topEarnTokens = EarnError.NotHttpError().left(),
)
// Act
val result = converter.convert(listOf(createEarnOpportunities())) as EarnOpportunitiesUM.Content
// Assert
assertThat(result.tokenList).isEmpty()
}
}

View file

@ -0,0 +1,94 @@
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.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.models.earn.EarnType
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class ForYouEarnOpportunitiesTopTokenRowConverterTest {
private val converter = ForYouEarnOpportunitiesTopTokenRowConverter()
@Test
fun `GIVEN top-earn token WHEN convert THEN row carries currency identity and network subtitle`() {
// Arrange
val topToken = createTopEarnToken(
tokenId = "solana",
networkRawId = "SOL",
networkName = "Solana",
name = "Solana",
apy = "7.25",
)
// Act
val result = converter.convert(topToken)
// Assert
val row = result.tokenRowUM as TangemTokenRowUM.Content
assertThat(row.id).isEqualTo("solana-SOL")
assertThat(row.titleUM).isEqualTo(TangemTokenRowUM.TitleUM.Content(text = stringReference("Solana")))
assertThat(row.subtitleUM).isEqualTo(
TangemTokenRowUM.SubtitleUM.Content(
text = resourceReference(R.string.wallet_network_group_title, wrappedList("Solana")),
),
)
assertThat(row.topEndContentUM).isEqualTo(
TangemTokenRowUM.EndContentUM.Content(
text = resourceReference(R.string.markets_apy_placeholder, wrappedList("7.25".expectedPercent())),
),
)
}
@Test
fun `GIVEN staking token WHEN convert THEN bottom end labels staking`() {
// Arrange
val topToken = createTopEarnToken(type = EarnType.STAKING)
// Act
val result = converter.convert(topToken)
// Assert
val row = result.tokenRowUM as TangemTokenRowUM.Content
assertThat(row.bottomEndContentUM).isEqualTo(
TangemTokenRowUM.EndContentUM.Content(text = resourceReference(R.string.common_staking)),
)
}
@Test
fun `GIVEN yield token WHEN convert THEN bottom end labels yield mode`() {
// Arrange
val topToken = createTopEarnToken(type = EarnType.YIELD)
// Act
val result = converter.convert(topToken)
// Assert
val row = result.tokenRowUM as TangemTokenRowUM.Content
assertThat(row.bottomEndContentUM).isEqualTo(
TangemTokenRowUM.EndContentUM.Content(text = resourceReference(R.string.common_yield_mode)),
)
}
@Test
fun `GIVEN top-earn token WHEN convert THEN item is a flat non-expandable row`() {
// Act
val result = converter.convert(createTopEarnToken())
// Assert
assertThat(result.isExpandable).isFalse()
assertThat(result.isExpanded).isFalse()
assertThat(result.tokenList).isEmpty()
}
/** Mirrors the production APY rendering used by [ForYouEarnOpportunitiesTopTokenRowConverter]. */
private fun String.expectedPercent(): TextReference =
TextReference.Str(BigDecimal(this).format { percent(withPercentSign = false) })
}

View file

@ -10,6 +10,7 @@ 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
@ -307,6 +308,7 @@ internal class SetPortfolioReviewTransformerTest {
tokenList = persistentListOf<ForYouTokenListItemUM>(),
marketChartUM = MarketChartUM.NoData,
),
earnOpportunities = EarnOpportunitiesUM.Loading(tokenList = persistentListOf()),
notifications = persistentListOf(),
)