From 9f69ba143a02755fb4451e210c138e66ac385b26 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 18 Sep 2025 16:34:01 +0400 Subject: [PATCH] Updated on 2026-08-14 --- domain/account/status/build.gradle.kts | 1 + .../tangem/domain/models/TotalFiatBalance.kt | 5 +- .../domain/models/tokenlist/TokenList.kt | 1 - .../tokens/GetWalletTotalBalanceUseCase.kt | 9 +- .../TokenListFiatBalanceOperations.kt | 115 ---- .../tokens/operations/TokenListOperations.kt | 9 +- .../operations/TotalFiatBalanceCalculator.kt | 194 +++++++ .../domain/tokens/mock/MockTokenLists.kt | 2 - .../TotalFiatBalanceCalculatorTest.kt | 506 ++++++++++++++++++ 9 files changed, 708 insertions(+), 134 deletions(-) delete mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListFiatBalanceOperations.kt create mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculator.kt create mode 100644 domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt diff --git a/domain/account/status/build.gradle.kts b/domain/account/status/build.gradle.kts index 592deeab3a..104086e62c 100644 --- a/domain/account/status/build.gradle.kts +++ b/domain/account/status/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.networks) implementation(projects.domain.staking) + implementation(projects.domain.tokens) implementation(projects.libs.crypto) diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/TotalFiatBalance.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/TotalFiatBalance.kt index 374956a548..88af8c6bbd 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/TotalFiatBalance.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/TotalFiatBalance.kt @@ -26,13 +26,12 @@ sealed interface TotalFiatBalance { /** * Represents the successfully loaded state of the fiat balance. * - * @property amount the loaded fiat balance amount - * @property isAllAmountsSummarized indicates whether the amount includes a summary of all underlying amounts + * @property amount the loaded fiat balance amount + * @property source status source */ @Serializable data class Loaded( val amount: SerializedBigDecimal, - val isAllAmountsSummarized: Boolean, val source: StatusSource, ) : TotalFiatBalance } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/tokenlist/TokenList.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/tokenlist/TokenList.kt index bec343c4df..776c46147f 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/tokenlist/TokenList.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/tokenlist/TokenList.kt @@ -70,7 +70,6 @@ sealed interface TokenList { override val totalFiatBalance: TotalFiatBalance = TotalFiatBalance.Loaded( amount = SerializedBigDecimal.ZERO, - isAllAmountsSummarized = true, source = StatusSource.ACTUAL, ) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetWalletTotalBalanceUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetWalletTotalBalanceUseCase.kt index 0fc94390be..18401f9621 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetWalletTotalBalanceUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetWalletTotalBalanceUseCase.kt @@ -13,7 +13,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations -import com.tangem.domain.tokens.operations.TokenListFiatBalanceOperations +import com.tangem.domain.tokens.operations.TotalFiatBalanceCalculator import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import timber.log.Timber @@ -112,11 +112,8 @@ class GetWalletTotalBalanceUseCase( } } - val operations = TokenListFiatBalanceOperations( - currencies = ensureNotNull(statuses.toNonEmptyListOrNull()) { lceLoading() }, - isAnyTokenLoading = false, + TotalFiatBalanceCalculator.calculate( + statuses = ensureNotNull(statuses.toNonEmptyListOrNull()) { lceLoading() }, ) - - operations.calculateFiatBalance() } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListFiatBalanceOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListFiatBalanceOperations.kt deleted file mode 100644 index 198890fd63..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListFiatBalanceOperations.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.tangem.domain.tokens.operations - -import arrow.core.NonEmptyList -import com.tangem.domain.models.StatusSource -import com.tangem.domain.models.TotalFiatBalance -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.getResultStatusSource -import com.tangem.domain.models.staking.YieldBalance -import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance -import com.tangem.lib.crypto.BlockchainUtils -import com.tangem.utils.extensions.orZero -import java.math.BigDecimal - -internal class TokenListFiatBalanceOperations( - private val currencies: NonEmptyList, - private val isAnyTokenLoading: Boolean, -) { - - @Suppress("LoopWithTooManyJumpStatements") - fun calculateFiatBalance(): TotalFiatBalance { - var fiatBalance: TotalFiatBalance = TotalFiatBalance.Loading - if (isAnyTokenLoading) return fiatBalance - - for (token in currencies) { - val blockchainId = token.currency.network.rawId - when (val status = token.value) { - is CryptoCurrencyStatus.Loading -> { - fiatBalance = TotalFiatBalance.Loading - break - } - is CryptoCurrencyStatus.NoQuote, - is CryptoCurrencyStatus.MissedDerivation, - -> { - fiatBalance = TotalFiatBalance.Failed - break - } - is CryptoCurrencyStatus.Unreachable, - is CryptoCurrencyStatus.NoAmount, - -> { - if (BlockchainUtils.isIncludeToBalanceOnError(blockchainId)) { - fiatBalance = recalculateNoAccountBalance(status, fiatBalance) - } else { - fiatBalance = TotalFiatBalance.Failed - break - } - } - is CryptoCurrencyStatus.NoAccount -> { - fiatBalance = recalculateNoAccountBalance(status, fiatBalance) - } - is CryptoCurrencyStatus.Loaded -> { - fiatBalance = recalculateBalance(status, fiatBalance, blockchainId) - } - is CryptoCurrencyStatus.Custom -> { - fiatBalance = recalculateBalance(status, fiatBalance, blockchainId) - } - } - } - - return (fiatBalance as? TotalFiatBalance.Loaded)?.copy( - source = currencies.map { it.value.sources.total }.getResultStatusSource(), - ) ?: fiatBalance - } - - private fun recalculateNoAccountBalance( - status: CryptoCurrencyStatus.Value, - currentBalance: TotalFiatBalance, - ): TotalFiatBalance { - return (currentBalance as? TotalFiatBalance.Loaded)?.copy(isAllAmountsSummarized = false) - ?: TotalFiatBalance.Loaded( - amount = BigDecimal.ZERO, - isAllAmountsSummarized = false, - source = (status as? CryptoCurrencyStatus.NoAccount)?.sources?.total ?: StatusSource.ACTUAL, - ) - } - - private fun recalculateBalance( - status: CryptoCurrencyStatus.Loaded, - currentBalance: TotalFiatBalance, - blockchainId: String, - ): TotalFiatBalance { - return with(currentBalance) { - val yieldBalance = status.yieldBalance as? YieldBalance.Data - val stakingBalance = yieldBalance?.getTotalWithRewardsStakingBalance(blockchainId).orZero() - val fiatStakingBalance = status.fiatRate.times(stakingBalance) - (this as? TotalFiatBalance.Loaded)?.copy( - amount = this.amount + status.fiatAmount + fiatStakingBalance, - ) ?: TotalFiatBalance.Loaded( - amount = status.fiatAmount + fiatStakingBalance, - isAllAmountsSummarized = true, - source = status.sources.total, - ) - } - } - - private fun recalculateBalance( - status: CryptoCurrencyStatus.Custom, - currentBalance: TotalFiatBalance, - blockchainId: String, - ): TotalFiatBalance { - return with(currentBalance) { - val isTokenAmountCanBeSummarized = status.fiatAmount != null - val yieldBalance = (status.yieldBalance as? YieldBalance.Data) - ?.getTotalWithRewardsStakingBalance(blockchainId).orZero() - val fiatYieldBalance = status.fiatRate?.times(yieldBalance).orZero() - (this as? TotalFiatBalance.Loaded)?.copy( - amount = this.amount + status.fiatAmount.orZero() + fiatYieldBalance, - isAllAmountsSummarized = isTokenAmountCanBeSummarized, - ) ?: TotalFiatBalance.Loaded( - amount = status.fiatAmount.orZero() + fiatYieldBalance, - isAllAmountsSummarized = isTokenAmountCanBeSummarized, - source = StatusSource.ACTUAL, - ) - } - } -} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListOperations.kt index 01eac8c1bb..4ff3806cbc 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListOperations.kt @@ -33,13 +33,9 @@ internal class TokenListOperations( private fun Raise.createTokenList(isGrouped: Boolean, isSortedByBalance: Boolean): TokenList { val nonEmptyCurrencies = tokens.toNonEmptyListOrNull() ?: return TokenList.Empty - val isAnyTokenLoading = nonEmptyCurrencies.any { it.value is CryptoCurrencyStatus.Loading } - val fiatBalanceOperations = TokenListFiatBalanceOperations(nonEmptyCurrencies, isAnyTokenLoading) - return createTokenList( currencies = nonEmptyCurrencies, - fiatBalance = fiatBalanceOperations.calculateFiatBalance(), - isAnyTokenLoading = isAnyTokenLoading, + fiatBalance = TotalFiatBalanceCalculator.calculate(statuses = nonEmptyCurrencies), isGrouped = isGrouped, isSortedByBalance = isSortedByBalance, ) @@ -48,13 +44,12 @@ internal class TokenListOperations( private fun Raise.createTokenList( currencies: NonEmptyList, fiatBalance: TotalFiatBalance, - isAnyTokenLoading: Boolean, isGrouped: Boolean, isSortedByBalance: Boolean, ): TokenList { val sortingOperations = TokenListSortingOperations( currencies = currencies, - isAnyTokenLoading = isAnyTokenLoading, + isAnyTokenLoading = currencies.any { it.value is CryptoCurrencyStatus.Loading }, sortByBalance = isSortedByBalance, ) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculator.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculator.kt new file mode 100644 index 0000000000..bef4a4e3dc --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculator.kt @@ -0,0 +1,194 @@ +package com.tangem.domain.tokens.operations + +import arrow.core.NonEmptyList +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.getResultStatusSource +import com.tangem.domain.models.staking.YieldBalance +import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.extensions.orZero +import java.math.BigDecimal + +/** + * Utility to calculate total fiat balance from a set of [CryptoCurrencyStatus]. + * + * The calculation considers various states of each cryptocurrency, including loading, + * unreachable, no amount, no account, loaded, and custom states. + * + * The result is a [TotalFiatBalance], which can be in one of the following states: + * - [TotalFiatBalance.Loading]: If any cryptocurrency is still loading. + * - [TotalFiatBalance.Failed]: If any cryptocurrency is in a non-computable state (e.g., no quote). + * - [TotalFiatBalance.Loaded]: If all cryptocurrencies are computable, containing the total fiat amount, + * a flag indicating if all amounts were summarized, and the source of the data. + * + * The calculation also takes into account staking balances when available. + */ +object TotalFiatBalanceCalculator { + + fun calculate(statuses: NonEmptyList): TotalFiatBalance { + val computationState = ComputationState.resolve(statuses) + + return when (computationState) { + ComputationState.LOADING -> TotalFiatBalance.Loading + ComputationState.NON_COMPUTABLE -> TotalFiatBalance.Failed + ComputationState.COMPUTABLE -> compute(statuses) + } + } + + private fun compute(statuses: NonEmptyList): TotalFiatBalance { + var mutableBalance: TotalFiatBalance = TotalFiatBalance.Loading + + for (token in statuses) { + val blockchainId = token.currency.network.rawId + + when (val status = token.value) { + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + is CryptoCurrencyStatus.NoAccount, + -> { + mutableBalance = mutableBalance.plusEmptyBalance(status) + } + is CryptoCurrencyStatus.Loaded -> { + mutableBalance = mutableBalance.plusLoaded(status, blockchainId) + } + is CryptoCurrencyStatus.Custom -> { + mutableBalance = mutableBalance.plusLoaded(status, blockchainId) + } + // Non computable states, should be handled before + CryptoCurrencyStatus.Loading, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.NoQuote, + -> continue + } + } + + return mutableBalance.updateSource(statuses) + } + + private fun TotalFiatBalance.plusEmptyBalance(status: CryptoCurrencyStatus.Value): TotalFiatBalance { + return fold( + ifLoaded = { it }, + ifNot = { + TotalFiatBalance.Loaded( + amount = BigDecimal.ZERO, + source = status.sources.total, // never mind + ) + }, + ) + } + + private fun TotalFiatBalance.plusLoaded( + status: CryptoCurrencyStatus.Loaded, + blockchainId: String, + ): TotalFiatBalance { + val fiatStakingBalance = status.getFiatStakingBalance(blockchainId) + + return fold( + ifLoaded = { loaded -> + loaded.copy(amount = loaded.amount + status.fiatAmount + fiatStakingBalance) + }, + ifNot = { + TotalFiatBalance.Loaded( + amount = status.fiatAmount + fiatStakingBalance, + source = status.sources.total, // never mind + ) + }, + ) + } + + private fun TotalFiatBalance.plusLoaded( + status: CryptoCurrencyStatus.Custom, + blockchainId: String, + ): TotalFiatBalance { + val fiatStakingBalance = status.getFiatStakingBalance(blockchainId) + + return fold( + ifLoaded = { loaded -> + loaded.copy( + amount = loaded.amount + status.fiatAmount.orZero() + fiatStakingBalance, + ) + }, + ifNot = { + TotalFiatBalance.Loaded( + amount = status.fiatAmount.orZero() + fiatStakingBalance, + source = StatusSource.ACTUAL, + ) + }, + ) + } + + private fun TotalFiatBalance.updateSource(statuses: NonEmptyList): TotalFiatBalance { + return fold( + ifLoaded = { loaded -> + loaded.copy( + source = statuses.map { it.value.sources.total }.getResultStatusSource(), + ) + }, + ifNot = { this }, + ) + } + + private fun CryptoCurrencyStatus.Loaded.getFiatStakingBalance(blockchainId: String): BigDecimal { + val yieldBalance = yieldBalance as? YieldBalance.Data + val stakingBalance = yieldBalance?.getTotalWithRewardsStakingBalance(blockchainId).orZero() + + return fiatRate.times(stakingBalance) + } + + private fun CryptoCurrencyStatus.Custom.getFiatStakingBalance(blockchainId: String): BigDecimal { + val yieldBalance = yieldBalance as? YieldBalance.Data + val stakingBalance = yieldBalance?.getTotalWithRewardsStakingBalance(blockchainId).orZero() + + return fiatRate?.times(stakingBalance).orZero() + } + + private inline fun TotalFiatBalance.fold( + ifLoaded: (TotalFiatBalance.Loaded) -> TotalFiatBalance, + ifNot: () -> TotalFiatBalance, + ): TotalFiatBalance { + return if (this is TotalFiatBalance.Loaded) { + ifLoaded(this) + } else { + ifNot() + } + } + + private enum class ComputationState { + + LOADING, NON_COMPUTABLE, COMPUTABLE; + + companion object { + + fun resolve(statuses: NonEmptyList): ComputationState { + for (status in statuses) { + when (status.value) { + CryptoCurrencyStatus.Loading -> { + return LOADING + } + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.MissedDerivation, + -> { + return NON_COMPUTABLE + } + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + -> { + val blockchainId = status.currency.network.rawId + if (!BlockchainUtils.isIncludeToBalanceOnError(blockchainId)) { + return NON_COMPUTABLE + } + } + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.NoAccount, + -> continue + } + } + + return COMPUTABLE + } + } + } +} \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockTokenLists.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockTokenLists.kt index caa2c13c6f..af553ea246 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockTokenLists.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockTokenLists.kt @@ -80,7 +80,6 @@ internal object MockTokenLists { sortedBy = TokensSortType.NONE, totalFiatBalance = TotalFiatBalance.Loaded( amount = tokens.sumOf { it.value.fiatAmount ?: BigDecimal.ZERO }, - isAllAmountsSummarized = true, source = StatusSource.ACTUAL, ), ) @@ -97,7 +96,6 @@ internal object MockTokenLists { amount = groups .flatMap { it.currencies as NonEmptyList } .sumOf { it.value.fiatAmount ?: BigDecimal.ZERO }, - isAllAmountsSummarized = true, source = StatusSource.ACTUAL, ), ) diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt new file mode 100644 index 0000000000..12dba0e8bd --- /dev/null +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt @@ -0,0 +1,506 @@ +package com.tangem.domain.tokens.operations + +import arrow.core.nonEmptyListOf +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +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.NetworkAddress +import com.tangem.domain.models.staking.BalanceItem +import com.tangem.domain.models.staking.BalanceType +import com.tangem.domain.models.staking.YieldBalance +import com.tangem.domain.models.staking.YieldBalanceItem +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TotalFiatBalanceCalculatorTest { + + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val binance = cryptoCurrencyFactory.createCoin(Blockchain.Binance) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class LoadingOrNonComputable { + + @Test + fun `one token is Loading, total is Loading`() { + // Arrange + val statuses = nonEmptyListOf( + createLoading(currency = cryptoCurrencyFactory.ethereum), + createNoQuote(currency = cryptoCurrencyFactory.stellar), + createMissedDerivation(currency = cryptoCurrencyFactory.chia), + ) + + // Act + val actual = TotalFiatBalanceCalculator.calculate(statuses) + + // Assert + val expected = TotalFiatBalance.Loading + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `one token is NoQuote, total is Failed`() { + // Arrange + val statuses = nonEmptyListOf( + createNoQuote(currency = cryptoCurrencyFactory.stellar), + createUnreachable(currency = cryptoCurrencyFactory.chia), + ) + + // Act + val actual = TotalFiatBalanceCalculator.calculate(statuses) + + // Assert + val expected = TotalFiatBalance.Failed + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `one token is MissedDerivation, total is Failed`() { + // Arrange + val statuses = nonEmptyListOf( + createNoQuote(currency = cryptoCurrencyFactory.stellar), + createUnreachable(currency = cryptoCurrencyFactory.chia), + ) + + // Act + val actual = TotalFiatBalanceCalculator.calculate(statuses) + + // Assert + val expected = TotalFiatBalance.Failed + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `one token is Unreachable and isIncludeToBalanceOnError is FALSE, total is Failed`() { + // Arrange + val statuses = nonEmptyListOf( + createUnreachable(currency = cryptoCurrencyFactory.stellar), + createNoAccount(currency = cryptoCurrencyFactory.chia), + ) + + // Act + val actual = TotalFiatBalanceCalculator.calculate(statuses) + + // Assert + val expected = TotalFiatBalance.Failed + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `one token is NoAmount and isIncludeToBalanceOnError is FALSE, total is Failed`() { + // Arrange + val statuses = nonEmptyListOf( + createNoAmount(currency = cryptoCurrencyFactory.stellar), + createNoAccount(currency = cryptoCurrencyFactory.chia), + ) + + // Act + val actual = TotalFiatBalanceCalculator.calculate(statuses) + + // Assert + val expected = TotalFiatBalance.Failed + Truth.assertThat(actual).isEqualTo(expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Computable { + + @Test + fun `Unreachable token isIncludeToBalanceOnError, total is Loaded`() { + // Arrange + val statuses = nonEmptyListOf(createUnreachable(currency = binance)) + + // Act + val actual = TotalFiatBalanceCalculator.calculate(statuses) + + // Assert + val expected = TotalFiatBalance.Loaded( + amount = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ) + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `NoAmount token isIncludeToBalanceOnError, total is Loaded`() { + // Arrange + val statuses = nonEmptyListOf(createNoAmount(currency = binance)) + + // Act + val actual = TotalFiatBalanceCalculator.calculate(statuses) + + // Assert + val expected = TotalFiatBalance.Loaded( + amount = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ) + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `all tokens are NoAccount, total is Loaded`() { + // Arrange + val statuses = nonEmptyListOf( + createNoAccount(currency = cryptoCurrencyFactory.ethereum), + createNoAccount(currency = cryptoCurrencyFactory.stellar), + createNoAccount(currency = cryptoCurrencyFactory.chia), + ) + + // Act + val actual = TotalFiatBalanceCalculator.calculate(statuses) + + // Assert + val expected = TotalFiatBalance.Loaded( + amount = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ) + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `all tokens are Custom, total is Loaded`() { + // Arrange + val statuses = nonEmptyListOf( + /** + * Balance: 10 + * - fiat: 10 + * - staking: 0 + */ + createCustom( + currency = cryptoCurrencyFactory.ethereum, + fiatAmount = BigDecimal.TEN, + ), + /** + * Balance: 20 + * - fiat: 0 + * - staking: 20 (REWARDS) + */ + createCustom( + currency = cryptoCurrencyFactory.cardano, + fiatAmount = BigDecimal.ZERO, + yieldBalance = createYieldBalance( + amount = BigDecimal(20), + // It is important to use `BalanceType.REWARDS` because Cardano should not include the full + // staking balance. See `getTotalWithRewardsStakingBalance`. + balanceType = BalanceType.REWARDS, + ), + ), + /** + * Balance: 10 + * - fiat: 1 + * - staking: 9 (STAKED) + */ + createCustom( + currency = cryptoCurrencyFactory.stellar, + fiatAmount = BigDecimal.ONE, + yieldBalance = createYieldBalance( + amount = BigDecimal(9), + balanceType = BalanceType.STAKED, + ), + ), + ) + + // Act + val actual = TotalFiatBalanceCalculator.calculate(statuses) + + // Assert + val expected = TotalFiatBalance.Loaded( + amount = BigDecimal(40), + source = StatusSource.ACTUAL, + ) + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `all tokens are Loaded, total is Loaded`() { + // Arrange + val statuses = nonEmptyListOf( + /** + * Balance: 10 + * - fiat: 10 + * - staking: 0 + */ + createLoaded( + currency = cryptoCurrencyFactory.ethereum, + fiatAmount = BigDecimal.TEN, + ), + /** + * Balance: 20 + * - fiat: 0 + * - staking: 20 (REWARDS) + */ + createLoaded( + currency = cryptoCurrencyFactory.cardano, + fiatAmount = BigDecimal.ZERO, + yieldBalance = createYieldBalance( + amount = BigDecimal(20), + // It is important to use `BalanceType.REWARDS` because Cardano should not include the full + // staking balance. See `getTotalWithRewardsStakingBalance`. + balanceType = BalanceType.REWARDS, + ), + ), + /** + * Balance: 10 + * - fiat: 1 + * - staking: 9 (STAKED) + */ + createLoaded( + currency = cryptoCurrencyFactory.stellar, + fiatAmount = BigDecimal.ONE, + yieldBalance = createYieldBalance( + amount = BigDecimal(9), + balanceType = BalanceType.STAKED, + ), + ), + ) + + // Act + val actual = TotalFiatBalanceCalculator.calculate(statuses) + + // Assert + val expected = TotalFiatBalance.Loaded( + amount = BigDecimal(40), + source = StatusSource.ACTUAL, + ) + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `tokens contain all types of computable statuses, total is Loaded`() { + // Arrange + val statuses = nonEmptyListOf( + createNoAccount(currency = cryptoCurrencyFactory.ethereum), // 0 + createNoAmount(currency = binance), // 0 + createUnreachable(currency = binance), // 0 + createCustom( + // 1 + currency = cryptoCurrencyFactory.cardano, + fiatAmount = BigDecimal.ONE, + ), + createLoaded( + // 10 + currency = cryptoCurrencyFactory.stellar, + fiatAmount = BigDecimal.TEN, + ), + ) + + // Act + val actual = TotalFiatBalanceCalculator.calculate(statuses) + + // Assert + val expected = TotalFiatBalance.Loaded( + amount = BigDecimal(11), + source = StatusSource.ACTUAL, + ) + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `StatusSource is Actual is all tokens are Actual`() { + // Arrange + val statuses = nonEmptyListOf( + createNoAccount(currency = cryptoCurrencyFactory.ethereum), + createNoAmount(currency = binance), + createUnreachable(currency = binance), + createCustom( + currency = cryptoCurrencyFactory.cardano, + fiatAmount = BigDecimal.ONE, + ), + createLoaded( + currency = cryptoCurrencyFactory.stellar, + fiatAmount = BigDecimal.TEN, + ), + ) + + // Act + val actual = (TotalFiatBalanceCalculator.calculate(statuses) as TotalFiatBalance.Loaded).source + + // Assert + val expected = StatusSource.ACTUAL + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `StatusSource is Cache is any token is Cache`() { + // Arrange + val statuses = nonEmptyListOf( + createNoAccount(currency = cryptoCurrencyFactory.ethereum), + createNoAmount(currency = binance), + createLoaded( + currency = cryptoCurrencyFactory.cardano, + fiatAmount = BigDecimal.ONE, + source = StatusSource.CACHE, + ), + ) + + // Act + val actual = (TotalFiatBalanceCalculator.calculate(statuses) as TotalFiatBalance.Loaded).source + + // Assert + val expected = StatusSource.CACHE + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `StatusSource is ONLY_CACHE is any token is ONLY_CACHE`() { + // Arrange + val statuses = nonEmptyListOf( + createNoAccount(currency = cryptoCurrencyFactory.ethereum), + createNoAmount(currency = binance), + createLoaded( + currency = cryptoCurrencyFactory.cardano, + fiatAmount = BigDecimal.ONE, + source = StatusSource.ONLY_CACHE, + ), + ) + + // Act + val actual = (TotalFiatBalanceCalculator.calculate(statuses) as TotalFiatBalance.Loaded).source + + // Assert + val expected = StatusSource.ONLY_CACHE + Truth.assertThat(actual).isEqualTo(expected) + } + } + + private fun createLoading(currency: CryptoCurrency): CryptoCurrencyStatus { + return CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loading, + ) + } + + private fun createNoQuote(currency: CryptoCurrency): CryptoCurrencyStatus { + return CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.NoQuote( + amount = BigDecimal.ONE, + yieldBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = createNetworkAddress(), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + } + + private fun createMissedDerivation(currency: CryptoCurrency): CryptoCurrencyStatus { + return CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null), + ) + } + + private fun createUnreachable(currency: CryptoCurrency): CryptoCurrencyStatus { + return CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Unreachable(priceChange = null, fiatRate = null, networkAddress = null), + ) + } + + private fun createNoAmount(currency: CryptoCurrency): CryptoCurrencyStatus { + return CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.NoAmount(priceChange = null, fiatRate = null), + ) + } + + private fun createNoAccount(currency: CryptoCurrency): CryptoCurrencyStatus { + return CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.NoAccount( + priceChange = null, + amountToCreateAccount = BigDecimal.ONE, + fiatAmount = null, + fiatRate = null, + networkAddress = createNetworkAddress(), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + } + + private fun createCustom( + currency: CryptoCurrency, + fiatAmount: BigDecimal?, + yieldBalance: YieldBalance? = null, + ): CryptoCurrencyStatus { + return CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Custom( + amount = BigDecimal.ONE, + fiatAmount = fiatAmount, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + yieldBalance = yieldBalance, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = createNetworkAddress(), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + } + + private fun createLoaded( + currency: CryptoCurrency, + fiatAmount: BigDecimal, + yieldBalance: YieldBalance? = null, + source: StatusSource = StatusSource.ACTUAL, + ): CryptoCurrencyStatus { + return CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loaded( + amount = BigDecimal.ONE, + fiatAmount = fiatAmount, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + yieldBalance = yieldBalance, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = createNetworkAddress(), + sources = CryptoCurrencyStatus.Sources(source, source, source), + ), + ) + } + + private fun createYieldBalance(amount: BigDecimal, balanceType: BalanceType): YieldBalance.Data { + return YieldBalance.Data( + stakingId = mockk(), + source = StatusSource.ACTUAL, + balance = YieldBalanceItem( + items = listOf( + mockk { + every { this@mockk.amount } returns amount + every { this@mockk.type } returns balanceType + }, + ), + integrationId = "", + ), + ) + } + + private fun createNetworkAddress(): NetworkAddress.Single { + return NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x1", + type = NetworkAddress.Address.Type.Primary, + ), + ) + } +} \ No newline at end of file