diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt index b538927ce3..a484f7b7c7 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt @@ -3,21 +3,19 @@ package com.tangem.features.foryou.impl.model import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.common.test.domain.wallet.MockUserWalletFactory -import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter -import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.ds.row.token.TangemTokenRowUM import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.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.UserWalletIcon import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.usecase.GetWalletIconUseCase +import com.tangem.features.foryou.impl.components.state.MarketChartUM import com.tangem.features.foryou.impl.entity.PortfolioReviewUM import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every @@ -36,24 +34,18 @@ import org.junit.jupiter.api.Test import java.math.BigDecimal @OptIn(ExperimentalCoroutinesApi::class) -@Suppress("LargeClass") internal class ForYouModelTest { private val userWalletsListRepository: UserWalletsListRepository = mockk() private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier = mockk() private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() - private val walletIconUMConverter: WalletIconUMConverter = mockk { - every { convert(any()) } returns DeviceIconUM.Stub(cardsCount = 1) - } - private val getWalletIconUseCase: GetWalletIconUseCase = mockk() private var model: ForYouModel? = null @BeforeEach fun setup() { - every { getWalletIconUseCase.invoke(any()) } returns UserWalletIcon.Stub(cardsCount = 1) // Default: a real, non-empty emission so the model's `getOrElse { Default }` mapping path is - // actually exercised in every test, not bypassed by an empty flow. Individual tests may override. + // actually exercised in every test, not bypassed by an empty flow. every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right()) } @@ -67,21 +59,19 @@ internal class ForYouModelTest { inner class InitialState { @Test - fun `GIVEN model created WHEN not yet advanced THEN uiState is Loading`() = runTest { + fun `GIVEN model created WHEN not yet advanced THEN uiState is Loading with skeleton rows`() = runTest { // Arrange - every { userWalletsListRepository.userWallets } returns MutableStateFlow(null) every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null) every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf()) - every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right()) // Act val model = createModel(testScope = this) // Assert — before advancing, the model exposes skeleton placeholder rows val loading = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Loading - assertThat(loading.tokenList).hasSize(4) assertThat(loading.tokenList.all { it.tokenRowUM is TangemTokenRowUM.Loading }).isTrue() + assertThat(loading.marketChartUM).isEqualTo(MarketChartUM.NoData) } } @@ -89,107 +79,42 @@ internal class ForYouModelTest { inner class ContentState { @Test - fun `GIVEN wallets and account statuses emitted WHEN advanced THEN uiState becomes Content with tabs`() = + fun `GIVEN selected wallet and statuses emitted WHEN advanced THEN uiState becomes Content`() = runTest { + // Arrange + val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") + stubSelectedWallet( + currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), + totalFiatBalance = BigDecimal("100"), + ) + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + val content = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content + assertThat(content.tokenList.map { it.tokenRowUM.id }).containsExactly("btc") + assertThat(content.marketChartUM).isInstanceOf(MarketChartUM.Loaded::class.java) + assertThat(model.uiState.value.notifications).isEmpty() + } + + @Test + fun `GIVEN total balance from outdated source WHEN advanced THEN outdated-data notification is shown`() = runTest { // Arrange - val walletOne = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") - val walletTwo = MockUserWalletFactory.create().copy(walletId = UserWalletId("02"), name = "Wallet 2") - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(walletOne, walletTwo)) - every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(walletOne) - val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") - val accountStatusList = createAccountStatusList( - userWalletId = walletOne.walletId, + stubSelectedWallet( currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), totalFiatBalance = BigDecimal("100"), + source = StatusSource.ONLY_CACHE, ) - every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( - linkedMapOf(walletOne.walletId to accountStatusList), - ) - every { getSelectedAppCurrencyUseCase() } returns flowOf() // Act val model = createModel(testScope = this) advanceUntilIdle() // Assert - val content = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content - assertThat(content.assetCount).isNotNull() - assertThat(model.uiState.value.walletListUM.items).hasSize(2) - assertThat(model.uiState.value.walletListUM.items.map { it.isSelected }).containsExactly(true, false) - } - - @Test - fun `GIVEN exactly one wallet WHEN advanced THEN walletListUM items is empty`() = runTest { - // Arrange - val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(wallet)) - every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) - - val accountStatusList = createAccountStatusList( - userWalletId = wallet.walletId, - currencies = emptyList(), - totalFiatBalance = BigDecimal.ZERO, - ) - every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( - linkedMapOf(wallet.walletId to accountStatusList), - ) - every { getSelectedAppCurrencyUseCase() } returns flowOf() - - // Act - val model = createModel(testScope = this) - advanceUntilIdle() - - // Assert — the "tabs.size != 1" rule: a single wallet shows no tabs - assertThat(model.uiState.value.walletListUM.items).isEmpty() - } - } - - @Nested - inner class TabClick { - - @Test - fun `GIVEN two wallets WHEN onTabClick THEN locally selected wallet switches and currencies rederive`() = - runTest { - // Arrange - val walletOne = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") - val walletTwo = MockUserWalletFactory.create().copy(walletId = UserWalletId("02"), name = "Wallet 2") - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(walletOne, walletTwo)) - every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(walletOne) - - val btc = createCoin(rawCurrencyId = "btc", symbol = "BTC") - val eth = createCoin(rawCurrencyId = "eth", symbol = "ETH") - val statusOne = createAccountStatusList( - userWalletId = walletOne.walletId, - currencies = listOf(createStatus(btc, loadedValue(BigDecimal("100")))), - totalFiatBalance = BigDecimal("100"), - ) - val statusTwo = createAccountStatusList( - userWalletId = walletTwo.walletId, - currencies = listOf(createStatus(eth, loadedValue(BigDecimal("50")))), - totalFiatBalance = BigDecimal("50"), - ) - every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( - linkedMapOf(walletOne.walletId to statusOne, walletTwo.walletId to statusTwo), - ) - every { getSelectedAppCurrencyUseCase() } returns flowOf() - - val model = createModel(testScope = this) - advanceUntilIdle() - - // Act — click the second wallet's tab - model.uiState.value.walletListUM.items[1].onClick() - advanceUntilIdle() - - // Assert - assertThat(model.uiState.value.walletListUM.items.map { it.isSelected }).containsExactly( - false, - true, - ).inOrder() - val content = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content - val assetRow = content.tokenList.single().tokenRowUM as TangemTokenRowUM.Content - val titleUM = assetRow.titleUM as TangemTokenRowUM.TitleUM.Content - assertThat(titleUM.text).isEqualTo(com.tangem.core.ui.extensions.stringReference("ETH")) + assertThat(model.uiState.value.notifications).containsExactly(ForYouNotification.UsedOutdatedData) } } @@ -199,29 +124,18 @@ internal class ForYouModelTest { @Test fun `GIVEN asset row clicked WHEN clicked again THEN isExpanded toggles back to false`() = runTest { // Arrange - val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(wallet)) - every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) - val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") - val accountStatusList = createAccountStatusList( - userWalletId = wallet.walletId, + stubSelectedWallet( currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), totalFiatBalance = BigDecimal("100"), ) - every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( - linkedMapOf(wallet.walletId to accountStatusList), - ) - every { getSelectedAppCurrencyUseCase() } returns flowOf() - val model = createModel(testScope = this) advanceUntilIdle() val initialContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content assertThat(initialContent.tokenList.single().isExpanded).isFalse() // Act — click once to expand - val assetRow = initialContent.tokenList.single().tokenRowUM as TangemTokenRowUM.Content - assetRow.onItemClick?.invoke() + initialContent.assetRow().onItemClick?.invoke() advanceUntilIdle() // Assert @@ -229,8 +143,7 @@ internal class ForYouModelTest { assertThat(expandedContent.tokenList.single().isExpanded).isTrue() // Act — click again to collapse - val expandedRow = expandedContent.tokenList.single().tokenRowUM as TangemTokenRowUM.Content - expandedRow.onItemClick?.invoke() + expandedContent.assetRow().onItemClick?.invoke() advanceUntilIdle() // Assert @@ -246,26 +159,15 @@ internal class ForYouModelTest { fun `GIVEN Content state WHEN period clicked THEN initialSelectedItem updates without resetting rest`() = runTest { // Arrange - val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"), name = "Wallet 1") - every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(wallet)) - every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) - val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC") - val accountStatusList = createAccountStatusList( - userWalletId = wallet.walletId, + stubSelectedWallet( currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))), totalFiatBalance = BigDecimal("100"), ) - every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( - linkedMapOf(wallet.walletId to accountStatusList), - ) - every { getSelectedAppCurrencyUseCase() } returns flowOf() - val model = createModel(testScope = this) advanceUntilIdle() val contentBefore = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content val weekItem = contentBefore.periodPickerUM.items[1] - val assetCountBefore = contentBefore.assetCount // Act contentBefore.onPeriodClick(weekItem) @@ -273,19 +175,34 @@ internal class ForYouModelTest { // Assert val contentAfter = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content assertThat(contentAfter.periodPickerUM.initialSelectedItem).isEqualTo(weekItem) - assertThat(contentAfter.assetCount).isEqualTo(assetCountBefore) assertThat(contentAfter.tokenList).isEqualTo(contentBefore.tokenList) } } + private fun PortfolioReviewUM.Content.assetRow(): TangemTokenRowUM.Content = + tokenList.single().tokenRowUM as TangemTokenRowUM.Content + + /** Wires the repository + supplier so the model derives Content from a single selected wallet. */ + private fun stubSelectedWallet( + currencies: List, + totalFiatBalance: BigDecimal, + source: StatusSource = StatusSource.ACTUAL, + ) { + val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01")) + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) + every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf( + linkedMapOf( + wallet.walletId to createAccountStatusList(currencies, totalFiatBalance, source), + ), + ) + } + private fun createModel(testScope: TestScope): ForYouModel { return ForYouModel( userWalletsListRepository = userWalletsListRepository, multiAccountStatusListSupplier = multiAccountStatusListSupplier, dispatchers = testScope.createTestingCoroutineDispatcherProvider(), getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, - walletIconUMConverter = walletIconUMConverter, - getWalletIconUseCase = getWalletIconUseCase, ).also { model = it } } @@ -301,15 +218,14 @@ internal class ForYouModelTest { } private fun createAccountStatusList( - userWalletId: UserWalletId, currencies: List, totalFiatBalance: BigDecimal, + source: StatusSource = StatusSource.ACTUAL, ): AccountStatusList = mockk { - every { this@mockk.userWalletId } returns userWalletId every { flattenCurrencies() } returns currencies every { this@mockk.totalFiatBalance } returns TotalFiatBalance.Loaded( amount = totalFiatBalance, - source = com.tangem.domain.models.StatusSource.ACTUAL, + source = source, ) } @@ -322,6 +238,7 @@ internal class ForYouModelTest { every { amount } returns BigDecimal.ONE every { this@mockk.fiatAmount } returns fiatAmount every { isError } returns false + every { sources } returns CryptoCurrencyStatus.Sources() } private fun createCoin(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin { @@ -337,6 +254,7 @@ internal class ForYouModelTest { return mockk { 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 diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt index 9bc177f833..5b6cce0aea 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouPortfolioFormattersTest.kt @@ -1,11 +1,6 @@ package com.tangem.features.foryou.impl.model.converter import com.google.common.truth.Truth.assertThat -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.format.bigdecimal.fiat -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import io.mockk.every @@ -16,8 +11,6 @@ import java.math.BigDecimal internal class ForYouPortfolioFormattersTest { - private val appCurrency: AppCurrency = AppCurrency.Default - @Nested inner class ForYouGroupKey { @@ -62,102 +55,66 @@ internal class ForYouPortfolioFormattersTest { } @Nested - inner class ToForYouFiatText { + inner class ToForYouPercent { @Test - fun `GIVEN a fiat amount WHEN toForYouFiatText THEN delegates to fiat formatting`() { - // Arrange - val amount = BigDecimal("1234.5") - - // Act - val result = amount.toForYouFiatText(appCurrency) - - // Assert - val expected = stringReference( - amount.format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, - ) - assertThat(result).isEqualTo(expected) - } - - @Test - fun `GIVEN null amount WHEN toForYouFiatText THEN renders dash text`() { + fun `GIVEN null amount WHEN toForYouPercent THEN returns null`() { // Arrange val amount: BigDecimal? = null // Act - val result = amount.toForYouFiatText(appCurrency) + val result = amount.toForYouPercent(BigDecimal("100")) // Assert - val expected = stringReference( - amount.format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, - ) - assertThat(result).isEqualTo(expected) - } - } - - @Nested - inner class ToForYouPercentText { - - @Test - fun `GIVEN null amount WHEN toForYouPercentText THEN returns EMPTY`() { - // Arrange - val amount: BigDecimal? = null - - // Act - val result = amount.toForYouPercentText(BigDecimal("100")) - - // Assert - assertThat(result).isEqualTo(TextReference.EMPTY) + assertThat(result).isNull() } @Test - fun `GIVEN zero total WHEN toForYouPercentText THEN returns EMPTY`() { + fun `GIVEN zero total WHEN toForYouPercent THEN returns null`() { // Arrange val amount = BigDecimal("10") // Act - val result = amount.toForYouPercentText(BigDecimal.ZERO) + val result = amount.toForYouPercent(BigDecimal.ZERO) // Assert - assertThat(result).isEqualTo(TextReference.EMPTY) + assertThat(result).isNull() } @Test - fun `GIVEN zero amount WHEN toForYouPercentText THEN returns EMPTY`() { + fun `GIVEN zero amount WHEN toForYouPercent THEN returns null`() { // Arrange val amount = BigDecimal.ZERO // Act - val result = amount.toForYouPercentText(BigDecimal("100")) + val result = amount.toForYouPercent(BigDecimal("100")) // Assert - assertThat(result).isEqualTo(TextReference.EMPTY) + assertThat(result).isNull() } @Test - fun `GIVEN non-zero amount and total WHEN toForYouPercentText THEN returns rounded percent share`() { - // Arrange - val amount = BigDecimal("25.00") - val total = BigDecimal("100") + fun `GIVEN non-zero amount and total WHEN toForYouPercent THEN returns the share as a ratio`() { + // Arrange — 50.00 / 200 = 0.25 (ratio, scaled to the amount's scale) + val amount = BigDecimal("50.00") // Act - val result = amount.toForYouPercentText(total) + val result = amount.toForYouPercent(BigDecimal("200")) - // Assert — 25.00 / 100 = 0.25 -> 25.00% - assertThat(result).isEqualTo(stringReference("25.00%")) + // Assert + assertThat(result).isEqualTo(BigDecimal("0.25")) } @Test - fun `GIVEN a share requiring rounding WHEN toForYouPercentText THEN applies HALF_UP rounding`() { - // Arrange — 1.0000 / 3 = 0.3333... -> rounds to 33.33% + fun `GIVEN a share requiring rounding WHEN toForYouPercent THEN applies HALF_UP rounding`() { + // Arrange — 1.0000 / 3 = 0.3333... rounds HALF_UP to the amount's scale (4) val amount = BigDecimal("1.0000") - val total = BigDecimal("3") // Act - val result = amount.toForYouPercentText(total) + val result = amount.toForYouPercent(BigDecimal("3")) // Assert - assertThat(result).isEqualTo(stringReference("33.33%")) + assertThat(result).isEqualTo(BigDecimal("0.3333")) } } } \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt index ff86191938..a36e7bb702 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt @@ -2,6 +2,7 @@ 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.domain.appcurrency.model.AppCurrency @@ -77,7 +78,7 @@ internal class ForYouTokenListConverterTest { val item = result.single() val row = item.tokenRowUM as TangemTokenRowUM.Content val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(stringReference("2 networks")) + assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_networks_count, count = 2)) assertThat(item.tokenList).hasSize(2) } @@ -115,13 +116,13 @@ internal class ForYouTokenListConverterTest { } @Test - fun `GIVEN otherAssetCount is zero WHEN convert THEN no Other row is appended`() { + 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"), - otherAssetCount = 0, + otherAssets = emptyList(), ) // Act @@ -132,14 +133,13 @@ internal class ForYouTokenListConverterTest { } @Test - fun `GIVEN otherAssetCount is one WHEN convert THEN Other row subtitle is singular`() { + 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"), - otherAssetCount = 1, - otherFiatBalance = BigDecimal("50"), + otherAssets = listOf(otherAsset(BigDecimal("50"))), ) // Act @@ -149,18 +149,21 @@ internal class ForYouTokenListConverterTest { val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content assertThat(otherRow.id).isEqualTo("for_you_other_assets") val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(stringReference("1 asset")) + assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_assets, count = 1)) } @Test - fun `GIVEN otherAssetCount is more than one WHEN convert THEN Other row subtitle is plural`() { + 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"), - otherAssetCount = 3, - otherFiatBalance = BigDecimal("50"), + otherAssets = listOf( + otherAsset(BigDecimal("30")), + otherAsset(BigDecimal("15")), + otherAsset(BigDecimal("5")), + ), ) // Act @@ -169,7 +172,7 @@ internal class ForYouTokenListConverterTest { // Assert val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content - assertThat(subtitle.text).isEqualTo(stringReference("3 assets")) + assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_assets, count = 3)) } @Test @@ -210,17 +213,22 @@ internal class ForYouTokenListConverterTest { private fun createConverter( totalFiatBalance: BigDecimal, expandedAssetIds: Set = emptySet(), - otherAssetCount: Int = 0, - otherFiatBalance: BigDecimal = BigDecimal.ZERO, + otherAssets: List, BigDecimal>> = emptyList(), ): ForYouTokenListConverter = ForYouTokenListConverter( appCurrency = appCurrency, totalFiatBalance = totalFiatBalance, expandedAssetIds = expandedAssetIds, expandClick = {}, - otherAssetCount = otherAssetCount, - otherFiatBalance = otherFiatBalance, + otherAssets = otherAssets, ) + /** + * 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, BigDecimal> = + emptyList() to balance + private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( currency = currency, value = value, @@ -230,6 +238,7 @@ internal class ForYouTokenListConverterTest { 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 { diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt index c9a0a73940..9b9e46353f 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt @@ -2,7 +2,13 @@ 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.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -49,8 +55,8 @@ internal class ForYouTokenRowConverterTest { assertThat(result.id).isEqualTo("coin-eth") val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content - assertThat(topEnd.text).isEqualTo(BigDecimal("400").toForYouFiatText(appCurrency)) - assertThat(bottomEnd.text).isEqualTo(BigDecimal("400").toForYouPercentText(BigDecimal("1000"))) + assertThat(topEnd.text).isEqualTo(BigDecimal("400").expectedFiatText()) + assertThat(bottomEnd.text).isEqualTo(BigDecimal("400").expectedPercentText(BigDecimal("1000"))) } @Test @@ -68,7 +74,7 @@ internal class ForYouTokenRowConverterTest { // Assert val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content - assertThat(topEnd.text).isEqualTo(BigDecimal("600").toForYouFiatText(appCurrency)) + assertThat(topEnd.text).isEqualTo(BigDecimal("600").expectedFiatText()) } @Test @@ -87,6 +93,124 @@ internal class ForYouTokenRowConverterTest { // Assert assertThat(result).isInstanceOf(TangemTokenRowUM.Content::class.java) } + + @Test + fun `GIVEN loaded status from cache WHEN convertNetworkGroup THEN content flickers`() { + // Arrange + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf( + createStatus( + currency, + loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"), source = StatusSource.CACHE), + ), + ) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert + val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content + val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(topEnd.isFlickering).isTrue() + assertThat(bottomEnd.isFlickering).isTrue() + assertThat(topEnd.startIcons).isEmpty() + } + + @Test + fun `GIVEN loaded status only-cache WHEN convertNetworkGroup THEN error-sync start icon shown`() { + // Arrange + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf( + createStatus( + currency, + loadedValue( + amount = BigDecimal("1"), + fiatAmount = BigDecimal("100"), + source = StatusSource.ONLY_CACHE, + ), + ), + ) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert + val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(topEnd.isFlickering).isFalse() + assertThat(topEnd.startIcons).hasSize(1) + } + + @Test + fun `GIVEN missed derivation status WHEN convertNetworkGroup THEN no-address treatment`() { + // Arrange + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf(createStatus(currency, missedDerivationValue())) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert — top-end is a dash, bottom-end carries the attention "no address" icon + val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content + val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(topEnd.endIcons).isEmpty() + assertThat(bottomEnd.endIcons).hasSize(1) + } + + @Test + fun `GIVEN unreachable status WHEN convertNetworkGroup THEN dash on top and attention icon on bottom`() { + // Arrange + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf(createStatus(currency, unreachableValue())) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert — top-end is a bare dash, the attention "unreachable" icon lives on the bottom end + val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content + val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(topEnd.endIcons).isEmpty() + assertThat(bottomEnd.endIcons).hasSize(1) + } + + @Test + fun `GIVEN mixed Loaded and Unreachable WHEN convertNetworkGroup THEN collapses to unreachable`() { + // Arrange — one account resolved, another unreachable: the row must surface the error state + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf( + createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))), + createStatus(currency, unreachableValue()), + ) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert — the unreachable treatment (attention icon on the bottom end) wins over the loaded amount + val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(bottomEnd.endIcons).hasSize(1) + } + + @Test + fun `GIVEN mixed MissedDerivation and Unreachable WHEN convertNetworkGroup THEN missed-derivation wins`() { + // Arrange — missed derivation is the most severe terminal state and dominates + val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum") + val statuses = listOf( + createStatus(currency, unreachableValue()), + createStatus(currency, missedDerivationValue()), + ) + val converter = createConverter(totalFiatBalance = BigDecimal("1000")) + + // Act + val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content + + // Assert — top-end is a dash (no-address treatment), not an unreachable label + val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content + assertThat(topEnd.endIcons).isEmpty() + } } private fun createConverter(totalFiatBalance: BigDecimal) = ForYouTokenRowConverter( @@ -94,15 +218,46 @@ internal class ForYouTokenRowConverterTest { totalFiatBalance = totalFiatBalance, ) + /** Mirrors the production fiat rendering used by [ForYouTokenRowConverter] for a resolved row. */ + private fun BigDecimal.expectedFiatText(): TextReference = stringReference( + format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, + ) + + /** Mirrors the production percent-share rendering used by [ForYouTokenRowConverter] for a resolved row. */ + private fun BigDecimal.expectedPercentText(total: BigDecimal): TextReference = stringReference( + toForYouPercent(total).format { percent() }, + ) + private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( currency = currency, value = value, ) - private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk { + private fun loadedValue( + amount: BigDecimal, + fiatAmount: BigDecimal, + source: StatusSource = StatusSource.ACTUAL, + ): CryptoCurrencyStatus.Loaded = mockk { every { this@mockk.amount } returns amount every { this@mockk.fiatAmount } returns fiatAmount every { isError } returns false + every { sources } returns CryptoCurrencyStatus.Sources( + networkSource = source, + quoteSource = source, + stakingBalanceSource = source, + ) + } + + private fun missedDerivationValue(): CryptoCurrencyStatus.MissedDerivation = mockk { + every { amount } returns null + every { fiatAmount } returns null + every { isError } returns true + } + + private fun unreachableValue(): CryptoCurrencyStatus.Unreachable = mockk { + every { amount } returns null + every { fiatAmount } returns null + every { isError } returns true } private fun createCurrency( diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt index 76ddeda041..ad263cedb4 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt @@ -2,14 +2,18 @@ package com.tangem.features.foryou.impl.model.transformer import com.google.common.truth.Truth.assertThat import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network -import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM +import com.tangem.features.foryou.impl.components.state.MarketChartUM import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM import com.tangem.features.foryou.impl.entity.ForYouUM import com.tangem.features.foryou.impl.entity.PortfolioReviewUM +import com.tangem.features.foryou.impl.model.ForYouNotification import io.mockk.every import io.mockk.mockk import kotlinx.collections.immutable.persistentListOf @@ -20,13 +24,12 @@ import java.math.BigDecimal internal class SetPortfolioReviewTransformerTest { private val appCurrency: AppCurrency = AppCurrency.Default - private val walletListUM = WalletListUM(items = persistentListOf()) @Nested - inner class Transform { + inner class TokenList { @Test - fun `GIVEN currency with zero fiat balance WHEN transform THEN it is dropped from asset count`() { + fun `GIVEN currency with resolved zero fiat balance WHEN transform THEN it is dropped from the list`() { // Arrange val zeroBalance = createCurrency(rawCurrencyId = "btc", symbol = "BTC") val nonZeroBalance = createCurrency(rawCurrencyId = "eth", symbol = "ETH") @@ -34,18 +37,37 @@ internal class SetPortfolioReviewTransformerTest { createStatus(zeroBalance, loadedValue(BigDecimal.ZERO)), createStatus(nonZeroBalance, loadedValue(BigDecimal("100"))), ) - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("100")) + val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100")))) // Act val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - // Assert - assertThat(result.assetCount).isEqualTo(stringReference("1 assets")) + // Assert — only the ETH asset survives; the zero-fiat BTC is dropped + assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth") } @Test - fun `GIVEN assets across networks WHEN transform THEN they are aggregated and ranked by summed fiat`() { - // Arrange — same asset (rawCurrencyId "usdc") on two networks aggregates into one asset + 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") @@ -54,13 +76,13 @@ internal class SetPortfolioReviewTransformerTest { createStatus(onSol, loadedValue(BigDecimal("60"))), createStatus(other, loadedValue(BigDecimal("10"))), ) - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("120")) + 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) and btc (10) - assertThat(result.assetCount).isEqualTo(stringReference("2 assets")) + // Assert — 2 ranked assets: usdc (110 total) ahead of btc (10) + assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("usdc", "btc").inOrder() } @Test @@ -72,12 +94,12 @@ internal class SetPortfolioReviewTransformerTest { loadedValue(BigDecimal(100 - index)), ) } - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("470")) + val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("470")))) // Act val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - // Assert — tokenList has 4 top asset rows + 1 "Other" row = 5 items + // Assert — 4 top asset rows + 1 "Other" row assertThat(result.tokenList).hasSize(5) assertThat(result.tokenList.last().tokenRowUM.id).isEqualTo("for_you_other_assets") } @@ -91,7 +113,7 @@ internal class SetPortfolioReviewTransformerTest { loadedValue(BigDecimal(100 - index)), ) } - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("394")) + val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("394")))) // Act val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content @@ -101,39 +123,76 @@ internal class SetPortfolioReviewTransformerTest { } @Test - fun `GIVEN total and top balance non-zero WHEN transform THEN topHoldingPercent is computed`() { - // Arrange — a single asset means top balance == total balance == 100% - val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))) - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("100")) + 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.topHoldingPercent).isEqualTo(stringReference("Top holding 100.00%")) + 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 zero total fiat balance WHEN transform THEN topHoldingPercent is DASH_SIGN`() { + fun `GIVEN non-loaded total balance WHEN transform THEN market chart is NoData`() { // Arrange - val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val currencies = listOf(createStatus(currency, loadedValue(BigDecimal.ZERO))) - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal.ZERO) + 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.topHoldingPercent).isEqualTo(stringReference("Top holding —")) + 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) + } + } + + @Nested + inner class PeriodPicker { + @Test fun `GIVEN prev state is Loading WHEN transform THEN period picker is freshly created with Day selected`() { // Arrange - val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("10")))) - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("10")) + val currencies = listOf( + createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), + ) + val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("10")))) // Act val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content @@ -150,15 +209,17 @@ internal class SetPortfolioReviewTransformerTest { @Test fun `GIVEN prev state is Content WHEN transform THEN period picker selection is preserved`() { // Arrange - val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("10")))) - val transformer = createTransformer(currencies = currencies, totalFiatBalance = BigDecimal("10")) - val prevContentState = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content - val weekItem = prevContentState.periodPickerUM.items[1] - val prevWithWeekSelected = prevContentState.copy( - periodPickerUM = prevContentState.periodPickerUM.copy(initialSelectedItem = weekItem), + val currencies = listOf( + createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), + ) + val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("10")))) + val prevContent = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content + val weekItem = prevContent.periodPickerUM.items[1] + val prevState = loadingState().copy( + portfolioReviewUM = prevContent.copy( + periodPickerUM = prevContent.periodPickerUM.copy(initialSelectedItem = weekItem), + ), ) - val prevState = ForYouUM(walletListUM = walletListUM, portfolioReviewUM = prevWithWeekSelected) // Act val result = transformer.transform(prevState).portfolioReviewUM as PortfolioReviewUM.Content @@ -166,48 +227,86 @@ internal class SetPortfolioReviewTransformerTest { // Assert assertThat(result.periodPickerUM.initialSelectedItem).isEqualTo(weekItem) } + } + + @Nested + inner class Notifications { @Test - fun `GIVEN new state WHEN transform THEN walletListUM is applied from constructor`() { + fun `GIVEN total balance from outdated source WHEN transform THEN outdated-data notification is emitted`() { // Arrange - val currency = createCurrency(rawCurrencyId = "btc", symbol = "BTC") - val currencies = listOf(createStatus(currency, loadedValue(BigDecimal("10")))) - val newWalletListUM = WalletListUM(items = persistentListOf()) - val transformer = SetPortfolioReviewTransformer( - walletListUM = newWalletListUM, - currencies = currencies, - totalFiatBalance = BigDecimal("10"), - appCurrency = appCurrency, - expandedAssetIds = emptySet(), - expandClick = {}, - onPeriodClick = {}, + val currencies = listOf( + createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), + ) + val transformer = createTransformer( + accountStatusList(currencies, loaded(BigDecimal("10"), source = StatusSource.ONLY_CACHE)), ) // Act val result = transformer.transform(loadingState()) // Assert - assertThat(result.walletListUM).isSameInstanceAs(newWalletListUM) + assertThat(result.notifications).containsExactly(ForYouNotification.UsedOutdatedData) + } + + @Test + fun `GIVEN total balance from actual source WHEN transform THEN no notification is emitted`() { + // Arrange + val currencies = listOf( + createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))), + ) + val transformer = createTransformer( + accountStatusList(currencies, loaded(BigDecimal("10"), source = StatusSource.ACTUAL)), + ) + + // Act + val result = transformer.transform(loadingState()) + + // Assert + assertThat(result.notifications).isEmpty() + } + + @Test + fun `GIVEN null account status list WHEN transform THEN no notification is emitted`() { + // Arrange + val transformer = createTransformer(accountStatusList = null) + + // Act + val result = transformer.transform(loadingState()) + + // Assert + assertThat(result.notifications).isEmpty() } } private fun createTransformer( - currencies: List, - totalFiatBalance: BigDecimal, + accountStatusList: AccountStatusList?, expandedAssetIds: Set = emptySet(), ) = SetPortfolioReviewTransformer( - walletListUM = walletListUM, - currencies = currencies, - totalFiatBalance = totalFiatBalance, + accountStatusList = accountStatusList, appCurrency = appCurrency, expandedAssetIds = expandedAssetIds, expandClick = {}, onPeriodClick = {}, ) + private fun accountStatusList( + currencies: List, + totalFiatBalance: TotalFiatBalance, + ): AccountStatusList = mockk { + every { flattenCurrencies() } returns currencies + every { this@mockk.totalFiatBalance } returns totalFiatBalance + } + + private fun loaded(amount: BigDecimal, source: StatusSource = StatusSource.ACTUAL): TotalFiatBalance.Loaded = + TotalFiatBalance.Loaded(amount = amount, source = source) + private fun loadingState(): ForYouUM = ForYouUM( - walletListUM = walletListUM, - portfolioReviewUM = PortfolioReviewUM.Loading(tokenList = persistentListOf()), + portfolioReviewUM = PortfolioReviewUM.Loading( + tokenList = persistentListOf(), + marketChartUM = MarketChartUM.NoData, + ), + notifications = persistentListOf(), ) private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( @@ -219,8 +318,16 @@ internal class SetPortfolioReviewTransformerTest { 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" @@ -234,6 +341,7 @@ internal class SetPortfolioReviewTransformerTest { return mockk { 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