Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-01 14:16:18 +05:00
parent bac44ac402
commit 247dd6b099
5 changed files with 1168 additions and 0 deletions

View file

@ -0,0 +1,346 @@
package com.tangem.features.foryou.impl.model
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
import com.tangem.core.ui.ds.image.DeviceIconUM
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.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.common.wallets.UserWalletsListRepository
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.UserWalletIcon
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LargeClass")
internal class ForYouModelTest {
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier = mockk()
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
private val walletIconUMConverter: WalletIconUMConverter = mockk {
every { convert(any()) } returns DeviceIconUM.Stub(cardsCount = 1)
}
private val getWalletIconUseCase: GetWalletIconUseCase = mockk()
private var model: ForYouModel? = null
@BeforeEach
fun setup() {
every { getWalletIconUseCase.invoke(any()) } returns UserWalletIcon.Stub(cardsCount = 1)
// 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. Individual tests may override.
every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right())
}
@AfterEach
fun tearDown() {
model?.onDestroy()
model = null
}
@Nested
inner class InitialState {
@Test
fun `GIVEN model created WHEN not yet advanced THEN uiState is Loading`() = runTest {
// Arrange
every { userWalletsListRepository.userWallets } returns MutableStateFlow(null)
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null)
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf())
every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right())
// Act
val model = createModel(testScope = this)
// Assert — before advancing, the model exposes skeleton placeholder rows
val loading = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Loading
assertThat(loading.tokenList).hasSize(4)
assertThat(loading.tokenList.all { it.tokenRowUM is TangemTokenRowUM.Loading }).isTrue()
}
}
@Nested
inner class ContentState {
@Test
fun `GIVEN wallets and account statuses emitted WHEN advanced THEN uiState becomes Content with tabs`() =
runTest {
// Arrange
val walletOne = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1")
val walletTwo = MockUserWalletFactory.create().copy(walletId = UserWalletId("02"), name = "Wallet 2")
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(walletOne, walletTwo))
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(walletOne)
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
val accountStatusList = createAccountStatusList(
userWalletId = walletOne.walletId,
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
totalFiatBalance = BigDecimal("100"),
)
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(
linkedMapOf(walletOne.walletId to accountStatusList),
)
every { getSelectedAppCurrencyUseCase() } returns flowOf()
// Act
val model = createModel(testScope = this)
advanceUntilIdle()
// Assert
val content = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
assertThat(content.assetCount).isNotNull()
assertThat(model.uiState.value.walletListUM.items).hasSize(2)
assertThat(model.uiState.value.walletListUM.items.map { it.isSelected }).containsExactly(true, false)
}
@Test
fun `GIVEN exactly one wallet WHEN advanced THEN walletListUM items is empty`() = runTest {
// Arrange
val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1")
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(wallet))
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet)
val accountStatusList = createAccountStatusList(
userWalletId = wallet.walletId,
currencies = emptyList(),
totalFiatBalance = BigDecimal.ZERO,
)
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(
linkedMapOf(wallet.walletId to accountStatusList),
)
every { getSelectedAppCurrencyUseCase() } returns flowOf()
// Act
val model = createModel(testScope = this)
advanceUntilIdle()
// Assert — the "tabs.size != 1" rule: a single wallet shows no tabs
assertThat(model.uiState.value.walletListUM.items).isEmpty()
}
}
@Nested
inner class TabClick {
@Test
fun `GIVEN two wallets WHEN onTabClick THEN locally selected wallet switches and currencies rederive`() =
runTest {
// Arrange
val walletOne = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1")
val walletTwo = MockUserWalletFactory.create().copy(walletId = UserWalletId("02"), name = "Wallet 2")
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(walletOne, walletTwo))
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(walletOne)
val btc = createCoin(rawCurrencyId = "btc", symbol = "BTC")
val eth = createCoin(rawCurrencyId = "eth", symbol = "ETH")
val statusOne = createAccountStatusList(
userWalletId = walletOne.walletId,
currencies = listOf(createStatus(btc, loadedValue(BigDecimal("100")))),
totalFiatBalance = BigDecimal("100"),
)
val statusTwo = createAccountStatusList(
userWalletId = walletTwo.walletId,
currencies = listOf(createStatus(eth, loadedValue(BigDecimal("50")))),
totalFiatBalance = BigDecimal("50"),
)
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(
linkedMapOf(walletOne.walletId to statusOne, walletTwo.walletId to statusTwo),
)
every { getSelectedAppCurrencyUseCase() } returns flowOf()
val model = createModel(testScope = this)
advanceUntilIdle()
// Act — click the second wallet's tab
model.uiState.value.walletListUM.items[1].onClick()
advanceUntilIdle()
// Assert
assertThat(model.uiState.value.walletListUM.items.map { it.isSelected }).containsExactly(
false,
true,
).inOrder()
val content = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
val assetRow = content.tokenList.single().tokenRowUM as TangemTokenRowUM.Content
val titleUM = assetRow.titleUM as TangemTokenRowUM.TitleUM.Content
assertThat(titleUM.text).isEqualTo(com.tangem.core.ui.extensions.stringReference("ETH"))
}
}
@Nested
inner class ExpandClick {
@Test
fun `GIVEN asset row clicked WHEN clicked again THEN isExpanded toggles back to false`() = runTest {
// Arrange
val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1")
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(wallet))
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet)
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
val accountStatusList = createAccountStatusList(
userWalletId = wallet.walletId,
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
totalFiatBalance = BigDecimal("100"),
)
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(
linkedMapOf(wallet.walletId to accountStatusList),
)
every { getSelectedAppCurrencyUseCase() } returns flowOf()
val model = createModel(testScope = this)
advanceUntilIdle()
val initialContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
assertThat(initialContent.tokenList.single().isExpanded).isFalse()
// Act — click once to expand
val assetRow = initialContent.tokenList.single().tokenRowUM as TangemTokenRowUM.Content
assetRow.onItemClick?.invoke()
advanceUntilIdle()
// Assert
val expandedContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
assertThat(expandedContent.tokenList.single().isExpanded).isTrue()
// Act — click again to collapse
val expandedRow = expandedContent.tokenList.single().tokenRowUM as TangemTokenRowUM.Content
expandedRow.onItemClick?.invoke()
advanceUntilIdle()
// Assert
val collapsedContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
assertThat(collapsedContent.tokenList.single().isExpanded).isFalse()
}
}
@Nested
inner class PeriodClick {
@Test
fun `GIVEN Content state WHEN period clicked THEN initialSelectedItem updates without resetting rest`() =
runTest {
// Arrange
val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1")
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(wallet))
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet)
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
val accountStatusList = createAccountStatusList(
userWalletId = wallet.walletId,
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
totalFiatBalance = BigDecimal("100"),
)
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(
linkedMapOf(wallet.walletId to accountStatusList),
)
every { getSelectedAppCurrencyUseCase() } returns flowOf()
val model = createModel(testScope = this)
advanceUntilIdle()
val contentBefore = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
val weekItem = contentBefore.periodPickerUM.items[1]
val assetCountBefore = contentBefore.assetCount
// Act
contentBefore.onPeriodClick(weekItem)
// Assert
val contentAfter = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
assertThat(contentAfter.periodPickerUM.initialSelectedItem).isEqualTo(weekItem)
assertThat(contentAfter.assetCount).isEqualTo(assetCountBefore)
assertThat(contentAfter.tokenList).isEqualTo(contentBefore.tokenList)
}
}
private fun createModel(testScope: TestScope): ForYouModel {
return ForYouModel(
userWalletsListRepository = userWalletsListRepository,
multiAccountStatusListSupplier = multiAccountStatusListSupplier,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
walletIconUMConverter = walletIconUMConverter,
getWalletIconUseCase = getWalletIconUseCase,
).also { model = it }
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
private fun createAccountStatusList(
userWalletId: UserWalletId,
currencies: List<CryptoCurrencyStatus>,
totalFiatBalance: BigDecimal,
): AccountStatusList = mockk {
every { this@mockk.userWalletId } returns userWalletId
every { flattenCurrencies() } returns currencies
every { this@mockk.totalFiatBalance } returns TotalFiatBalance.Loaded(
amount = totalFiatBalance,
source = com.tangem.domain.models.StatusSource.ACTUAL,
)
}
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
}
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) }
}
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.network } returns network
every { this@mockk.decimals } returns 8
every { isCustom } returns false
every { iconUrl } returns null
}
}
}

View file

@ -0,0 +1,163 @@
package com.tangem.features.foryou.impl.model.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
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.CryptoCurrencyStatus
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 ForYouPortfolioFormattersTest {
private val appCurrency: AppCurrency = AppCurrency.Default
@Nested
inner class ForYouGroupKey {
@Test
fun `GIVEN standard currency with raw id WHEN forYouGroupKey THEN returns rawCurrencyId value`() {
// Arrange
val id: CryptoCurrency.ID = mockk {
every { rawCurrencyId } returns CryptoCurrency.RawID("bitcoin")
every { value } returns "coin-id-value"
}
val currency: CryptoCurrency = mockk { every { this@mockk.id } returns id }
val status = createStatus(currency)
// Act
val result = status.forYouGroupKey()
// Assert
assertThat(result).isEqualTo("bitcoin")
}
@Test
fun `GIVEN custom token with no raw id WHEN forYouGroupKey THEN falls back to currency id value`() {
// Arrange
val id: CryptoCurrency.ID = mockk {
every { rawCurrencyId } returns null
every { value } returns "custom-currency-id"
}
val currency: CryptoCurrency = mockk { every { this@mockk.id } returns id }
val status = createStatus(currency)
// Act
val result = status.forYouGroupKey()
// Assert
assertThat(result).isEqualTo("custom-currency-id")
}
private fun createStatus(currency: CryptoCurrency): CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Loading,
)
}
@Nested
inner class ToForYouFiatText {
@Test
fun `GIVEN a fiat amount WHEN toForYouFiatText THEN delegates to fiat formatting`() {
// Arrange
val amount = BigDecimal("1234.5")
// Act
val result = amount.toForYouFiatText(appCurrency)
// Assert
val expected = stringReference(
amount.format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) },
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `GIVEN null amount WHEN toForYouFiatText THEN renders dash text`() {
// Arrange
val amount: BigDecimal? = null
// Act
val result = amount.toForYouFiatText(appCurrency)
// Assert
val expected = stringReference(
amount.format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) },
)
assertThat(result).isEqualTo(expected)
}
}
@Nested
inner class ToForYouPercentText {
@Test
fun `GIVEN null amount WHEN toForYouPercentText THEN returns EMPTY`() {
// Arrange
val amount: BigDecimal? = null
// Act
val result = amount.toForYouPercentText(BigDecimal("100"))
// Assert
assertThat(result).isEqualTo(TextReference.EMPTY)
}
@Test
fun `GIVEN zero total WHEN toForYouPercentText THEN returns EMPTY`() {
// Arrange
val amount = BigDecimal("10")
// Act
val result = amount.toForYouPercentText(BigDecimal.ZERO)
// Assert
assertThat(result).isEqualTo(TextReference.EMPTY)
}
@Test
fun `GIVEN zero amount WHEN toForYouPercentText THEN returns EMPTY`() {
// Arrange
val amount = BigDecimal.ZERO
// Act
val result = amount.toForYouPercentText(BigDecimal("100"))
// Assert
assertThat(result).isEqualTo(TextReference.EMPTY)
}
@Test
fun `GIVEN non-zero amount and total WHEN toForYouPercentText THEN returns rounded percent share`() {
// Arrange
val amount = BigDecimal("25.00")
val total = BigDecimal("100")
// Act
val result = amount.toForYouPercentText(total)
// Assert — 25.00 / 100 = 0.25 -> 25.00%
assertThat(result).isEqualTo(stringReference("25.00%"))
}
@Test
fun `GIVEN a share requiring rounding WHEN toForYouPercentText THEN applies HALF_UP rounding`() {
// Arrange — 1.0000 / 3 = 0.3333... -> rounds to 33.33%
val amount = BigDecimal("1.0000")
val total = BigDecimal("3")
// Act
val result = amount.toForYouPercentText(total)
// Assert
assertThat(result).isEqualTo(stringReference("33.33%"))
}
}
}

View file

@ -0,0 +1,285 @@
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.resourceReference
import com.tangem.core.ui.extensions.stringReference
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(stringReference("2 networks"))
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 otherAssetCount is zero 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"),
otherAssetCount = 0,
)
// Act
val result = converter.convert(listOf(status))
// Assert
assertThat(result).hasSize(1)
}
@Test
fun `GIVEN otherAssetCount is one 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"),
otherAssetCount = 1,
otherFiatBalance = 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(stringReference("1 asset"))
}
@Test
fun `GIVEN otherAssetCount is more than one 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"),
otherAssetCount = 3,
otherFiatBalance = BigDecimal("50"),
)
// 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(stringReference("3 assets"))
}
@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(),
otherAssetCount: Int = 0,
otherFiatBalance: BigDecimal = BigDecimal.ZERO,
): ForYouTokenListConverter = ForYouTokenListConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
expandedAssetIds = expandedAssetIds,
expandClick = {},
otherAssetCount = otherAssetCount,
otherFiatBalance = otherFiatBalance,
)
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
}
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

@ -0,0 +1,131 @@
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.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 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 {
private val appCurrency: AppCurrency = AppCurrency.Default
@Nested
inner class ConvertNetworkGroup {
@Test
fun `GIVEN all statuses Loading WHEN convertNetworkGroup THEN row is Loading with representative id`() {
// Arrange
val currency = createCurrency(id = "coin-eth", symbol = "ETH")
val statuses = listOf(createStatus(currency, CryptoCurrencyStatus.Loading))
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses)
// Assert
assertThat(result).isEqualTo(TangemTokenRowUM.Loading(id = "coin-eth"))
}
@Test
fun `GIVEN single loaded status WHEN convertNetworkGroup THEN row is Content with its amounts`() {
// Arrange
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(
createStatus(currency, loadedValue(amount = BigDecimal("2"), fiatAmount = BigDecimal("400"))),
)
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
// Assert
assertThat(result.id).isEqualTo("coin-eth")
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
assertThat(topEnd.text).isEqualTo(BigDecimal("400").toForYouFiatText(appCurrency))
assertThat(bottomEnd.text).isEqualTo(BigDecimal("400").toForYouPercentText(BigDecimal("1000")))
}
@Test
fun `GIVEN several statuses of the same asset on one network WHEN convertNetworkGroup THEN amounts are summed`() {
// Arrange — same asset held in two accounts on the same network aggregates into one row
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
val statuses = listOf(
createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("200"))),
createStatus(currency, loadedValue(amount = BigDecimal("2"), fiatAmount = BigDecimal("400"))),
)
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
// Assert
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
assertThat(topEnd.text).isEqualTo(BigDecimal("600").toForYouFiatText(appCurrency))
}
@Test
fun `GIVEN mixed Loading and Loaded statuses WHEN convertNetworkGroup THEN row is Content`() {
// Arrange — not *all* statuses are Loading, so it should not collapse to a Loading row
val currency = createCurrency(id = "coin-eth", symbol = "ETH")
val statuses = listOf(
createStatus(currency, CryptoCurrencyStatus.Loading),
createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))),
)
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
// Act
val result = converter.convertNetworkGroup(statuses)
// Assert
assertThat(result).isInstanceOf(TangemTokenRowUM.Content::class.java)
}
}
private fun createConverter(totalFiatBalance: BigDecimal) = ForYouTokenRowConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
)
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
}
private fun createCurrency(
id: String,
symbol: String,
networkName: String = "Network",
): CryptoCurrency {
val network: Network = mockk {
every { name } returns networkName
every { isTestnet } returns false
every { this@mockk.id } returns mockk { every { rawId } returns Network.RawID(id) }
}
val currencyId: CryptoCurrency.ID = mockk {
every { value } returns id
every { rawCurrencyId } returns null
}
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
}
}
}

View file

@ -0,0 +1,243 @@
package com.tangem.features.foryou.impl.model.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.extensions.stringReference
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.commonfeatures.api.choosetoken.model.WalletListUM
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 io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class SetPortfolioReviewTransformerTest {
private val appCurrency: AppCurrency = AppCurrency.Default
private val walletListUM = WalletListUM(items = persistentListOf())
@Nested
inner class Transform {
@Test
fun `GIVEN currency with zero fiat balance WHEN transform THEN it is dropped from asset count`() {
// 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 transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("100"))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.assetCount).isEqualTo(stringReference("1 assets"))
}
@Test
fun `GIVEN assets across networks WHEN transform THEN they are aggregated and ranked by summed fiat`() {
// Arrange — same asset (rawCurrencyId "usdc") on two networks 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(currencies = currencies, totalFiatBalance = BigDecimal("120"))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert — 2 ranked assets: usdc (110 total) and btc (10)
assertThat(result.assetCount).isEqualTo(stringReference("2 assets"))
}
@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(currencies = currencies, totalFiatBalance = BigDecimal("470"))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert — tokenList has 4 top asset rows + 1 "Other" row = 5 items
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(currencies = currencies, totalFiatBalance = BigDecimal("394"))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.tokenList).hasSize(4)
}
@Test
fun `GIVEN total and top balance non-zero WHEN transform THEN topHoldingPercent is computed`() {
// Arrange — a single asset means top balance == total balance == 100%
val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100"))))
val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("100"))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.topHoldingPercent).isEqualTo(stringReference("Top holding 100.00%"))
}
@Test
fun `GIVEN zero total fiat balance WHEN transform THEN topHoldingPercent is DASH_SIGN`() {
// Arrange
val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
val currencies = listOf(createStatus(currency, loadedValue(BigDecimal.ZERO)))
val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal.ZERO)
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.topHoldingPercent).isEqualTo(stringReference("Top holding —"))
}
@Test
fun `GIVEN prev state is Loading WHEN transform THEN period picker is freshly created with Day selected`() {
// Arrange
val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("10"))))
val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("10"))
// Act
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.periodPickerUM.items.map { it.title }).containsExactly(
stringReference("Day"),
stringReference("Week"),
stringReference("Month"),
).inOrder()
assertThat(result.periodPickerUM.initialSelectedItem?.title).isEqualTo(stringReference("Day"))
}
@Test
fun `GIVEN prev state is Content WHEN transform THEN period picker selection is preserved`() {
// Arrange
val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("10"))))
val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("10"))
val prevContentState = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
val weekItem = prevContentState.periodPickerUM.items[1]
val prevWithWeekSelected = prevContentState.copy(
periodPickerUM = prevContentState.periodPickerUM.copy(initialSelectedItem = weekItem),
)
val prevState = ForYouUM(walletListUM = walletListUM, portfolioReviewUM = prevWithWeekSelected)
// Act
val result = transformer.transform(prevState).portfolioReviewUM as PortfolioReviewUM.Content
// Assert
assertThat(result.periodPickerUM.initialSelectedItem).isEqualTo(weekItem)
}
@Test
fun `GIVEN new state WHEN transform THEN walletListUM is applied from constructor`() {
// Arrange
val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("10"))))
val newWalletListUM = WalletListUM(items = persistentListOf())
val transformer = SetPortfolioReviewTransformer(
walletListUM = newWalletListUM,
currencies = currencies,
totalFiatBalance = BigDecimal("10"),
appCurrency = appCurrency,
expandedAssetIds = emptySet(),
expandClick = {},
onPeriodClick = {},
)
// Act
val result = transformer.transform(loadingState())
// Assert
assertThat(result.walletListUM).isSameInstanceAs(newWalletListUM)
}
}
private fun createTransformer(
currencies: List<CryptoCurrencyStatus>,
totalFiatBalance: BigDecimal,
expandedAssetIds: Set<String> = emptySet(),
) = SetPortfolioReviewTransformer(
walletListUM = walletListUM,
currencies = currencies,
totalFiatBalance = totalFiatBalance,
appCurrency = appCurrency,
expandedAssetIds = expandedAssetIds,
expandClick = {},
onPeriodClick = {},
)
private fun loadingState(): ForYouUM = ForYouUM(
walletListUM = walletListUM,
portfolioReviewUM = PortfolioReviewUM.Loading(tokenList = persistentListOf<ForYouTokenListItemUM>()),
)
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
}
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.network } returns network
every { this@mockk.decimals } returns 8
every { isCustom } returns false
every { iconUrl } returns null
}
}
}