Updated on 2026-08-14
This commit is contained in:
parent
f86441df6a
commit
571bf358bd
11 changed files with 936 additions and 590 deletions
|
|
@ -7,23 +7,49 @@ import com.tangem.core.decompose.model.MutableParamsContainer
|
||||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||||
import com.tangem.domain.account.models.AccountStatusList
|
import com.tangem.domain.account.models.AccountStatusList
|
||||||
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
|
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
|
||||||
|
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
|
||||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||||
|
import com.tangem.domain.earn.EarnErrorResolver
|
||||||
|
import com.tangem.domain.earn.model.EarnTokensBatchFlow
|
||||||
|
import com.tangem.domain.earn.model.EarnTokensBatchingContext
|
||||||
|
import com.tangem.domain.earn.model.EarnTokensListConfig
|
||||||
|
import com.tangem.domain.earn.usecase.GetEarnTokensBatchFlowUseCase
|
||||||
import com.tangem.domain.models.StatusSource
|
import com.tangem.domain.models.StatusSource
|
||||||
import com.tangem.domain.models.TotalFiatBalance
|
import com.tangem.domain.models.TotalFiatBalance
|
||||||
|
import com.tangem.domain.models.account.AccountStatus
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
|
import com.tangem.domain.models.earn.EarnError
|
||||||
|
import com.tangem.domain.models.earn.EarnRewardType
|
||||||
|
import com.tangem.domain.models.earn.EarnToken
|
||||||
|
import com.tangem.domain.models.earn.EarnTokenWithCurrency
|
||||||
|
import com.tangem.domain.models.earn.EarnType
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
|
import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase
|
||||||
|
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase
|
||||||
import com.tangem.features.foryou.ForYouComponent
|
import com.tangem.features.foryou.ForYouComponent
|
||||||
import com.tangem.features.foryou.impl.components.state.MarketChartUM
|
import com.tangem.features.foryou.impl.components.state.MarketChartUM
|
||||||
|
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
|
||||||
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
|
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
|
||||||
|
import com.tangem.features.foryou.impl.model.converter.TOP_EARN_TOKENS_BATCH_SIZE
|
||||||
|
import com.tangem.pagination.Batch
|
||||||
|
import com.tangem.pagination.BatchAction
|
||||||
|
import com.tangem.pagination.BatchListState
|
||||||
|
import com.tangem.pagination.PaginationStatus
|
||||||
|
import com.tangem.test.mock.MockAccounts
|
||||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||||
|
import io.mockk.CapturingSlot
|
||||||
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
|
@ -41,6 +67,11 @@ internal class ForYouModelTest {
|
||||||
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
||||||
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier = mockk()
|
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier = mockk()
|
||||||
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
|
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
|
||||||
|
private val yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase = mockk()
|
||||||
|
private val getEarnTokensBatchFlowUseCase: GetEarnTokensBatchFlowUseCase = mockk()
|
||||||
|
private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase = mockk()
|
||||||
|
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk()
|
||||||
|
private val earnErrorResolver: EarnErrorResolver = mockk()
|
||||||
|
|
||||||
private var model: ForYouModel? = null
|
private var model: ForYouModel? = null
|
||||||
|
|
||||||
|
|
@ -49,6 +80,10 @@ internal class ForYouModelTest {
|
||||||
// Default: a real, non-empty emission so the model's `getOrElse { Default }` mapping path is
|
// 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.
|
// actually exercised in every test, not bypassed by an empty flow.
|
||||||
every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right())
|
every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right())
|
||||||
|
every { yieldSupplyApyFlowUseCase() } returns flowOf(emptyMap())
|
||||||
|
coEvery { stakingAvailabilityListUseCase.invokeSync(any(), any()) } returns emptyMap()
|
||||||
|
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
|
||||||
|
stubTopEarnTokens(status = PaginationStatus.None)
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
|
|
@ -75,6 +110,21 @@ internal class ForYouModelTest {
|
||||||
assertThat(loading.tokenList.all { it.tokenRowUM is TangemTokenRowUM.Loading }).isTrue()
|
assertThat(loading.tokenList.all { it.tokenRowUM is TangemTokenRowUM.Loading }).isTrue()
|
||||||
assertThat(loading.marketChartUM).isEqualTo(MarketChartUM.NoData)
|
assertThat(loading.marketChartUM).isEqualTo(MarketChartUM.NoData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN model created WHEN not yet advanced THEN earn section is Loading with skeleton rows`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null)
|
||||||
|
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf())
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val model = createModel(testScope = this)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val loading = model.uiState.value.earnOpportunities as EarnOpportunitiesUM.Loading
|
||||||
|
assertThat(loading.tokenList).hasSize(5)
|
||||||
|
assertThat(loading.tokenList.all { it.tokenRowUM is TangemTokenRowUM.Loading }).isTrue()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
|
|
@ -84,10 +134,7 @@ internal class ForYouModelTest {
|
||||||
fun `GIVEN selected wallet and statuses emitted WHEN advanced THEN uiState becomes Content`() = runTest {
|
fun `GIVEN selected wallet and statuses emitted WHEN advanced THEN uiState becomes Content`() = runTest {
|
||||||
// Arrange
|
// Arrange
|
||||||
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
|
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
|
||||||
stubSelectedWallet(
|
stubSelectedWallet(currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))))
|
||||||
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
|
|
||||||
totalFiatBalance = BigDecimal("100"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
val model = createModel(testScope = this)
|
val model = createModel(testScope = this)
|
||||||
|
|
@ -107,7 +154,6 @@ internal class ForYouModelTest {
|
||||||
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
|
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
|
||||||
stubSelectedWallet(
|
stubSelectedWallet(
|
||||||
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
|
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
|
||||||
totalFiatBalance = BigDecimal("100"),
|
|
||||||
source = StatusSource.ONLY_CACHE,
|
source = StatusSource.ONLY_CACHE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -118,6 +164,71 @@ internal class ForYouModelTest {
|
||||||
// Assert
|
// Assert
|
||||||
assertThat(model.uiState.value.notifications).containsExactly(ForYouNotification.UsedOutdatedData)
|
assertThat(model.uiState.value.notifications).containsExactly(ForYouNotification.UsedOutdatedData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN nothing earn-eligible and loaded suggestions WHEN advanced THEN earn section suggests them`() =
|
||||||
|
runTest {
|
||||||
|
// Arrange — the portfolio coin has no earn option; the top-earn batch has one suggestion
|
||||||
|
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
|
||||||
|
stubSelectedWallet(currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))))
|
||||||
|
stubTopEarnTokens(
|
||||||
|
status = PaginationStatus.EndOfPagination,
|
||||||
|
suggestions = listOf(createTopEarnSuggestion()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val model = createModel(testScope = this)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val earn = model.uiState.value.earnOpportunities as EarnOpportunitiesUM.Content
|
||||||
|
assertThat(earn.tokenList.map { it.tokenRowUM.id }).containsExactly("coin-solana")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN model created WHEN advanced THEN top-earn tokens requested as one full-config reload`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val contextSlot: CapturingSlot<EarnTokensBatchingContext> = slot()
|
||||||
|
val batchFlow: EarnTokensBatchFlow = mockk {
|
||||||
|
every { state } returns MutableStateFlow(
|
||||||
|
BatchListState(data = emptyList(), status = PaginationStatus.None),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
every { getEarnTokensBatchFlowUseCase(capture(contextSlot), any()) } returns batchFlow
|
||||||
|
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null)
|
||||||
|
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf())
|
||||||
|
|
||||||
|
// Act
|
||||||
|
createModel(testScope = this)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Assert — a single Reload action with the unfiltered config, sized for one batch
|
||||||
|
verify { getEarnTokensBatchFlowUseCase(any(), TOP_EARN_TOKENS_BATCH_SIZE) }
|
||||||
|
val action = contextSlot.captured.actionsFlow.first() as BatchAction.Reload
|
||||||
|
assertThat(action.requestParams).isEqualTo(
|
||||||
|
EarnTokensListConfig(type = null, networks = null, isForEarn = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN top-earn batch fails to load WHEN advanced THEN error resolved and no suggestions shown`() =
|
||||||
|
runTest {
|
||||||
|
// Arrange
|
||||||
|
val failure = RuntimeException("network down")
|
||||||
|
every { earnErrorResolver.resolve(failure) } returns EarnError.NotHttpError()
|
||||||
|
stubTopEarnTokens(status = PaginationStatus.InitialLoadingError(failure))
|
||||||
|
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null)
|
||||||
|
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf())
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val model = createModel(testScope = this)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
verify { earnErrorResolver.resolve(failure) }
|
||||||
|
val earn = model.uiState.value.earnOpportunities as EarnOpportunitiesUM.Content
|
||||||
|
assertThat(earn.tokenList).isEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
|
|
@ -127,10 +238,7 @@ internal class ForYouModelTest {
|
||||||
fun `GIVEN asset row clicked WHEN clicked again THEN isExpanded toggles back to false`() = runTest {
|
fun `GIVEN asset row clicked WHEN clicked again THEN isExpanded toggles back to false`() = runTest {
|
||||||
// Arrange
|
// Arrange
|
||||||
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
|
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
|
||||||
stubSelectedWallet(
|
stubSelectedWallet(currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))))
|
||||||
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
|
|
||||||
totalFiatBalance = BigDecimal("100"),
|
|
||||||
)
|
|
||||||
val model = createModel(testScope = this)
|
val model = createModel(testScope = this)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
val initialContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
|
val initialContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
|
||||||
|
|
@ -162,22 +270,19 @@ internal class ForYouModelTest {
|
||||||
runTest {
|
runTest {
|
||||||
// Arrange
|
// Arrange
|
||||||
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
|
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
|
||||||
stubSelectedWallet(
|
stubSelectedWallet(currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))))
|
||||||
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
|
|
||||||
totalFiatBalance = BigDecimal("100"),
|
|
||||||
)
|
|
||||||
val model = createModel(testScope = this)
|
val model = createModel(testScope = this)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
val contentBefore = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
|
val stateBefore = model.uiState.value
|
||||||
val weekItem = contentBefore.periodPickerUM.items[1]
|
val weekItem = stateBefore.periodPickerUM.items[1]
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
contentBefore.onPeriodClick(weekItem)
|
stateBefore.onPeriodClick(weekItem)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
val contentAfter = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
|
val stateAfter = model.uiState.value
|
||||||
assertThat(contentAfter.periodPickerUM.initialSelectedItem).isEqualTo(weekItem)
|
assertThat(stateAfter.periodPickerUM.initialSelectedItem).isEqualTo(weekItem)
|
||||||
assertThat(contentAfter.tokenList).isEqualTo(contentBefore.tokenList)
|
assertThat(stateAfter.portfolioReviewUM).isEqualTo(stateBefore.portfolioReviewUM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,18 +292,51 @@ internal class ForYouModelTest {
|
||||||
/** Wires the repository + supplier so the model derives Content from a single selected wallet. */
|
/** Wires the repository + supplier so the model derives Content from a single selected wallet. */
|
||||||
private fun stubSelectedWallet(
|
private fun stubSelectedWallet(
|
||||||
currencies: List<CryptoCurrencyStatus>,
|
currencies: List<CryptoCurrencyStatus>,
|
||||||
totalFiatBalance: BigDecimal,
|
|
||||||
source: StatusSource = StatusSource.ACTUAL,
|
source: StatusSource = StatusSource.ACTUAL,
|
||||||
) {
|
) {
|
||||||
val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"))
|
val wallet = MockUserWalletFactory.create().copy(walletId = WALLET_ID)
|
||||||
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet)
|
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet)
|
||||||
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(
|
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(
|
||||||
linkedMapOf(
|
linkedMapOf(
|
||||||
wallet.walletId to createAccountStatusList(currencies, totalFiatBalance, source),
|
wallet.walletId to createAccountStatusList(currencies, source),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun stubTopEarnTokens(
|
||||||
|
status: PaginationStatus<List<EarnTokenWithCurrency>>,
|
||||||
|
suggestions: List<EarnTokenWithCurrency> = emptyList(),
|
||||||
|
) {
|
||||||
|
val batches = if (suggestions.isEmpty()) emptyList() else listOf(Batch(key = 0, data = suggestions))
|
||||||
|
val batchFlow: EarnTokensBatchFlow = mockk {
|
||||||
|
every { state } returns MutableStateFlow(BatchListState(data = batches, status = status))
|
||||||
|
}
|
||||||
|
every { getEarnTokensBatchFlowUseCase(any(), any()) } returns batchFlow
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A yield-type suggestion (7.5% on Solana) the user does not hold yet. */
|
||||||
|
private fun createTopEarnSuggestion(): EarnTokenWithCurrency = EarnTokenWithCurrency(
|
||||||
|
networkName = "Solana",
|
||||||
|
earnToken = EarnToken(
|
||||||
|
apy = "7.5",
|
||||||
|
networkId = "solana",
|
||||||
|
rewardType = EarnRewardType.APY,
|
||||||
|
type = EarnType.YIELD,
|
||||||
|
tokenId = "solana",
|
||||||
|
tokenSymbol = "SOL",
|
||||||
|
tokenName = "Solana",
|
||||||
|
tokenAddress = null,
|
||||||
|
decimalCount = null,
|
||||||
|
),
|
||||||
|
cryptoCurrency = createCoin(
|
||||||
|
rawCurrencyId = "solana",
|
||||||
|
symbol = "SOL",
|
||||||
|
name = "Solana",
|
||||||
|
networkRawId = "solana",
|
||||||
|
decimals = 9,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
private fun createModel(testScope: TestScope): ForYouModel {
|
private fun createModel(testScope: TestScope): ForYouModel {
|
||||||
return ForYouModel(
|
return ForYouModel(
|
||||||
paramsContainer = MutableParamsContainer(
|
paramsContainer = MutableParamsContainer(
|
||||||
|
|
@ -210,8 +348,13 @@ internal class ForYouModelTest {
|
||||||
),
|
),
|
||||||
userWalletsListRepository = userWalletsListRepository,
|
userWalletsListRepository = userWalletsListRepository,
|
||||||
multiAccountStatusListSupplier = multiAccountStatusListSupplier,
|
multiAccountStatusListSupplier = multiAccountStatusListSupplier,
|
||||||
|
yieldSupplyApyFlowUseCase = yieldSupplyApyFlowUseCase,
|
||||||
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
||||||
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
||||||
|
getEarnTokensBatchFlowUseCase = getEarnTokensBatchFlowUseCase,
|
||||||
|
stakingAvailabilityListUseCase = stakingAvailabilityListUseCase,
|
||||||
|
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
|
||||||
|
earnErrorResolver = earnErrorResolver,
|
||||||
).also { model = it }
|
).also { model = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,14 +371,20 @@ internal class ForYouModelTest {
|
||||||
|
|
||||||
private fun createAccountStatusList(
|
private fun createAccountStatusList(
|
||||||
currencies: List<CryptoCurrencyStatus>,
|
currencies: List<CryptoCurrencyStatus>,
|
||||||
totalFiatBalance: BigDecimal,
|
|
||||||
source: StatusSource = StatusSource.ACTUAL,
|
source: StatusSource = StatusSource.ACTUAL,
|
||||||
): AccountStatusList = mockk {
|
): AccountStatusList = mockk {
|
||||||
every { flattenCurrencies() } returns currencies
|
every { flattenCurrencies() } returns currencies
|
||||||
every { this@mockk.totalFiatBalance } returns TotalFiatBalance.Loaded(
|
every { this@mockk.totalFiatBalance } returns TotalFiatBalance.Loaded(
|
||||||
amount = totalFiatBalance,
|
amount = currencies.sumOf { it.value.fiatAmount ?: BigDecimal.ZERO },
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
|
every { userWalletId } returns WALLET_ID
|
||||||
|
every { accountStatuses } returns listOf(
|
||||||
|
mockk<AccountStatus.CryptoPortfolio> {
|
||||||
|
every { flattenCurrencies() } returns currencies
|
||||||
|
every { account } returns MockAccounts.createAccount(derivationIndex = 1)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
|
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
|
||||||
|
|
@ -248,14 +397,18 @@ internal class ForYouModelTest {
|
||||||
every { this@mockk.fiatAmount } returns fiatAmount
|
every { this@mockk.fiatAmount } returns fiatAmount
|
||||||
every { isError } returns false
|
every { isError } returns false
|
||||||
every { sources } returns CryptoCurrencyStatus.Sources()
|
every { sources } returns CryptoCurrencyStatus.Sources()
|
||||||
|
every { yieldSupplyStatus } returns null
|
||||||
|
every { stakingBalance } returns null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createCoin(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin {
|
private fun createCoin(
|
||||||
val network: Network = mockk {
|
rawCurrencyId: String,
|
||||||
every { name } returns "Network"
|
symbol: String,
|
||||||
every { isTestnet } returns false
|
name: String = symbol,
|
||||||
every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) }
|
networkRawId: String = rawCurrencyId,
|
||||||
}
|
decimals: Int = 8,
|
||||||
|
): CryptoCurrency.Coin {
|
||||||
|
val network = createNetwork(networkRawId)
|
||||||
val currencyId: CryptoCurrency.ID = mockk {
|
val currencyId: CryptoCurrency.ID = mockk {
|
||||||
every { value } returns "coin-$rawCurrencyId"
|
every { value } returns "coin-$rawCurrencyId"
|
||||||
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
|
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
|
||||||
|
|
@ -263,11 +416,31 @@ internal class ForYouModelTest {
|
||||||
return mockk<CryptoCurrency.Coin> {
|
return mockk<CryptoCurrency.Coin> {
|
||||||
every { this@mockk.id } returns currencyId
|
every { this@mockk.id } returns currencyId
|
||||||
every { this@mockk.symbol } returns symbol
|
every { this@mockk.symbol } returns symbol
|
||||||
every { this@mockk.name } returns symbol
|
every { this@mockk.name } returns name
|
||||||
every { this@mockk.network } returns network
|
every { this@mockk.network } returns network
|
||||||
every { this@mockk.decimals } returns 8
|
every { this@mockk.decimals } returns decimals
|
||||||
every { isCustom } returns false
|
every { isCustom } returns false
|
||||||
every { iconUrl } returns null
|
every { iconUrl } returns null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createNetwork(networkRawId: String): Network {
|
||||||
|
val networkId: Network.ID = mockk {
|
||||||
|
every { rawId } returns Network.RawID(networkRawId)
|
||||||
|
}
|
||||||
|
val networkStandardType: Network.StandardType = mockk {
|
||||||
|
every { name } returns "ERC20"
|
||||||
|
}
|
||||||
|
return mockk {
|
||||||
|
every { name } returns "Network"
|
||||||
|
every { isTestnet } returns false
|
||||||
|
every { rawId } returns networkRawId
|
||||||
|
every { id } returns networkId
|
||||||
|
every { standardType } returns networkStandardType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val WALLET_ID = UserWalletId("01")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,300 +0,0 @@
|
||||||
package com.tangem.features.foryou.impl.model.converter
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
|
||||||
import com.tangem.core.ui.extensions.pluralReference
|
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
|
||||||
import com.tangem.core.ui.extensions.stringReference
|
|
||||||
import com.tangem.core.ui.extensions.wrappedList
|
|
||||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
|
||||||
import com.tangem.domain.models.network.Network
|
|
||||||
import com.tangem.features.foryou.impl.R
|
|
||||||
import io.mockk.every
|
|
||||||
import io.mockk.mockk
|
|
||||||
import org.junit.jupiter.api.Nested
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
internal class ForYouTokenListConverterTest {
|
|
||||||
|
|
||||||
private val appCurrency: AppCurrency = AppCurrency.Default
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class Convert {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN single-network coin WHEN convert THEN subtitle is common main network`() {
|
|
||||||
// Arrange
|
|
||||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
|
||||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
|
||||||
val converter = createConverter(totalFiatBalance = BigDecimal("100"))
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = converter.convert(listOf(status))
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
val row = result.single().tokenRowUM as TangemTokenRowUM.Content
|
|
||||||
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
|
||||||
assertThat(subtitle.text).isEqualTo(resourceReference(R.string.common_main_network))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN single-network token WHEN convert THEN subtitle is the network standard type name`() {
|
|
||||||
// Arrange
|
|
||||||
val currency = createToken(
|
|
||||||
rawCurrencyId = "usdc",
|
|
||||||
symbol = "USDC",
|
|
||||||
networkId = "ethereum",
|
|
||||||
standardTypeName = "ERC20",
|
|
||||||
)
|
|
||||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
|
||||||
val converter = createConverter(totalFiatBalance = BigDecimal("100"))
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = converter.convert(listOf(status))
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
val row = result.single().tokenRowUM as TangemTokenRowUM.Content
|
|
||||||
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
|
||||||
assertThat(subtitle.text).isEqualTo(stringReference("ERC20"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN asset spans multiple networks WHEN convert THEN subtitle shows network count`() {
|
|
||||||
// Arrange — same asset (shared rawCurrencyId) on two different networks
|
|
||||||
val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum")
|
|
||||||
val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana")
|
|
||||||
val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
|
||||||
val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("200")))
|
|
||||||
val converter = createConverter(
|
|
||||||
totalFiatBalance = BigDecimal("300"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = converter.convert(listOf(statusEth, statusSol))
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
val item = result.single()
|
|
||||||
val row = item.tokenRowUM as TangemTokenRowUM.Content
|
|
||||||
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
|
||||||
assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_networks_count, count = 2))
|
|
||||||
assertThat(item.tokenList).hasSize(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN multi-network asset WHEN convert THEN child rows ordered by descending fiat balance`() {
|
|
||||||
// Arrange
|
|
||||||
val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum")
|
|
||||||
val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana")
|
|
||||||
val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
|
||||||
val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("500")))
|
|
||||||
val converter = createConverter(
|
|
||||||
totalFiatBalance = BigDecimal("600"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = converter.convert(listOf(statusEth, statusSol))
|
|
||||||
|
|
||||||
// Assert — Solana holding (500) ranks above Ethereum holding (100)
|
|
||||||
val childIds = result.single().tokenList.map { it.id }
|
|
||||||
assertThat(childIds).containsExactly("token-usdc-solana", "token-usdc-ethereum").inOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN all statuses of an asset are Loading WHEN convert THEN asset row is Loading`() {
|
|
||||||
// Arrange
|
|
||||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
|
||||||
val status = createStatus(currency, CryptoCurrencyStatus.Loading)
|
|
||||||
val converter = createConverter(totalFiatBalance = BigDecimal.ZERO)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = converter.convert(listOf(status))
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertThat(result.single().tokenRowUM).isInstanceOf(TangemTokenRowUM.Loading::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN no other assets WHEN convert THEN no Other row is appended`() {
|
|
||||||
// Arrange
|
|
||||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
|
||||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
|
||||||
val converter = createConverter(
|
|
||||||
totalFiatBalance = BigDecimal("100"),
|
|
||||||
otherAssets = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = converter.convert(listOf(status))
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertThat(result).hasSize(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN a single other asset WHEN convert THEN Other row subtitle is singular`() {
|
|
||||||
// Arrange
|
|
||||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
|
||||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
|
||||||
val converter = createConverter(
|
|
||||||
totalFiatBalance = BigDecimal("100"),
|
|
||||||
otherAssets = listOf(otherAsset(BigDecimal("50"))),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = converter.convert(listOf(status))
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content
|
|
||||||
assertThat(otherRow.id).isEqualTo("for_you_other_assets")
|
|
||||||
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
|
||||||
assertThat(subtitle.text).isEqualTo(
|
|
||||||
pluralReference(R.plurals.market_chart_assets_android, count = 1, formatArgs = wrappedList(1)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN more than one other asset WHEN convert THEN Other row subtitle is plural`() {
|
|
||||||
// Arrange
|
|
||||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
|
||||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
|
||||||
val converter = createConverter(
|
|
||||||
totalFiatBalance = BigDecimal("100"),
|
|
||||||
otherAssets = listOf(
|
|
||||||
otherAsset(BigDecimal("30")),
|
|
||||||
otherAsset(BigDecimal("15")),
|
|
||||||
otherAsset(BigDecimal("5")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = converter.convert(listOf(status))
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content
|
|
||||||
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
|
||||||
assertThat(subtitle.text).isEqualTo(
|
|
||||||
pluralReference(R.plurals.market_chart_assets_android, count = 3, formatArgs = wrappedList(3)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN asset id in expandedAssetIds WHEN convert THEN item isExpanded is true`() {
|
|
||||||
// Arrange
|
|
||||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
|
||||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
|
||||||
val converter = createConverter(
|
|
||||||
totalFiatBalance = BigDecimal("100"),
|
|
||||||
expandedAssetIds = setOf("bitcoin"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = converter.convert(listOf(status))
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertThat(result.single().isExpanded).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN asset id not in expandedAssetIds WHEN convert THEN item isExpanded is false`() {
|
|
||||||
// Arrange
|
|
||||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
|
||||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
|
||||||
val converter = createConverter(
|
|
||||||
totalFiatBalance = BigDecimal("100"),
|
|
||||||
expandedAssetIds = emptySet(),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = converter.convert(listOf(status))
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertThat(result.single().isExpanded).isFalse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createConverter(
|
|
||||||
totalFiatBalance: BigDecimal,
|
|
||||||
expandedAssetIds: Set<String> = emptySet(),
|
|
||||||
otherAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>> = emptyList(),
|
|
||||||
): ForYouTokenListConverter = ForYouTokenListConverter(
|
|
||||||
appCurrency = appCurrency,
|
|
||||||
totalFiatBalance = totalFiatBalance,
|
|
||||||
expandedAssetIds = expandedAssetIds,
|
|
||||||
expandClick = {},
|
|
||||||
otherAssets = otherAssets,
|
|
||||||
onTokenClick = {},
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds an "other" asset entry — only its summed [balance] and the number of entries drive the
|
|
||||||
* collapsed "Other" row, so the currency list is left empty.
|
|
||||||
*/
|
|
||||||
private fun otherAsset(balance: BigDecimal): Pair<List<CryptoCurrencyStatus>, BigDecimal> =
|
|
||||||
emptyList<CryptoCurrencyStatus>() to balance
|
|
||||||
|
|
||||||
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
|
|
||||||
currency = currency,
|
|
||||||
value = value,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk {
|
|
||||||
every { this@mockk.amount } returns amount
|
|
||||||
every { this@mockk.fiatAmount } returns fiatAmount
|
|
||||||
every { isError } returns false
|
|
||||||
every { sources } returns CryptoCurrencyStatus.Sources()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createCoin(rawCurrencyId: String, symbol: String, networkId: String): CryptoCurrency.Coin {
|
|
||||||
val network = createNetwork(networkId = networkId, standardTypeName = "MAIN")
|
|
||||||
val currencyId = createCurrencyId(idValue = "coin-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId)
|
|
||||||
return mockk<CryptoCurrency.Coin> {
|
|
||||||
every { this@mockk.id } returns currencyId
|
|
||||||
every { this@mockk.symbol } returns symbol
|
|
||||||
every { this@mockk.network } returns network
|
|
||||||
every { this@mockk.decimals } returns 8
|
|
||||||
every { isCustom } returns false
|
|
||||||
every { iconUrl } returns null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createToken(
|
|
||||||
rawCurrencyId: String,
|
|
||||||
symbol: String,
|
|
||||||
networkId: String,
|
|
||||||
standardTypeName: String = "ERC20",
|
|
||||||
): CryptoCurrency.Token {
|
|
||||||
val network = createNetwork(networkId = networkId, standardTypeName = standardTypeName)
|
|
||||||
val currencyId = createCurrencyId(idValue = "token-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId)
|
|
||||||
return mockk<CryptoCurrency.Token> {
|
|
||||||
every { this@mockk.id } returns currencyId
|
|
||||||
every { this@mockk.symbol } returns symbol
|
|
||||||
every { this@mockk.network } returns network
|
|
||||||
every { this@mockk.decimals } returns 6
|
|
||||||
every { isCustom } returns false
|
|
||||||
every { iconUrl } returns null
|
|
||||||
every { contractAddress } returns "0xCONTRACT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createCurrencyId(idValue: String, rawCurrencyId: String): CryptoCurrency.ID = mockk {
|
|
||||||
every { value } returns idValue
|
|
||||||
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNetwork(networkId: String, standardTypeName: String): Network {
|
|
||||||
val standardType: Network.StandardType = mockk {
|
|
||||||
every { name } returns standardTypeName
|
|
||||||
}
|
|
||||||
return mockk {
|
|
||||||
every { id } returns mockk {
|
|
||||||
every { rawId } returns Network.RawID(networkId)
|
|
||||||
}
|
|
||||||
every { name } returns networkId
|
|
||||||
every { isTestnet } returns false
|
|
||||||
every { this@mockk.standardType } returns standardType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,7 @@ import org.junit.jupiter.api.Nested
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
internal class ForYouPortfolioFormattersTest {
|
internal class ForYouUtilsTest {
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
inner class ForYouGroupKey {
|
inner class ForYouGroupKey {
|
||||||
|
|
@ -148,12 +148,13 @@ internal fun createEarnStatusValue(
|
||||||
fiatAmount: BigDecimal = BigDecimal("100"),
|
fiatAmount: BigDecimal = BigDecimal("100"),
|
||||||
yieldSupplyActive: Boolean? = null,
|
yieldSupplyActive: Boolean? = null,
|
||||||
isStakingActive: Boolean = false,
|
isStakingActive: Boolean = false,
|
||||||
|
stakingBalance: StakingBalance? = if (isStakingActive) mockk<StakingBalance.Data.P2PEthPool>() else null,
|
||||||
): CryptoCurrencyStatus.Loaded = mockk {
|
): CryptoCurrencyStatus.Loaded = mockk {
|
||||||
every { this@mockk.fiatAmount } returns fiatAmount
|
every { this@mockk.fiatAmount } returns fiatAmount
|
||||||
every { yieldSupplyStatus } returns yieldSupplyActive?.let { active ->
|
every { yieldSupplyStatus } returns yieldSupplyActive?.let { active ->
|
||||||
mockk<YieldSupplyStatus> { every { isActive } returns active }
|
mockk<YieldSupplyStatus> { every { isActive } returns active }
|
||||||
}
|
}
|
||||||
every { stakingBalance } returns if (isStakingActive) mockk<StakingBalance.Data.P2PEthPool>() else null
|
every { this@mockk.stakingBalance } returns stakingBalance
|
||||||
every { isError } returns false
|
every { isError } returns false
|
||||||
every { sources } returns CryptoCurrencyStatus.Sources()
|
every { sources } returns CryptoCurrencyStatus.Sources()
|
||||||
}
|
}
|
||||||
|
|
@ -165,13 +166,13 @@ internal fun createStatus(
|
||||||
|
|
||||||
internal fun createEarnOpportunities(
|
internal fun createEarnOpportunities(
|
||||||
account: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 1),
|
account: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 1),
|
||||||
earnCurrencues: Map<CryptoCurrencyStatus, EarnApyInfo> = mapOf(
|
earnCurrencies: Map<CryptoCurrencyStatus, EarnApyInfo> = mapOf(
|
||||||
createStatus(createEarnCurrency()) to createEarnApyInfo(),
|
createStatus(createEarnCurrency()) to createEarnApyInfo(),
|
||||||
),
|
),
|
||||||
accountPotentialReward: BigDecimal = BigDecimal.ZERO,
|
accountPotentialReward: BigDecimal = BigDecimal.ZERO,
|
||||||
): EarnOpportunities = EarnOpportunities(
|
): EarnOpportunities = EarnOpportunities(
|
||||||
account = account,
|
account = account,
|
||||||
earnCurrencues = earnCurrencues,
|
earnCurrencies = earnCurrencies,
|
||||||
accountPotentialReward = accountPotentialReward,
|
accountPotentialReward = accountPotentialReward,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,27 @@ package com.tangem.features.foryou.impl.model.converter.earnOpportunities
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import com.tangem.common.ui.R
|
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.resourceReference
|
||||||
import com.tangem.core.ui.extensions.wrappedList
|
import com.tangem.core.ui.extensions.wrappedList
|
||||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||||
import com.tangem.core.ui.format.bigdecimal.format
|
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.appcurrency.model.AppCurrency
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.currency.yieldSupplyKey
|
import com.tangem.domain.models.currency.yieldSupplyKey
|
||||||
import com.tangem.domain.models.earn.EarnTopToken
|
import com.tangem.domain.models.earn.EarnTopToken
|
||||||
|
import com.tangem.domain.models.staking.BalanceItem
|
||||||
|
import com.tangem.domain.models.staking.StakingBalance
|
||||||
import com.tangem.domain.staking.model.StakingAvailability
|
import com.tangem.domain.staking.model.StakingAvailability
|
||||||
|
import com.tangem.domain.staking.model.StakingIntegrationID
|
||||||
import com.tangem.domain.staking.model.StakingOption
|
import com.tangem.domain.staking.model.StakingOption
|
||||||
|
import com.tangem.domain.staking.model.common.RewardInfo
|
||||||
|
import com.tangem.domain.staking.model.common.RewardType
|
||||||
|
import com.tangem.domain.staking.model.stakekit.Yield
|
||||||
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
|
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
|
||||||
|
import com.tangem.test.mock.MockAccounts
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import org.junit.jupiter.api.Nested
|
import org.junit.jupiter.api.Nested
|
||||||
|
|
@ -193,6 +203,74 @@ internal class ForYouEarnOpportunitiesConverterTest {
|
||||||
.isEqualTo(expectedPerYearReward(fiat = BigDecimal("100"), yieldPercent = BigDecimal("10.00")))
|
.isEqualTo(expectedPerYearReward(fiat = BigDecimal("100"), yieldPercent = BigDecimal("10.00")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN active StakeKit stake WHEN convert THEN the staked validator's rate wins over best preferred`() {
|
||||||
|
// Arrange — the user stakes with v2 (4%), while the best preferred validator offers 10%
|
||||||
|
val staked = createEarnCurrency(tokenId = "ethereum", currencyId = "coin-staked")
|
||||||
|
val stakedStatus = createStatus(
|
||||||
|
staked,
|
||||||
|
createEarnStatusValue(fiatAmount = BigDecimal("100"), stakingBalance = stakeKitBalance("v2")),
|
||||||
|
)
|
||||||
|
val fresh = createEarnCurrency(tokenId = "solana", currencyId = "coin-fresh")
|
||||||
|
val freshStatus = createStatus(fresh, createEarnStatusValue(fiatAmount = BigDecimal("100")))
|
||||||
|
val converter = createConverter(
|
||||||
|
yieldStakingAvailability = mapOf(
|
||||||
|
staked to stakeKitAvailable(
|
||||||
|
validator(address = "v1", preferred = true, rate = BigDecimal("0.10")),
|
||||||
|
validator(address = "v2", preferred = false, rate = BigDecimal("0.04")),
|
||||||
|
),
|
||||||
|
fresh to stakingAvailable(apy = BigDecimal("0.05")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = converter.convert(
|
||||||
|
createAccountStatusList(createPortfolioStatus(listOf(stakedStatus, freshStatus))),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert — the staked token's row shows the 4% of the validator actually staked with
|
||||||
|
assertThat((result as EarnOpportunitiesUM.Content).rateOfRow("coin-staked"))
|
||||||
|
.isEqualTo(BigDecimal("0.04").format { percent() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN stake with unknown validator WHEN convert THEN falls back to best preferred validator rate`() {
|
||||||
|
// Arrange — the staked validator is not among the option's validators
|
||||||
|
val staked = createEarnCurrency(tokenId = "ethereum", currencyId = "coin-staked")
|
||||||
|
val stakedStatus = createStatus(
|
||||||
|
staked,
|
||||||
|
createEarnStatusValue(fiatAmount = BigDecimal("100"), stakingBalance = stakeKitBalance("unknown")),
|
||||||
|
)
|
||||||
|
val fresh = createEarnCurrency(tokenId = "solana", currencyId = "coin-fresh")
|
||||||
|
val freshStatus = createStatus(fresh, createEarnStatusValue(fiatAmount = BigDecimal("100")))
|
||||||
|
val converter = createConverter(
|
||||||
|
yieldStakingAvailability = mapOf(
|
||||||
|
staked to stakeKitAvailable(
|
||||||
|
validator(address = "v1", preferred = true, rate = BigDecimal("0.10")),
|
||||||
|
validator(address = "v2", preferred = true, rate = BigDecimal("0.12")),
|
||||||
|
validator(address = "v3", preferred = false, rate = BigDecimal("0.50")),
|
||||||
|
),
|
||||||
|
fresh to stakingAvailable(apy = BigDecimal("0.05")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = converter.convert(
|
||||||
|
createAccountStatusList(createPortfolioStatus(listOf(stakedStatus, freshStatus))),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert — the best *preferred* rate (12%) is used; the non-preferred 50% is ignored
|
||||||
|
assertThat((result as EarnOpportunitiesUM.Content).rateOfRow("coin-staked"))
|
||||||
|
.isEqualTo(BigDecimal("0.12").format { percent() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts the rendered rate (the styled bottom-end text) of the row with the given [id]. */
|
||||||
|
private fun EarnOpportunitiesUM.Content.rateOfRow(id: String): String {
|
||||||
|
val row = tokenList.first { it.tokenRowUM.id == id }.tokenRowUM as TangemTokenRowUM.Content
|
||||||
|
val bottomEnd = row.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
|
||||||
|
return (bottomEnd.text as TextReference.StyledStr).value
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN staking-only token WHEN convert THEN staking rate is used for the reward`() {
|
fun `GIVEN staking-only token WHEN convert THEN staking rate is used for the reward`() {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|
@ -212,6 +290,39 @@ internal class ForYouEarnOpportunitiesConverterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
inner class AccountOrdering {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN several accounts WHEN convert THEN accounts ordered by potential reward descending`() {
|
||||||
|
// Arrange — same 5% rate; the second account holds more fiat (200 vs 100), so it earns more
|
||||||
|
val smallHolding = createEarnCurrency(tokenId = "ethereum", currencyId = "coin-eth")
|
||||||
|
val largeHolding = createEarnCurrency(tokenId = "solana", currencyId = "coin-sol")
|
||||||
|
val smallAccount = createPortfolioStatus(
|
||||||
|
currencies = listOf(createStatus(smallHolding, createEarnStatusValue(fiatAmount = BigDecimal("100")))),
|
||||||
|
account = MockAccounts.createAccount(derivationIndex = 1),
|
||||||
|
)
|
||||||
|
val largeAccount = createPortfolioStatus(
|
||||||
|
currencies = listOf(createStatus(largeHolding, createEarnStatusValue(fiatAmount = BigDecimal("200")))),
|
||||||
|
account = MockAccounts.createAccount(derivationIndex = 2),
|
||||||
|
)
|
||||||
|
val converter = createConverter(
|
||||||
|
yieldStakingAvailability = mapOf(
|
||||||
|
smallHolding to stakingAvailable(apy = BigDecimal("0.05")),
|
||||||
|
largeHolding to stakingAvailable(apy = BigDecimal("0.05")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = converter.convert(createAccountStatusList(smallAccount, largeAccount))
|
||||||
|
|
||||||
|
// Assert — the higher-earning account's token leads the flat list
|
||||||
|
assertThat((result as EarnOpportunitiesUM.Content).tokenList.map { it.tokenRowUM.id })
|
||||||
|
.containsExactly("coin-sol", "coin-eth")
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createConverter(
|
private fun createConverter(
|
||||||
yieldSupplyAvailability: Map<String, BigDecimal> = emptyMap(),
|
yieldSupplyAvailability: Map<String, BigDecimal> = emptyMap(),
|
||||||
yieldStakingAvailability: Map<CryptoCurrency, StakingAvailability> = emptyMap(),
|
yieldStakingAvailability: Map<CryptoCurrency, StakingAvailability> = emptyMap(),
|
||||||
|
|
@ -234,6 +345,39 @@ internal class ForYouEarnOpportunitiesConverterTest {
|
||||||
private fun stakingAvailable(apy: BigDecimal): StakingAvailability =
|
private fun stakingAvailable(apy: BigDecimal): StakingAvailability =
|
||||||
StakingAvailability.Available(option = stakingOption(apy))
|
StakingAvailability.Available(option = stakingOption(apy))
|
||||||
|
|
||||||
|
private fun stakeKitAvailable(vararg validatorList: Yield.Validator): StakingAvailability {
|
||||||
|
// A real StakeKit option with a real integration id: stubbing `integrationId` on a mock would
|
||||||
|
// make mockk instrument StakingIntegrationID.StakeKit, whose implementations are enums that the
|
||||||
|
// JVM refuses to retransform ("cannot change the class modifiers").
|
||||||
|
val yieldModel: Yield = mockk {
|
||||||
|
every { validators } returns validatorList.toList()
|
||||||
|
every { apy } returns BigDecimal("0.10")
|
||||||
|
every { token } returns mockk()
|
||||||
|
every { isAvailable } returns true
|
||||||
|
}
|
||||||
|
return StakingAvailability.Available(
|
||||||
|
option = StakingOption.StakeKit(
|
||||||
|
integrationId = StakingIntegrationID.StakeKit.Coin.Ton,
|
||||||
|
yield = yieldModel,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validator(address: String, preferred: Boolean, rate: BigDecimal): Yield.Validator = mockk {
|
||||||
|
every { this@mockk.address } returns address
|
||||||
|
every { this@mockk.preferred } returns preferred
|
||||||
|
every { rewardInfo } returns RewardInfo(rate = rate, type = RewardType.APY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An active StakeKit balance whose items point at the given validator addresses. */
|
||||||
|
private fun stakeKitBalance(vararg validatorAddresses: String?): StakingBalance.Data.StakeKit = mockk {
|
||||||
|
every { balance } returns mockk {
|
||||||
|
every { items } returns validatorAddresses.map { address ->
|
||||||
|
mockk<BalanceItem> { every { validatorAddress } returns address }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Mirrors the production reward computation: `fiat * (yieldPercent / 100)`, rendered per year. */
|
/** Mirrors the production reward computation: `fiat * (yieldPercent / 100)`, rendered per year. */
|
||||||
private fun expectedPerYearReward(fiat: BigDecimal, yieldPercent: BigDecimal) =
|
private fun expectedPerYearReward(fiat: BigDecimal, yieldPercent: BigDecimal) =
|
||||||
fiat.multiply(yieldPercent.divide(BigDecimal("100"), RoundingMode.HALF_UP)).expectedPerYearText()
|
fiat.multiply(yieldPercent.divide(BigDecimal("100"), RoundingMode.HALF_UP)).expectedPerYearText()
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,9 @@ internal class ForYouEarnOpportunitiesNoTokensConverterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN no top tokens loaded WHEN convert THEN suggestions are empty and reward type is absent`() {
|
fun `GIVEN no top tokens loaded WHEN convert THEN suggestions are empty and reward type is absent`() {
|
||||||
// Arrange
|
// Arrange
|
||||||
val converter = ForYouEarnOpportunitiesNoTokensConverter(topEarnTokens = null)
|
val converter = ForYouEarnOpportunitiesNoTokensConverter(
|
||||||
|
topEarnTokens = null,
|
||||||
|
)
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
val result = converter.convert(emptyList()) as EarnOpportunitiesUM.Content
|
val result = converter.convert(emptyList()) as EarnOpportunitiesUM.Content
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest {
|
||||||
fun `GIVEN accounts mode off WHEN convert THEN one flat row per earn currency`() {
|
fun `GIVEN accounts mode off WHEN convert THEN one flat row per earn currency`() {
|
||||||
// Arrange
|
// Arrange
|
||||||
val earnData = createEarnOpportunities(
|
val earnData = createEarnOpportunities(
|
||||||
earnCurrencues = listOf("token-a", "token-b").associate { currencyId ->
|
earnCurrencies = listOf("token-a", "token-b").associate { currencyId ->
|
||||||
createStatus(
|
createStatus(
|
||||||
createEarnCurrency(tokenId = currencyId, currencyId = currencyId),
|
createEarnCurrency(tokenId = currencyId, currencyId = currencyId),
|
||||||
createRowLoadedValue(),
|
createRowLoadedValue(),
|
||||||
|
|
@ -45,7 +45,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest {
|
||||||
val account = MockAccounts.createAccount(derivationIndex = 1, name = "Earn account")
|
val account = MockAccounts.createAccount(derivationIndex = 1, name = "Earn account")
|
||||||
val earnData = createEarnOpportunities(
|
val earnData = createEarnOpportunities(
|
||||||
account = account,
|
account = account,
|
||||||
earnCurrencues = listOf("token-a", "token-b").associate { currencyId ->
|
earnCurrencies = listOf("token-a", "token-b").associate { currencyId ->
|
||||||
createStatus(
|
createStatus(
|
||||||
createEarnCurrency(tokenId = currencyId, currencyId = currencyId),
|
createEarnCurrency(tokenId = currencyId, currencyId = currencyId),
|
||||||
createRowLoadedValue(),
|
createRowLoadedValue(),
|
||||||
|
|
@ -71,7 +71,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest {
|
||||||
val account = MockAccounts.createAccount(derivationIndex = 1)
|
val account = MockAccounts.createAccount(derivationIndex = 1)
|
||||||
val earnData = createEarnOpportunities(
|
val earnData = createEarnOpportunities(
|
||||||
account = account,
|
account = account,
|
||||||
earnCurrencues = mapOf(
|
earnCurrencies = mapOf(
|
||||||
createStatus(createEarnCurrency(), createRowLoadedValue()) to createEarnApyInfo(isActive = false),
|
createStatus(createEarnCurrency(), createRowLoadedValue()) to createEarnApyInfo(isActive = false),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -93,7 +93,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest {
|
||||||
val account = MockAccounts.createAccount(derivationIndex = 1)
|
val account = MockAccounts.createAccount(derivationIndex = 1)
|
||||||
val earnData = createEarnOpportunities(
|
val earnData = createEarnOpportunities(
|
||||||
account = account,
|
account = account,
|
||||||
earnCurrencues = mapOf(
|
earnCurrencies = mapOf(
|
||||||
createStatus(createEarnCurrency(), createRowLoadedValue()) to createEarnApyInfo(isActive = false),
|
createStatus(createEarnCurrency(), createRowLoadedValue()) to createEarnApyInfo(isActive = false),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest {
|
||||||
fun `GIVEN top tokens contain active portfolio assets WHEN convert THEN active ones are excluded`() {
|
fun `GIVEN top tokens contain active portfolio assets WHEN convert THEN active ones are excluded`() {
|
||||||
// Arrange
|
// Arrange
|
||||||
val activePortfolio = createEarnOpportunities(
|
val activePortfolio = createEarnOpportunities(
|
||||||
earnCurrencues = mapOf(
|
earnCurrencies = mapOf(
|
||||||
createStatus(createEarnCurrency(tokenId = "ethereum", networkRawId = "ETH")) to createEarnApyInfo(),
|
createStatus(createEarnCurrency(tokenId = "ethereum", networkRawId = "ETH")) to createEarnApyInfo(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -37,7 +37,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest {
|
||||||
fun `GIVEN more suggestions than the cap WHEN convert THEN filtering happens before the top-5 cut`() {
|
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
|
// Arrange — two of the first candidates are active; the cap must still be filled from the tail
|
||||||
val activePortfolio = createEarnOpportunities(
|
val activePortfolio = createEarnOpportunities(
|
||||||
earnCurrencues = listOf("token-0", "token-1").associate { tokenId ->
|
earnCurrencies = listOf("token-0", "token-1").associate { tokenId ->
|
||||||
createStatus(createEarnCurrency(tokenId = tokenId, networkRawId = "NET")) to createEarnApyInfo()
|
createStatus(createEarnCurrency(tokenId = tokenId, networkRawId = "NET")) to createEarnApyInfo()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -60,7 +60,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest {
|
||||||
fun `GIVEN asset active on another network WHEN convert THEN suggestion on a new network is kept`() {
|
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
|
// Arrange — matching is per asset AND network, not per asset
|
||||||
val activePortfolio = createEarnOpportunities(
|
val activePortfolio = createEarnOpportunities(
|
||||||
earnCurrencues = mapOf(
|
earnCurrencies = mapOf(
|
||||||
createStatus(createEarnCurrency(tokenId = "usd-coin", networkRawId = "ETH")) to createEarnApyInfo(),
|
createStatus(createEarnCurrency(tokenId = "usd-coin", networkRawId = "ETH")) to createEarnApyInfo(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -81,7 +81,9 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN no top tokens loaded WHEN convert THEN content with empty suggestions`() {
|
fun `GIVEN no top tokens loaded WHEN convert THEN content with empty suggestions`() {
|
||||||
// Arrange
|
// Arrange
|
||||||
val converter = ForYouEarnOpportunitiesTokensActiveConverter(topEarnTokens = null)
|
val converter = ForYouEarnOpportunitiesTokensActiveConverter(
|
||||||
|
topEarnTokens = null,
|
||||||
|
)
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
val result = converter.convert(listOf(createEarnOpportunities()))
|
val result = converter.convert(listOf(createEarnOpportunities()))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,458 @@
|
||||||
|
package com.tangem.features.foryou.impl.model.converter.portfolioReview
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||||
|
import com.tangem.core.ui.extensions.pluralReference
|
||||||
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
|
import com.tangem.core.ui.extensions.stringReference
|
||||||
|
import com.tangem.core.ui.extensions.wrappedList
|
||||||
|
import com.tangem.domain.account.models.AccountStatusList
|
||||||
|
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||||
|
import com.tangem.domain.models.StatusSource
|
||||||
|
import com.tangem.domain.models.TotalFiatBalance
|
||||||
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
|
import com.tangem.domain.models.network.Network
|
||||||
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
|
import com.tangem.features.foryou.impl.R
|
||||||
|
import com.tangem.features.foryou.impl.components.state.MarketChartUM
|
||||||
|
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.junit.jupiter.api.Nested
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
internal class ForYouPortfolioReviewConverterTest {
|
||||||
|
|
||||||
|
private val appCurrency: AppCurrency = AppCurrency.Default
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
inner class AssetRanking {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN currency with resolved zero fiat balance WHEN convert THEN it is dropped from the list`() {
|
||||||
|
// Arrange
|
||||||
|
val zeroBalance = createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin")
|
||||||
|
val nonZeroBalance = createCoin(rawCurrencyId = "eth", symbol = "ETH", networkId = "ethereum")
|
||||||
|
val statuses = listOf(
|
||||||
|
createStatus(zeroBalance, loadedValue(BigDecimal.ONE, BigDecimal.ZERO)),
|
||||||
|
createStatus(nonZeroBalance, loadedValue(BigDecimal.ONE, BigDecimal("100"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = convert(statuses, totalFiatBalance = BigDecimal("100"))
|
||||||
|
|
||||||
|
// Assert — only the ETH asset survives; the zero-fiat BTC is dropped
|
||||||
|
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN non-content status with null fiat WHEN convert THEN it is kept not dropped`() {
|
||||||
|
// Arrange — a non-content status (Unreachable) carries a null fiatAmount, not a resolved zero;
|
||||||
|
// it must still be shown so the user sees the token they hold, with the appropriate treatment.
|
||||||
|
val unreachable = createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin")
|
||||||
|
val loaded = createCoin(rawCurrencyId = "eth", symbol = "ETH", networkId = "ethereum")
|
||||||
|
val statuses = listOf(
|
||||||
|
createStatus(unreachable, unreachableValue()),
|
||||||
|
createStatus(loaded, loadedValue(BigDecimal.ONE, BigDecimal("100"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = convert(statuses, totalFiatBalance = BigDecimal("100"))
|
||||||
|
|
||||||
|
// Assert — both assets kept, ranked by summed fiat (eth 100 > btc 0)
|
||||||
|
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth", "btc").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN same asset across networks WHEN convert THEN aggregated into one asset ranked by summed fiat`() {
|
||||||
|
// Arrange — the same asset (shared rawCurrencyId "usdc") aggregates into one asset
|
||||||
|
val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum")
|
||||||
|
val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana")
|
||||||
|
val other = createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin")
|
||||||
|
val statuses = listOf(
|
||||||
|
createStatus(onEth, loadedValue(BigDecimal.ONE, BigDecimal("50"))),
|
||||||
|
createStatus(onSol, loadedValue(BigDecimal.ONE, BigDecimal("60"))),
|
||||||
|
createStatus(other, loadedValue(BigDecimal.ONE, BigDecimal("10"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = convert(statuses, totalFiatBalance = BigDecimal("120"))
|
||||||
|
|
||||||
|
// Assert — 2 ranked assets: usdc (110 total) ahead of btc (10)
|
||||||
|
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("usdc", "btc").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN more than four assets WHEN convert THEN excess assets collapse into Other`() {
|
||||||
|
// Arrange — 5 distinct assets, top 4 kept individually, 5th collapsed into "Other"
|
||||||
|
val statuses = (1..5).map { index ->
|
||||||
|
createStatus(
|
||||||
|
createCoin(rawCurrencyId = "asset-$index", symbol = "A$index", networkId = "net-$index"),
|
||||||
|
loadedValue(BigDecimal.ONE, BigDecimal(100 - index)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = convert(statuses, totalFiatBalance = BigDecimal("470"))
|
||||||
|
|
||||||
|
// Assert — 4 top asset rows + 1 "Other" row
|
||||||
|
assertThat(result.tokenList).hasSize(5)
|
||||||
|
assertThat(result.tokenList.last().tokenRowUM.id).isEqualTo("for_you_other_assets")
|
||||||
|
assertThat(result.tokenList.last().isExpandable).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN exactly four assets WHEN convert THEN no Other row is appended`() {
|
||||||
|
// Arrange
|
||||||
|
val statuses = (1..4).map { index ->
|
||||||
|
createStatus(
|
||||||
|
createCoin(rawCurrencyId = "asset-$index", symbol = "A$index", networkId = "net-$index"),
|
||||||
|
loadedValue(BigDecimal.ONE, BigDecimal(100 - index)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = convert(statuses, totalFiatBalance = BigDecimal("394"))
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result.tokenList).hasSize(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a single other asset WHEN convert THEN Other row subtitle is singular`() {
|
||||||
|
// Arrange — 5 assets: the lowest-balance one collapses into an "Other" row of count 1
|
||||||
|
val statuses = (1..5).map { index ->
|
||||||
|
createStatus(
|
||||||
|
createCoin(rawCurrencyId = "asset-$index", symbol = "A$index", networkId = "net-$index"),
|
||||||
|
loadedValue(BigDecimal.ONE, BigDecimal(100 - index)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = convert(statuses, totalFiatBalance = BigDecimal("470"))
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val otherRow = result.tokenList.last().tokenRowUM as TangemTokenRowUM.Content
|
||||||
|
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
||||||
|
assertThat(subtitle.text).isEqualTo(
|
||||||
|
pluralReference(R.plurals.market_chart_assets_android, count = 1, formatArgs = wrappedList(1)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN several other assets WHEN convert THEN Other row subtitle is plural`() {
|
||||||
|
// Arrange — 7 assets: three lowest-balance ones collapse into an "Other" row of count 3
|
||||||
|
val statuses = (1..7).map { index ->
|
||||||
|
createStatus(
|
||||||
|
createCoin(rawCurrencyId = "asset-$index", symbol = "A$index", networkId = "net-$index"),
|
||||||
|
loadedValue(BigDecimal.ONE, BigDecimal(100 - index)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = convert(statuses, totalFiatBalance = BigDecimal("658"))
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val otherRow = result.tokenList.last().tokenRowUM as TangemTokenRowUM.Content
|
||||||
|
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
||||||
|
assertThat(subtitle.text).isEqualTo(
|
||||||
|
pluralReference(R.plurals.market_chart_assets_android, count = 3, formatArgs = wrappedList(3)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN null account status list WHEN convert THEN token list is empty`() {
|
||||||
|
// Act
|
||||||
|
val result = createConverter().convert(null) as PortfolioReviewUM.Content
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result.tokenList).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
inner class AssetRow {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN single-network coin WHEN convert THEN subtitle is common main network`() {
|
||||||
|
// Arrange
|
||||||
|
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
||||||
|
val statuses = listOf(createStatus(currency, loadedValue(BigDecimal.ONE, BigDecimal("100"))))
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = convert(statuses, totalFiatBalance = BigDecimal("100"))
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val row = result.tokenList.single().tokenRowUM as TangemTokenRowUM.Content
|
||||||
|
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
||||||
|
assertThat(subtitle.text).isEqualTo(resourceReference(R.string.common_main_network))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN single-network token WHEN convert THEN subtitle is the network standard type name`() {
|
||||||
|
// Arrange
|
||||||
|
val currency = createToken(
|
||||||
|
rawCurrencyId = "usdc",
|
||||||
|
symbol = "USDC",
|
||||||
|
networkId = "ethereum",
|
||||||
|
standardTypeName = "ERC20",
|
||||||
|
)
|
||||||
|
val statuses = listOf(createStatus(currency, loadedValue(BigDecimal.ONE, BigDecimal("100"))))
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = convert(statuses, totalFiatBalance = BigDecimal("100"))
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val row = result.tokenList.single().tokenRowUM as TangemTokenRowUM.Content
|
||||||
|
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
||||||
|
assertThat(subtitle.text).isEqualTo(stringReference("ERC20"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN asset spans multiple networks WHEN convert THEN subtitle shows network count with child rows`() {
|
||||||
|
// Arrange — same asset (shared rawCurrencyId) on two different networks
|
||||||
|
val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum")
|
||||||
|
val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana")
|
||||||
|
val statuses = listOf(
|
||||||
|
createStatus(onEth, loadedValue(BigDecimal.ONE, BigDecimal("100"))),
|
||||||
|
createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("200"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = convert(statuses, totalFiatBalance = BigDecimal("300"))
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val item = result.tokenList.single()
|
||||||
|
val row = item.tokenRowUM as TangemTokenRowUM.Content
|
||||||
|
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
||||||
|
assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_networks_count, count = 2))
|
||||||
|
assertThat(item.tokenList).hasSize(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN multi-network asset WHEN convert THEN child rows ordered by descending fiat balance`() {
|
||||||
|
// Arrange
|
||||||
|
val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum")
|
||||||
|
val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana")
|
||||||
|
val statuses = listOf(
|
||||||
|
createStatus(onEth, loadedValue(BigDecimal.ONE, BigDecimal("100"))),
|
||||||
|
createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("500"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = convert(statuses, totalFiatBalance = BigDecimal("600"))
|
||||||
|
|
||||||
|
// Assert — Solana holding (500) ranks above Ethereum holding (100)
|
||||||
|
val childIds = result.tokenList.single().tokenList.map { it.id }
|
||||||
|
assertThat(childIds).containsExactly("token-usdc-solana", "token-usdc-ethereum").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN all statuses of an asset are Loading WHEN convert THEN asset row is Loading`() {
|
||||||
|
// Arrange
|
||||||
|
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
||||||
|
val statuses = listOf(createStatus(currency, CryptoCurrencyStatus.Loading))
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = convert(statuses, totalFiatBalance = BigDecimal.ZERO)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result.tokenList.single().tokenRowUM).isInstanceOf(TangemTokenRowUM.Loading::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN asset id in expandedAssetIds WHEN convert THEN item isExpanded is true`() {
|
||||||
|
// Arrange
|
||||||
|
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
||||||
|
val statuses = listOf(createStatus(currency, loadedValue(BigDecimal.ONE, BigDecimal("100"))))
|
||||||
|
val converter = createConverter(expandedAssetIds = setOf("bitcoin"))
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = converter.convert(
|
||||||
|
accountStatusList(statuses, BigDecimal("100")),
|
||||||
|
) as PortfolioReviewUM.Content
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result.tokenList.single().isExpanded).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN asset row clicked WHEN convert THEN expand callback receives the asset id`() {
|
||||||
|
// Arrange
|
||||||
|
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
||||||
|
val statuses = listOf(createStatus(currency, loadedValue(BigDecimal.ONE, BigDecimal("100"))))
|
||||||
|
var clickedAssetId: String? = null
|
||||||
|
val converter = createConverter(expandClick = { clickedAssetId = it })
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = converter.convert(
|
||||||
|
accountStatusList(statuses, BigDecimal("100")),
|
||||||
|
) as PortfolioReviewUM.Content
|
||||||
|
(result.tokenList.single().tokenRowUM as TangemTokenRowUM.Content).onItemClick?.invoke()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(clickedAssetId).isEqualTo("bitcoin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
inner class MarketChart {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN loaded total balance WHEN convert THEN market chart is Loaded with one segment per top asset`() {
|
||||||
|
// Arrange
|
||||||
|
val statuses = listOf(
|
||||||
|
createStatus(
|
||||||
|
createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin"),
|
||||||
|
loadedValue(BigDecimal.ONE, BigDecimal("70")),
|
||||||
|
),
|
||||||
|
createStatus(
|
||||||
|
createCoin(rawCurrencyId = "eth", symbol = "ETH", networkId = "ethereum"),
|
||||||
|
loadedValue(BigDecimal.ONE, BigDecimal("30")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = convert(statuses, totalFiatBalance = BigDecimal("100"))
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val marketChart = result.marketChartUM as MarketChartUM.Loaded
|
||||||
|
assertThat(marketChart.assetCount).isEqualTo(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN non-loaded total balance WHEN convert THEN market chart is NoData`() {
|
||||||
|
// Arrange
|
||||||
|
val statuses = listOf(
|
||||||
|
createStatus(
|
||||||
|
createCoin(rawCurrencyId = "btc", symbol = "BTC", networkId = "bitcoin"),
|
||||||
|
loadedValue(BigDecimal.ONE, BigDecimal("100")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val statusList: AccountStatusList = mockk {
|
||||||
|
every { flattenCurrencies() } returns statuses
|
||||||
|
every { totalFiatBalance } returns TotalFiatBalance.Loading
|
||||||
|
every { userWalletId } returns UserWalletId("01")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = createConverter().convert(statusList) as PortfolioReviewUM.Content
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN null account status list WHEN convert THEN market chart is NoData`() {
|
||||||
|
// Act
|
||||||
|
val result = createConverter().convert(null) as PortfolioReviewUM.Content
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convert(
|
||||||
|
statuses: List<CryptoCurrencyStatus>,
|
||||||
|
totalFiatBalance: BigDecimal,
|
||||||
|
): PortfolioReviewUM.Content =
|
||||||
|
createConverter().convert(accountStatusList(statuses, totalFiatBalance)) as PortfolioReviewUM.Content
|
||||||
|
|
||||||
|
private fun createConverter(
|
||||||
|
expandedAssetIds: Set<String> = emptySet(),
|
||||||
|
expandClick: (String) -> Unit = {},
|
||||||
|
onTokenClick: (UserWalletId, CryptoCurrency) -> Unit = { _, _ -> },
|
||||||
|
): ForYouPortfolioReviewConverter = ForYouPortfolioReviewConverter(
|
||||||
|
appCurrency = appCurrency,
|
||||||
|
expandedAssetIds = expandedAssetIds,
|
||||||
|
expandClick = expandClick,
|
||||||
|
onTokenClick = onTokenClick,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun accountStatusList(
|
||||||
|
currencies: List<CryptoCurrencyStatus>,
|
||||||
|
totalFiatBalance: BigDecimal,
|
||||||
|
source: StatusSource = StatusSource.ACTUAL,
|
||||||
|
): AccountStatusList = mockk {
|
||||||
|
every { flattenCurrencies() } returns currencies
|
||||||
|
every { this@mockk.totalFiatBalance } returns TotalFiatBalance.Loaded(
|
||||||
|
amount = totalFiatBalance,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
every { userWalletId } returns UserWalletId("01")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
|
||||||
|
currency = currency,
|
||||||
|
value = value,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk {
|
||||||
|
every { this@mockk.amount } returns amount
|
||||||
|
every { this@mockk.fiatAmount } returns fiatAmount
|
||||||
|
every { isError } returns false
|
||||||
|
every { sources } returns CryptoCurrencyStatus.Sources()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A non-content status: carries a null fiatAmount (unknown balance), not a resolved zero. */
|
||||||
|
private fun unreachableValue(): CryptoCurrencyStatus.Unreachable = CryptoCurrencyStatus.Unreachable(
|
||||||
|
priceChange = null,
|
||||||
|
fiatRate = null,
|
||||||
|
networkAddress = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createCoin(rawCurrencyId: String, symbol: String, networkId: String): CryptoCurrency.Coin {
|
||||||
|
val network = createNetwork(networkId = networkId, standardTypeName = "MAIN")
|
||||||
|
val currencyId = createCurrencyId(idValue = "coin-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId)
|
||||||
|
return mockk<CryptoCurrency.Coin> {
|
||||||
|
every { this@mockk.id } returns currencyId
|
||||||
|
every { this@mockk.symbol } returns symbol
|
||||||
|
every { this@mockk.name } returns symbol
|
||||||
|
every { this@mockk.network } returns network
|
||||||
|
every { this@mockk.decimals } returns 8
|
||||||
|
every { isCustom } returns false
|
||||||
|
every { iconUrl } returns null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createToken(
|
||||||
|
rawCurrencyId: String,
|
||||||
|
symbol: String,
|
||||||
|
networkId: String,
|
||||||
|
standardTypeName: String = "ERC20",
|
||||||
|
): CryptoCurrency.Token {
|
||||||
|
val network = createNetwork(networkId = networkId, standardTypeName = standardTypeName)
|
||||||
|
val currencyId = createCurrencyId(idValue = "token-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId)
|
||||||
|
return mockk<CryptoCurrency.Token> {
|
||||||
|
every { this@mockk.id } returns currencyId
|
||||||
|
every { this@mockk.symbol } returns symbol
|
||||||
|
every { this@mockk.name } returns symbol
|
||||||
|
every { this@mockk.network } returns network
|
||||||
|
every { this@mockk.decimals } returns 6
|
||||||
|
every { isCustom } returns false
|
||||||
|
every { iconUrl } returns null
|
||||||
|
every { contractAddress } returns "0xCONTRACT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCurrencyId(idValue: String, rawCurrencyId: String): CryptoCurrency.ID = mockk {
|
||||||
|
every { value } returns idValue
|
||||||
|
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNetwork(networkId: String, standardTypeName: String): Network {
|
||||||
|
val standardType: Network.StandardType = mockk {
|
||||||
|
every { name } returns standardTypeName
|
||||||
|
}
|
||||||
|
return mockk {
|
||||||
|
every { id } returns mockk {
|
||||||
|
every { rawId } returns Network.RawID(networkId)
|
||||||
|
}
|
||||||
|
every { name } returns networkId
|
||||||
|
every { isTestnet } returns false
|
||||||
|
every { this@mockk.standardType } returns standardType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.tangem.features.foryou.impl.model.converter
|
package com.tangem.features.foryou.impl.model.converter.portfolioReview
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||||
|
|
@ -12,13 +12,15 @@ import com.tangem.domain.models.StatusSource
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
|
import com.tangem.domain.models.wallet.UserWalletId
|
||||||
|
import com.tangem.features.foryou.impl.model.converter.toForYouPercent
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import org.junit.jupiter.api.Nested
|
import org.junit.jupiter.api.Nested
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
internal class ForYouTokenRowConverterTest {
|
internal class ForYouPortfolioReviewTokenRowConverterTest {
|
||||||
|
|
||||||
private val appCurrency: AppCurrency = AppCurrency.Default
|
private val appCurrency: AppCurrency = AppCurrency.Default
|
||||||
|
|
||||||
|
|
@ -194,6 +196,51 @@ internal class ForYouTokenRowConverterTest {
|
||||||
assertThat(bottomEnd.endIcons).hasSize(1)
|
assertThat(bottomEnd.endIcons).hasSize(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN wallet id present WHEN row clicked THEN token callback receives wallet id and currency`() {
|
||||||
|
// Arrange
|
||||||
|
val currency = createCurrency(id = "coin-eth", symbol = "ETH")
|
||||||
|
val statuses = listOf(
|
||||||
|
createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))),
|
||||||
|
)
|
||||||
|
val walletId = UserWalletId("01")
|
||||||
|
var clicked: Pair<UserWalletId, CryptoCurrency>? = null
|
||||||
|
val converter = createConverter(
|
||||||
|
totalFiatBalance = BigDecimal("1000"),
|
||||||
|
userWalletId = walletId,
|
||||||
|
onTokenClick = { id, clickedCurrency -> clicked = id to clickedCurrency },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
|
||||||
|
result.onItemClick?.invoke()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(clicked).isEqualTo(walletId to currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN no wallet id WHEN row clicked THEN token callback is not invoked`() {
|
||||||
|
// Arrange — without a selected wallet there is nowhere to navigate, so the click is a no-op
|
||||||
|
val currency = createCurrency(id = "coin-eth", symbol = "ETH")
|
||||||
|
val statuses = listOf(
|
||||||
|
createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))),
|
||||||
|
)
|
||||||
|
var clicked = false
|
||||||
|
val converter = createConverter(
|
||||||
|
totalFiatBalance = BigDecimal("1000"),
|
||||||
|
userWalletId = null,
|
||||||
|
onTokenClick = { _, _ -> clicked = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
|
||||||
|
result.onItemClick?.invoke()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(clicked).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN mixed MissedDerivation and Unreachable WHEN convertNetworkGroup THEN missed-derivation wins`() {
|
fun `GIVEN mixed MissedDerivation and Unreachable WHEN convertNetworkGroup THEN missed-derivation wins`() {
|
||||||
// Arrange — missed derivation is the most severe terminal state and dominates
|
// Arrange — missed derivation is the most severe terminal state and dominates
|
||||||
|
|
@ -213,18 +260,23 @@ internal class ForYouTokenRowConverterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createConverter(totalFiatBalance: BigDecimal) = ForYouTokenRowConverter(
|
private fun createConverter(
|
||||||
|
totalFiatBalance: BigDecimal,
|
||||||
|
userWalletId: UserWalletId? = UserWalletId("01"),
|
||||||
|
onTokenClick: (UserWalletId, CryptoCurrency) -> Unit = { _, _ -> },
|
||||||
|
) = ForYouPortfolioReviewTokenRowConverter(
|
||||||
appCurrency = appCurrency,
|
appCurrency = appCurrency,
|
||||||
|
userWalletId = userWalletId,
|
||||||
totalFiatBalance = totalFiatBalance,
|
totalFiatBalance = totalFiatBalance,
|
||||||
onTokenClick = {},
|
onTokenClick = onTokenClick,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Mirrors the production fiat rendering used by [ForYouTokenRowConverter] for a resolved row. */
|
/** Mirrors the production fiat rendering used by [ForYouPortfolioReviewTokenRowConverter] for a resolved row. */
|
||||||
private fun BigDecimal.expectedFiatText(): TextReference = stringReference(
|
private fun BigDecimal.expectedFiatText(): TextReference = stringReference(
|
||||||
format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) },
|
format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) },
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Mirrors the production percent-share rendering used by [ForYouTokenRowConverter] for a resolved row. */
|
/** Mirrors the production percent-share rendering of [ForYouPortfolioReviewTokenRowConverter]. */
|
||||||
private fun BigDecimal.expectedPercentText(total: BigDecimal): TextReference = stringReference(
|
private fun BigDecimal.expectedPercentText(total: BigDecimal): TextReference = stringReference(
|
||||||
toForYouPercent(total).format { percent() },
|
toForYouPercent(total).format { percent() },
|
||||||
)
|
)
|
||||||
|
|
@ -278,6 +330,7 @@ internal class ForYouTokenRowConverterTest {
|
||||||
return mockk<CryptoCurrency.Coin> {
|
return mockk<CryptoCurrency.Coin> {
|
||||||
every { this@mockk.id } returns currencyId
|
every { this@mockk.id } returns currencyId
|
||||||
every { this@mockk.symbol } returns symbol
|
every { this@mockk.symbol } returns symbol
|
||||||
|
every { this@mockk.name } returns symbol
|
||||||
every { this@mockk.network } returns network
|
every { this@mockk.network } returns network
|
||||||
every { this@mockk.decimals } returns 8
|
every { this@mockk.decimals } returns 8
|
||||||
every { isCustom } returns false
|
every { isCustom } returns false
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
package com.tangem.features.foryou.impl.model.transformer
|
package com.tangem.features.foryou.impl.model.transformer
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
|
||||||
|
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
|
||||||
import com.tangem.core.ui.extensions.stringReference
|
import com.tangem.core.ui.extensions.stringReference
|
||||||
import com.tangem.domain.account.models.AccountStatusList
|
import com.tangem.domain.account.models.AccountStatusList
|
||||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
|
||||||
import com.tangem.domain.models.StatusSource
|
import com.tangem.domain.models.StatusSource
|
||||||
import com.tangem.domain.models.TotalFiatBalance
|
import com.tangem.domain.models.TotalFiatBalance
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
|
||||||
import com.tangem.domain.models.network.Network
|
|
||||||
import com.tangem.features.foryou.impl.components.state.MarketChartUM
|
import com.tangem.features.foryou.impl.components.state.MarketChartUM
|
||||||
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM
|
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.ForYouUM
|
||||||
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
|
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
|
||||||
import com.tangem.features.foryou.impl.model.ForYouNotification
|
import com.tangem.features.foryou.impl.model.ForYouNotification
|
||||||
|
|
@ -24,163 +21,25 @@ import java.math.BigDecimal
|
||||||
|
|
||||||
internal class SetPortfolioReviewTransformerTest {
|
internal class SetPortfolioReviewTransformerTest {
|
||||||
|
|
||||||
private val appCurrency: AppCurrency = AppCurrency.Default
|
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
inner class TokenList {
|
inner class Sections {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN currency with resolved zero fiat balance WHEN transform THEN it is dropped from the list`() {
|
fun `GIVEN pre-built section UMs WHEN transform THEN both are set on the state`() {
|
||||||
// Arrange
|
// Arrange
|
||||||
val zeroBalance = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
|
val portfolioReview = contentPortfolioReview()
|
||||||
val nonZeroBalance = createCurrency(rawCurrencyId = "eth", symbol = "ETH")
|
val earnOpportunities = contentEarnOpportunities()
|
||||||
val currencies = listOf(
|
val transformer = createTransformer(
|
||||||
createStatus(zeroBalance, loadedValue(BigDecimal.ZERO)),
|
portfolioReviewUM = portfolioReview,
|
||||||
createStatus(nonZeroBalance, loadedValue(BigDecimal("100"))),
|
earnOpportunitiesUM = earnOpportunities,
|
||||||
)
|
)
|
||||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100"))))
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
val result = transformer.transform(loadingState())
|
||||||
|
|
||||||
// Assert — only the ETH asset survives; the zero-fiat BTC is dropped
|
|
||||||
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN non-content status with null fiat WHEN transform THEN it is kept not dropped`() {
|
|
||||||
// Arrange — a non-content status (Unreachable) carries a null fiatAmount, not a resolved zero;
|
|
||||||
// it must still be shown so the user sees the token they hold, with the appropriate treatment.
|
|
||||||
val unreachable = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
|
|
||||||
val loaded = createCurrency(rawCurrencyId = "eth", symbol = "ETH")
|
|
||||||
val currencies = listOf(
|
|
||||||
createStatus(unreachable, unreachableValue()),
|
|
||||||
createStatus(loaded, loadedValue(BigDecimal("100"))),
|
|
||||||
)
|
|
||||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100"))))
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
|
||||||
|
|
||||||
// Assert — both assets kept, ranked by summed fiat (eth 100 > btc 0)
|
|
||||||
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth", "btc").inOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN same asset across networks WHEN transform THEN aggregated into one asset ranked by summed fiat`() {
|
|
||||||
// Arrange — the same asset (shared rawCurrencyId "usdc") aggregates into one asset
|
|
||||||
val onEth = createCurrency(rawCurrencyId = "usdc", symbol = "USDC")
|
|
||||||
val onSol = createCurrency(rawCurrencyId = "usdc", symbol = "USDC")
|
|
||||||
val other = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
|
|
||||||
val currencies = listOf(
|
|
||||||
createStatus(onEth, loadedValue(BigDecimal("50"))),
|
|
||||||
createStatus(onSol, loadedValue(BigDecimal("60"))),
|
|
||||||
createStatus(other, loadedValue(BigDecimal("10"))),
|
|
||||||
)
|
|
||||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("120"))))
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
|
||||||
|
|
||||||
// Assert — 2 ranked assets: usdc (110 total) ahead of btc (10)
|
|
||||||
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("usdc", "btc").inOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN more than TOP_HOLDINGS_COUNT assets WHEN transform THEN excess assets collapse into Other`() {
|
|
||||||
// Arrange — 5 distinct assets, top 4 kept individually, 5th collapsed into "Other"
|
|
||||||
val currencies = (1..5).map { index ->
|
|
||||||
createStatus(
|
|
||||||
createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"),
|
|
||||||
loadedValue(BigDecimal(100 - index)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("470"))))
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
|
||||||
|
|
||||||
// Assert — 4 top asset rows + 1 "Other" row
|
|
||||||
assertThat(result.tokenList).hasSize(5)
|
|
||||||
assertThat(result.tokenList.last().tokenRowUM.id).isEqualTo("for_you_other_assets")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN exactly TOP_HOLDINGS_COUNT assets WHEN transform THEN no Other row is appended`() {
|
|
||||||
// Arrange
|
|
||||||
val currencies = (1..4).map { index ->
|
|
||||||
createStatus(
|
|
||||||
createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"),
|
|
||||||
loadedValue(BigDecimal(100 - index)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("394"))))
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assertThat(result.tokenList).hasSize(4)
|
assertThat(result.portfolioReviewUM).isEqualTo(portfolioReview)
|
||||||
}
|
assertThat(result.earnOpportunities).isEqualTo(earnOpportunities)
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN null account status list WHEN transform THEN token list is empty`() {
|
|
||||||
// Arrange
|
|
||||||
val transformer = createTransformer(accountStatusList = null)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertThat(result.tokenList).isEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class MarketChart {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN loaded total balance WHEN transform THEN market chart is Loaded with one segment per top asset`() {
|
|
||||||
// Arrange
|
|
||||||
val currencies = listOf(
|
|
||||||
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("70"))),
|
|
||||||
createStatus(createCurrency(rawCurrencyId = "eth", symbol = "ETH"), loadedValue(BigDecimal("30"))),
|
|
||||||
)
|
|
||||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100"))))
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
val marketChart = result.marketChartUM as MarketChartUM.Loaded
|
|
||||||
assertThat(marketChart.assetCount).isEqualTo(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN non-loaded total balance WHEN transform THEN market chart is NoData`() {
|
|
||||||
// Arrange
|
|
||||||
val currencies = listOf(
|
|
||||||
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("100"))),
|
|
||||||
)
|
|
||||||
val transformer = createTransformer(accountStatusList(currencies, TotalFiatBalance.Loading))
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `GIVEN null account status list WHEN transform THEN market chart is NoData`() {
|
|
||||||
// Arrange
|
|
||||||
val transformer = createTransformer(accountStatusList = null)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,45 +47,37 @@ internal class SetPortfolioReviewTransformerTest {
|
||||||
inner class PeriodPicker {
|
inner class PeriodPicker {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN prev state is Loading WHEN transform THEN period picker is freshly created with Day selected`() {
|
fun `GIVEN previous state is Loading WHEN transform THEN period picker is created with Day selected`() {
|
||||||
// Arrange
|
// Arrange
|
||||||
val currencies = listOf(
|
val transformer = createTransformer()
|
||||||
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))),
|
|
||||||
)
|
|
||||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("10"))))
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
val result = transformer.transform(loadingState())
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assertThat(result.periodPickerUM.items.map { it.title }).containsExactly(
|
assertThat(result.periodPickerUM.items).hasSize(3)
|
||||||
stringReference("Day"),
|
assertThat(result.periodPickerUM.initialSelectedItem).isEqualTo(result.periodPickerUM.items.first())
|
||||||
stringReference("Week"),
|
|
||||||
stringReference("Month"),
|
|
||||||
).inOrder()
|
|
||||||
assertThat(result.periodPickerUM.initialSelectedItem?.title).isEqualTo(stringReference("Day"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN prev state is Content WHEN transform THEN period picker selection is preserved`() {
|
fun `GIVEN previous state is Content WHEN transform THEN user's picker selection is carried over`() {
|
||||||
// Arrange
|
// Arrange — the user has already switched to the "Week" segment
|
||||||
val currencies = listOf(
|
val week = TangemSegmentUM(id = "1", title = stringReference("Week"))
|
||||||
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))),
|
val pickerWithSelection = TangemSegmentedPickerUM(
|
||||||
|
items = persistentListOf(TangemSegmentUM(id = "0", title = stringReference("Day")), week),
|
||||||
|
initialSelectedItem = week,
|
||||||
)
|
)
|
||||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("10"))))
|
|
||||||
val prevContent = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
|
||||||
val weekItem = prevContent.periodPickerUM.items[1]
|
|
||||||
val prevState = loadingState().copy(
|
val prevState = loadingState().copy(
|
||||||
portfolioReviewUM = prevContent.copy(
|
portfolioReviewUM = contentPortfolioReview(),
|
||||||
periodPickerUM = prevContent.periodPickerUM.copy(initialSelectedItem = weekItem),
|
periodPickerUM = pickerWithSelection,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
val transformer = createTransformer()
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
val result = transformer.transform(prevState).portfolioReviewUM as PortfolioReviewUM.Content
|
val result = transformer.transform(prevState)
|
||||||
|
|
||||||
// Assert
|
// Assert — a balance refresh must not reset the selection back to Day
|
||||||
assertThat(result.periodPickerUM.initialSelectedItem).isEqualTo(weekItem)
|
assertThat(result.periodPickerUM).isEqualTo(pickerWithSelection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,11 +87,8 @@ internal class SetPortfolioReviewTransformerTest {
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN total balance from outdated source WHEN transform THEN outdated-data notification is emitted`() {
|
fun `GIVEN total balance from outdated source WHEN transform THEN outdated-data notification is emitted`() {
|
||||||
// Arrange
|
// Arrange
|
||||||
val currencies = listOf(
|
|
||||||
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))),
|
|
||||||
)
|
|
||||||
val transformer = createTransformer(
|
val transformer = createTransformer(
|
||||||
accountStatusList(currencies, loaded(BigDecimal("10"), source = StatusSource.ONLY_CACHE)),
|
accountStatusList = accountStatusList(loaded(BigDecimal("10"), source = StatusSource.ONLY_CACHE)),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|
@ -253,11 +101,8 @@ internal class SetPortfolioReviewTransformerTest {
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN total balance from actual source WHEN transform THEN no notification is emitted`() {
|
fun `GIVEN total balance from actual source WHEN transform THEN no notification is emitted`() {
|
||||||
// Arrange
|
// Arrange
|
||||||
val currencies = listOf(
|
|
||||||
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))),
|
|
||||||
)
|
|
||||||
val transformer = createTransformer(
|
val transformer = createTransformer(
|
||||||
accountStatusList(currencies, loaded(BigDecimal("10"), source = StatusSource.ACTUAL)),
|
accountStatusList = accountStatusList(loaded(BigDecimal("10"), source = StatusSource.ACTUAL)),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|
@ -281,22 +126,28 @@ internal class SetPortfolioReviewTransformerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createTransformer(
|
private fun createTransformer(
|
||||||
accountStatusList: AccountStatusList?,
|
accountStatusList: AccountStatusList? = null,
|
||||||
expandedAssetIds: Set<String> = emptySet(),
|
portfolioReviewUM: PortfolioReviewUM = contentPortfolioReview(),
|
||||||
|
earnOpportunitiesUM: EarnOpportunitiesUM = contentEarnOpportunities(),
|
||||||
) = SetPortfolioReviewTransformer(
|
) = SetPortfolioReviewTransformer(
|
||||||
accountStatusList = accountStatusList,
|
accountStatusList = accountStatusList,
|
||||||
appCurrency = appCurrency,
|
portfolioReviewUM = portfolioReviewUM,
|
||||||
expandedAssetIds = expandedAssetIds,
|
earnOpportunitiesUM = earnOpportunitiesUM,
|
||||||
expandClick = {},
|
|
||||||
onPeriodClick = {},
|
|
||||||
onTokenClick = {},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun accountStatusList(
|
private fun contentPortfolioReview(): PortfolioReviewUM.Content = PortfolioReviewUM.Content(
|
||||||
currencies: List<CryptoCurrencyStatus>,
|
tokenList = persistentListOf(),
|
||||||
totalFiatBalance: TotalFiatBalance,
|
marketChartUM = MarketChartUM.NoData,
|
||||||
): AccountStatusList = mockk {
|
)
|
||||||
every { flattenCurrencies() } returns currencies
|
|
||||||
|
private fun contentEarnOpportunities(): EarnOpportunitiesUM.Content = EarnOpportunitiesUM.Content(
|
||||||
|
tokenList = persistentListOf(),
|
||||||
|
subtitleRes = 0,
|
||||||
|
potentialReward = null,
|
||||||
|
potentialRewardType = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun accountStatusList(totalFiatBalance: TotalFiatBalance): AccountStatusList = mockk {
|
||||||
every { this@mockk.totalFiatBalance } returns totalFiatBalance
|
every { this@mockk.totalFiatBalance } returns totalFiatBalance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,50 +156,12 @@ internal class SetPortfolioReviewTransformerTest {
|
||||||
|
|
||||||
private fun loadingState(): ForYouUM = ForYouUM(
|
private fun loadingState(): ForYouUM = ForYouUM(
|
||||||
portfolioReviewUM = PortfolioReviewUM.Loading(
|
portfolioReviewUM = PortfolioReviewUM.Loading(
|
||||||
tokenList = persistentListOf<ForYouTokenListItemUM>(),
|
tokenList = persistentListOf(),
|
||||||
marketChartUM = MarketChartUM.NoData,
|
marketChartUM = MarketChartUM.NoData,
|
||||||
),
|
),
|
||||||
earnOpportunities = EarnOpportunitiesUM.Loading(tokenList = persistentListOf()),
|
earnOpportunities = EarnOpportunitiesUM.Loading(tokenList = persistentListOf()),
|
||||||
notifications = persistentListOf(),
|
notifications = persistentListOf(),
|
||||||
|
periodPickerUM = TangemSegmentedPickerUM(persistentListOf()),
|
||||||
|
onPeriodClick = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
|
|
||||||
currency = currency,
|
|
||||||
value = value,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun loadedValue(fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk {
|
|
||||||
every { amount } returns BigDecimal.ONE
|
|
||||||
every { this@mockk.fiatAmount } returns fiatAmount
|
|
||||||
every { isError } returns false
|
|
||||||
every { sources } returns CryptoCurrencyStatus.Sources()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A non-content status: carries a null fiatAmount (unknown balance), not a resolved zero. */
|
|
||||||
private fun unreachableValue(): CryptoCurrencyStatus.Unreachable = CryptoCurrencyStatus.Unreachable(
|
|
||||||
priceChange = null,
|
|
||||||
fiatRate = null,
|
|
||||||
networkAddress = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun createCurrency(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin {
|
|
||||||
val network: Network = mockk {
|
|
||||||
every { name } returns "Network"
|
|
||||||
every { isTestnet } returns false
|
|
||||||
every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) }
|
|
||||||
}
|
|
||||||
val currencyId: CryptoCurrency.ID = mockk {
|
|
||||||
every { value } returns "coin-$rawCurrencyId"
|
|
||||||
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
|
|
||||||
}
|
|
||||||
return mockk<CryptoCurrency.Coin> {
|
|
||||||
every { this@mockk.id } returns currencyId
|
|
||||||
every { this@mockk.symbol } returns symbol
|
|
||||||
every { this@mockk.name } returns symbol
|
|
||||||
every { this@mockk.network } returns network
|
|
||||||
every { this@mockk.decimals } returns 8
|
|
||||||
every { isCustom } returns false
|
|
||||||
every { iconUrl } returns null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue