From 7e513093554c6d7f9ccc445219f9ee517247c28d Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Jul 2026 11:16:38 +0500 Subject: [PATCH] Updated on 2026-08-14 --- features/for-you/impl/build.gradle.kts | 1 + .../ForYouPortfolioFormattersTest.kt | 57 ++++ .../converter/ForYouTokenListConverterTest.kt | 9 +- .../EarnOpportunitiesTestFactory.kt | 194 ++++++++++++++ .../ForYouEarnOpportunitiesConverterTest.kt | 245 ++++++++++++++++++ ...uEarnOpportunitiesNoTokensConverterTest.kt | 66 +++++ ...ortunitiesPotentialRewardsConverterTest.kt | 146 +++++++++++ ...uEarnOpportunitiesTokenRowConverterTest.kt | 116 +++++++++ ...nOpportunitiesTokensActiveConverterTest.kt | 112 ++++++++ ...rnOpportunitiesTopTokenRowConverterTest.kt | 94 +++++++ .../SetPortfolioReviewTransformerTest.kt | 2 + 11 files changed, 1040 insertions(+), 2 deletions(-) create mode 100644 features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt create mode 100644 features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt create mode 100644 features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt create mode 100644 features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt create mode 100644 features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverterTest.kt create mode 100644 features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt create mode 100644 features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverterTest.kt diff --git a/features/for-you/impl/build.gradle.kts b/features/for-you/impl/build.gradle.kts index 26cbf40712..fcdb2d8087 100644 --- a/features/for-you/impl/build.gradle.kts +++ b/features/for-you/impl/build.gradle.kts @@ -70,4 +70,5 @@ dependencies { /** Tests */ testImplementation(projects.common.test) testImplementation(projects.test.core) + testImplementation(projects.test.mock) } \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt index 5b6cce0aea..1766aa4a97 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt @@ -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 { diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt index 97e1d86e69..192e969293 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt @@ -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 diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt new file mode 100644 index 0000000000..ec6c995036 --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt @@ -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 { + 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 `"_"`. */ +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 `"-"`. */ +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 { every { isActive } returns active } + } + every { stakingBalance } returns if (isStakingActive) mockk() 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 = 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, + 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() +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt new file mode 100644 index 0000000000..e38e2b23dc --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt @@ -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( + 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 = emptyMap(), + yieldStakingAvailability: Map = 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) }), + ) +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt new file mode 100644 index 0000000000..614e819c4b --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt @@ -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() + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt new file mode 100644 index 0000000000..98cc7f3b16 --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt @@ -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 = emptySet(), + expandClick: (String) -> Unit = {}, + ) = ForYouEarnOpportunitiesPotentialRewardsConverter( + appCurrency = appCurrency, + isAccountsModeEnabled = isAccountsModeEnabled, + expandedAssetIds = expandedAssetIds, + expandClick = expandClick, + ) +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverterTest.kt new file mode 100644 index 0000000000..2c7840246c --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverterTest.kt @@ -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 "+/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)) + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt new file mode 100644 index 0000000000..6d5ff554ef --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt @@ -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() + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverterTest.kt new file mode 100644 index 0000000000..8bd359680d --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverterTest.kt @@ -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) }) +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt index 4e6e65f57a..450b82515c 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt @@ -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(), marketChartUM = MarketChartUM.NoData, ), + earnOpportunities = EarnOpportunitiesUM.Loading(tokenList = persistentListOf()), notifications = persistentListOf(), )