Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-08 00:44:19 +05:00
parent 8a8f657d03
commit fccbac44c2
4 changed files with 1952 additions and 0 deletions

View file

@ -0,0 +1,385 @@
package com.tangem.feature.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
import com.tangem.feature.swap.domain.models.domain.RateType
import com.tangem.feature.swap.domain.models.domain.SwapFeeState
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.FeeType
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.domain.models.ui.TxFee
import com.tangem.feature.swap.domain.models.ui.TxFeeState
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.utils.Provider
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Characterization tests for `StateBuilder.createFeeState` (private), exercised through the
* public `createQuotesLoadedState`.
*
* Pinned behavior:
* - `TxFeeState.Empty` `FeeItemState.Empty`
* - `TxFeeState.SingleFeeState` `FeeItemState.Content` with `isClickable = false`
* - `TxFeeState.MultipleFeeState` + `selectedFeeType = NORMAL` uses normal fee values, isClickable = true
* - `TxFeeState.MultipleFeeState` + `selectedFeeType = PRIORITY` uses priority fee values, isClickable = true
* - `hideFee = true` always `FeeItemState.Empty` regardless of `txFee`
* - `feeCryptoFormattedWithNative` is what populates `FeeItemState.Content.amountCrypto`,
* NOT the plain `feeCryptoFormatted`. Same for fiat. (This pins the bridge-fee
* "display fee with native as workaround for okx" pathway.)
*
* [REDACTED_TASK_KEY] these exist to guarantee the redesign's `FeeSelectorBlockComponent` carries
* the same display semantics across the cutover.
*/
internal class StateBuilderFeeStateTest {
private val actions: UiActions = mockk(relaxed = true)
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private lateinit var sut: StateBuilder
private val userWalletId = UserWalletId("aabbccdd")
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
every { walletId } returns userWalletId
}
@BeforeEach
fun setup() {
every { isBalanceHiddenProvider() } returns false
every { appCurrencyProvider() } returns AppCurrency.Default
every { isAccountsModeProvider() } returns false
every { isGaslessFeeSupportedForNetwork(any()) } returns false
sut = StateBuilder(
actions = actions,
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
)
}
@Test
fun `GIVEN TxFeeState Empty WHEN hideFee false THEN fee is FeeItemState Empty`() {
val baseState = buildReadyState()
val quoteModel = buildQuoteModel(txFeeState = TxFeeState.Empty)
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = buildSwapProvider(),
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
}
@Test
fun `GIVEN SingleFeeState WHEN hideFee false THEN fee Content is not clickable`() {
val baseState = buildReadyState()
val singleFee = buildLegacyFee(
feeType = FeeType.NORMAL,
cryptoFormatted = "0.001 ETH",
cryptoFormattedWithNative = "0.001 ETH",
fiatFormatted = "$2.00",
fiatFormattedWithNative = "$2.00",
)
val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee))
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = buildSwapProvider(),
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
val feeContent = result.fee as FeeItemState.Content
assertThat(feeContent.isClickable).isFalse()
// Field source pinning: amountCrypto/fiatFormatted come from the *WithNative variants.
assertThat(feeContent.amountCrypto).isEqualTo("0.001 ETH")
assertThat(feeContent.amountFiatFormatted).isEqualTo("$2.00")
}
@Test
fun `GIVEN MultipleFeeState WHEN selectedFeeType NORMAL THEN fee Content has normal fee values and is clickable`() {
val baseState = buildReadyState()
val normalFee = buildLegacyFee(
feeType = FeeType.NORMAL,
cryptoFormatted = "0.001 ETH",
cryptoFormattedWithNative = "0.001 ETH",
fiatFormatted = "$2.00",
fiatFormattedWithNative = "$2.00",
)
val priorityFee = buildLegacyFee(
feeType = FeeType.PRIORITY,
cryptoFormatted = "0.005 ETH",
cryptoFormattedWithNative = "0.005 ETH",
fiatFormatted = "$10.00",
fiatFormattedWithNative = "$10.00",
)
val quoteModel = buildQuoteModel(
txFeeState = TxFeeState.MultipleFeeState(normalFee = normalFee, priorityFee = priorityFee),
)
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = buildSwapProvider(),
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
val feeContent = result.fee as FeeItemState.Content
assertThat(feeContent.isClickable).isTrue()
assertThat(feeContent.feeType).isEqualTo(FeeType.NORMAL)
assertThat(feeContent.amountCrypto).isEqualTo("0.001 ETH")
assertThat(feeContent.amountFiatFormatted).isEqualTo("$2.00")
}
@Test
fun `GIVEN MultipleFeeState WHEN selectedFeeType PRIORITY THEN fee Content has priority fee values and is clickable`() {
val baseState = buildReadyState()
val normalFee = buildLegacyFee(
feeType = FeeType.NORMAL,
cryptoFormatted = "0.001 ETH",
cryptoFormattedWithNative = "0.001 ETH",
fiatFormatted = "$2.00",
fiatFormattedWithNative = "$2.00",
)
val priorityFee = buildLegacyFee(
feeType = FeeType.PRIORITY,
cryptoFormatted = "0.005 ETH",
cryptoFormattedWithNative = "0.005 ETH",
fiatFormatted = "$10.00",
fiatFormattedWithNative = "$10.00",
)
val quoteModel = buildQuoteModel(
txFeeState = TxFeeState.MultipleFeeState(normalFee = normalFee, priorityFee = priorityFee),
)
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = buildSwapProvider(),
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.PRIORITY,
needApplyFCARestrictions = false,
hideFee = false,
)
val feeContent = result.fee as FeeItemState.Content
assertThat(feeContent.isClickable).isTrue()
assertThat(feeContent.feeType).isEqualTo(FeeType.PRIORITY)
assertThat(feeContent.amountCrypto).isEqualTo("0.005 ETH")
assertThat(feeContent.amountFiatFormatted).isEqualTo("$10.00")
}
@Test
fun `GIVEN hideFee true WHEN any TxFeeState THEN fee is Empty`() {
val baseState = buildReadyState()
val singleFee = buildLegacyFee(
feeType = FeeType.NORMAL,
cryptoFormatted = "0.001 ETH",
cryptoFormattedWithNative = "0.001 ETH",
fiatFormatted = "$2.00",
fiatFormattedWithNative = "$2.00",
)
val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee))
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = buildSwapProvider(),
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = true,
)
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
}
@Test
fun `GIVEN otherNativeFee greater than feeValue WHEN SingleFeeState THEN amountCrypto reflects the With-Native variant`() {
// Bridge fee scenario: the WithNative formatted strings differ from the plain ones.
// StateBuilder.createFeeState picks `feeCryptoFormattedWithNative` (and fiat) — pinning that.
val baseState = buildReadyState()
val singleFee = buildLegacyFee(
feeType = FeeType.NORMAL,
cryptoFormatted = "0.001 ETH",
cryptoFormattedWithNative = "0.006 ETH", // includes 0.005 bridge native fee
fiatFormatted = "$2.00",
fiatFormattedWithNative = "$12.00",
)
val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee))
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = buildSwapProvider(),
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
val feeContent = result.fee as FeeItemState.Content
assertThat(feeContent.amountCrypto).isEqualTo("0.006 ETH")
assertThat(feeContent.amountFiatFormatted).isEqualTo("$12.00")
}
@Test
fun `GIVEN otherNativeFee equal to feeValue WHEN SingleFeeState THEN amountCrypto equals plain feeCryptoFormatted`() {
// No bridge fee → WithNative strings happen to equal the plain strings.
val baseState = buildReadyState()
val singleFee = buildLegacyFee(
feeType = FeeType.NORMAL,
cryptoFormatted = "0.0007 ETH",
cryptoFormattedWithNative = "0.0007 ETH",
fiatFormatted = "$1.40",
fiatFormattedWithNative = "$1.40",
)
val quoteModel = buildQuoteModel(txFeeState = TxFeeState.SingleFeeState(singleFee))
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = buildSwapProvider(),
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
val feeContent = result.fee as FeeItemState.Content
assertThat(feeContent.amountCrypto).isEqualTo("0.0007 ETH")
assertThat(feeContent.amountFiatFormatted).isEqualTo("$1.40")
}
// region — local fixtures
private fun buildReadyState(): SwapStateHolder {
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
return sut.createInitialReadyState(
uiStateHolder = sut.createInitialLoadingState(),
emptyAmountState = SwapState.EmptyAmountState(
zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"),
),
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
}
private fun buildQuoteModel(
txFeeState: TxFeeState,
): SwapState.QuotesLoadedState {
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val fromInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("1.0"), 18),
amountFiat = BigDecimal("100.00"),
swapCurrencyStatus = fromStatus,
)
val toInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("0.05"), 18),
amountFiat = BigDecimal("100.00"),
swapCurrencyStatus = toStatus,
)
return SwapState.QuotesLoadedState(
fromTokenInfo = fromInfo,
toTokenInfo = toInfo,
priceImpact = PriceImpact.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
isBalanceEnough = true,
feeState = SwapFeeState.Enough,
hasOutgoingTransaction = false,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
),
permissionState = PermissionDataState.Empty,
txFee = txFeeState,
currencyCheck = null,
validationResult = null,
minAdaValue = null,
swapProvider = buildSwapProvider(),
)
}
private fun buildSwapProvider(): SwapProvider = SwapProvider(
providerId = "provider-id",
rateTypes = listOf(RateType.FLOAT),
name = "TestProvider",
type = ExchangeProviderType.DEX,
imageLarge = "https://example.com/icon.png",
termsOfUse = null,
privacyPolicy = null,
isRecommended = false,
slippage = null,
isExtraIdSupported = false,
)
private fun buildLegacyFee(
feeType: FeeType,
cryptoFormatted: String,
cryptoFormattedWithNative: String,
fiatFormatted: String,
fiatFormattedWithNative: String,
): TxFee.Legacy {
val fee: Fee = mockk(relaxed = true)
return TxFee.Legacy(
feeValue = BigDecimal("0.001"),
feeFiatFormatted = fiatFormatted,
feeCryptoFormatted = cryptoFormatted,
feeIncludeOtherNativeFee = BigDecimal.ZERO,
feeFiatFormattedWithNative = fiatFormattedWithNative,
feeCryptoFormattedWithNative = cryptoFormattedWithNative,
cryptoSymbol = "ETH",
feeType = feeType,
fee = fee,
)
}
// endregion
}

View file

@ -0,0 +1,486 @@
package com.tangem.feature.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.notifications.NotificationUM
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.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
import com.tangem.feature.swap.domain.models.domain.RateType
import com.tangem.feature.swap.domain.models.domain.SwapFeeState
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.FeeType
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.domain.models.ui.TxFee
import com.tangem.feature.swap.domain.models.ui.TxFeeState
import com.tangem.feature.swap.model.SwapNotificationsFactory
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.SwapNotificationUM
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Characterization tests for fee-related notifications produced by [SwapNotificationsFactory].
*
* Pinned behavior:
* - [SwapNotificationUM.Error.UnableToCoverFeeWarning]:
* adds when `feeState = NotEnough` AND `isBalanceEnough = true` AND
* `permissionState != PermissionLoading` AND fee currency != fromCurrency,
* AND NOT (gasless network AND CEX provider).
* Suppressed for CEX-on-gasless-network. Re-added unconditionally when
* `includeFeeInAmount is BalanceNotEnough`.
* - [NotificationUM.Warning.FeeCoverageNotification]: triggers on
* `includeFeeInAmount is Included` AND a fee is selected AND no existential deposit.
* - [SwapNotificationUM.Info.PermissionNeeded]: triggers on `permissionState is PermissionRequired`.
* - [SwapNotificationUM.Error.TransactionInProgressWarning]: triggers on
* `hasOutgoingTransaction = true` (when `permissionState is not PermissionLoading`).
* - `hideFee = true` short-circuits `maybeAddUnableCoverFeeWarning` only.
*
* [REDACTED_TASK_KEY] these guard the redesign that consolidates fee-state into `SwapFee` /
* `FeeBucket`. Phase 5 will rewrite the factory; these tests stay green throughout.
*/
internal class SwapNotificationsFactoryFeeWarningsTest {
private val actions: UiActions = mockk(relaxed = true)
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val sut: SwapNotificationsFactory by lazy {
SwapNotificationsFactory(
actions = actions,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
)
}
private val ethNetworkMock: Network = mockk(relaxed = true) {
every { name } returns "Ethereum"
every { currencySymbol } returns "ETH"
every { rawId } returns "ethereum"
}
private val fromCurrency: CryptoCurrency = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { network } returns ethNetworkMock
every { symbol } returns "ETH"
every { decimals } returns 18
every { name } returns "Ethereum"
}
private val differentFeeCurrency: CryptoCurrency = mockk<CryptoCurrency.Token>(relaxed = true) {
every { network } returns ethNetworkMock
every { symbol } returns "USDC"
every { decimals } returns 6
every { name } returns "USD Coin"
}
// ---------- UnableToCoverFeeWarning ----------
@Test
fun `UnableToCoverFeeWarning is added when feeState NotEnough and balance enough and not gasless and fee currency differs`() {
// Given
every { isGaslessFeeSupportedForNetwork(any()) } returns false
val quoteModel = buildQuoteModel(
feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"),
isBalanceEnough = true,
permissionState = PermissionDataState.Empty,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
providerType = ExchangeProviderType.DEX,
)
val feeStatus = buildFeeStatus(differentFeeCurrency)
// When
val notifications = sut.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeStatus,
selectedFeeType = FeeType.NORMAL,
providerName = "TestProvider",
hideFee = false,
)
// Then
assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isTrue()
}
@Test
fun `UnableToCoverFeeWarning is suppressed when gasless is available for CEX provider`() {
// Given — CEX + supported network → suppressed
every { isGaslessFeeSupportedForNetwork(any()) } returns true
val quoteModel = buildQuoteModel(
feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"),
isBalanceEnough = true,
permissionState = PermissionDataState.Empty,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
providerType = ExchangeProviderType.CEX,
)
val feeStatus = buildFeeStatus(differentFeeCurrency)
// When
val notifications = sut.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeStatus,
selectedFeeType = FeeType.NORMAL,
providerName = "TestProvider",
hideFee = false,
)
// Then
assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isFalse()
}
@Test
fun `UnableToCoverFeeWarning is added even when gasless is available IF includeFeeInAmount is BalanceNotEnough`() {
// Given — CEX + supported network BUT BalanceNotEnough overrides the suppression.
every { isGaslessFeeSupportedForNetwork(any()) } returns true
val quoteModel = buildQuoteModel(
feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"),
isBalanceEnough = true,
permissionState = PermissionDataState.Empty,
includeFeeInAmount = IncludeFeeInAmount.BalanceNotEnough,
providerType = ExchangeProviderType.CEX,
)
val feeStatus = buildFeeStatus(differentFeeCurrency)
// When
val notifications = sut.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeStatus,
selectedFeeType = FeeType.NORMAL,
providerName = "TestProvider",
hideFee = false,
)
// Then
assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isTrue()
}
@Test
fun `UnableToCoverFeeWarning is not added when hideFee true`() {
// Given
every { isGaslessFeeSupportedForNetwork(any()) } returns false
val quoteModel = buildQuoteModel(
feeState = SwapFeeState.NotEnough(currencyName = "Ethereum", currencySymbol = "ETH"),
isBalanceEnough = true,
permissionState = PermissionDataState.Empty,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
providerType = ExchangeProviderType.DEX,
)
val feeStatus = buildFeeStatus(differentFeeCurrency)
// When
val notifications = sut.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeStatus,
selectedFeeType = FeeType.NORMAL,
providerName = "TestProvider",
hideFee = true,
)
// Then
assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isFalse()
}
@Test
fun `UnableToCoverFeeWarning is not added when feeState is Enough`() {
// Given
every { isGaslessFeeSupportedForNetwork(any()) } returns false
val quoteModel = buildQuoteModel(
feeState = SwapFeeState.Enough,
isBalanceEnough = true,
permissionState = PermissionDataState.Empty,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
providerType = ExchangeProviderType.DEX,
)
val feeStatus = buildFeeStatus(differentFeeCurrency)
// When
val notifications = sut.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeStatus,
selectedFeeType = FeeType.NORMAL,
providerName = "TestProvider",
hideFee = false,
)
// Then
assertThat(notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }).isFalse()
}
// ---------- FeeCoverageNotification ----------
@Test
fun `FeeCoverageNotification is added when includeFeeInAmount is Included with a selected fee`() {
// Given
every { isGaslessFeeSupportedForNetwork(any()) } returns false
val singleFee = buildLegacyFee(FeeType.NORMAL)
val quoteModel = buildQuoteModel(
feeState = SwapFeeState.Enough,
isBalanceEnough = true,
permissionState = PermissionDataState.Empty,
includeFeeInAmount = IncludeFeeInAmount.Included(SwapAmount(BigDecimal("0.99"), 18)),
providerType = ExchangeProviderType.CEX,
txFeeState = TxFeeState.SingleFeeState(singleFee),
)
val feeStatus = buildFeeStatus(fromCurrency)
// When
val notifications = sut.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeStatus,
selectedFeeType = FeeType.NORMAL,
providerName = "TestProvider",
hideFee = false,
)
// Then
assertThat(notifications.any { it is NotificationUM.Warning.FeeCoverageNotification }).isTrue()
}
@Test
fun `FeeCoverageNotification is not added when includeFeeInAmount is Excluded`() {
// Given
every { isGaslessFeeSupportedForNetwork(any()) } returns false
val singleFee = buildLegacyFee(FeeType.NORMAL)
val quoteModel = buildQuoteModel(
feeState = SwapFeeState.Enough,
isBalanceEnough = true,
permissionState = PermissionDataState.Empty,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
providerType = ExchangeProviderType.CEX,
txFeeState = TxFeeState.SingleFeeState(singleFee),
)
val feeStatus = buildFeeStatus(fromCurrency)
// When
val notifications = sut.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeStatus,
selectedFeeType = FeeType.NORMAL,
providerName = "TestProvider",
hideFee = false,
)
// Then
assertThat(notifications.any { it is NotificationUM.Warning.FeeCoverageNotification }).isFalse()
}
// ---------- PermissionNeeded ----------
@Test
fun `PermissionNeeded is added when permissionState is PermissionRequired`() {
// Given
every { isGaslessFeeSupportedForNetwork(any()) } returns false
val quoteModel = buildQuoteModel(
feeState = SwapFeeState.Enough,
isBalanceEnough = true,
permissionState = PermissionDataState.PermissionRequired(
isResetApproval = false,
spenderAddress = "0xSpender",
),
includeFeeInAmount = IncludeFeeInAmount.Excluded,
providerType = ExchangeProviderType.DEX,
)
val feeStatus = buildFeeStatus(fromCurrency)
// When
val notifications = sut.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeStatus,
selectedFeeType = FeeType.NORMAL,
providerName = "TestProvider",
hideFee = false,
)
// Then
assertThat(notifications.any { it is SwapNotificationUM.Info.PermissionNeeded }).isTrue()
}
@Test
fun `PermissionNeeded is not added when permissionState is Empty`() {
// Given
every { isGaslessFeeSupportedForNetwork(any()) } returns false
val quoteModel = buildQuoteModel(
feeState = SwapFeeState.Enough,
isBalanceEnough = true,
permissionState = PermissionDataState.Empty,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
providerType = ExchangeProviderType.DEX,
)
val feeStatus = buildFeeStatus(fromCurrency)
// When
val notifications = sut.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeStatus,
selectedFeeType = FeeType.NORMAL,
providerName = "TestProvider",
hideFee = false,
)
// Then
assertThat(notifications.any { it is SwapNotificationUM.Info.PermissionNeeded }).isFalse()
}
// ---------- TransactionInProgressWarning / ApprovalInProgressWarning ----------
@Test
fun `ApprovalInProgressWarning is added when permissionState is PermissionLoading`() {
// Given — PermissionLoading short-circuits to ApprovalInProgressWarning (an Error subtype)
every { isGaslessFeeSupportedForNetwork(any()) } returns false
val quoteModel = buildQuoteModel(
feeState = SwapFeeState.Enough,
isBalanceEnough = true,
permissionState = PermissionDataState.PermissionLoading,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
providerType = ExchangeProviderType.DEX,
hasOutgoingTransaction = false,
)
val feeStatus = buildFeeStatus(fromCurrency)
// When
val notifications = sut.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeStatus,
selectedFeeType = FeeType.NORMAL,
providerName = "TestProvider",
hideFee = false,
)
// Then
assertThat(notifications.any { it is SwapNotificationUM.Error.ApprovalInProgressWarning }).isTrue()
}
@Test
fun `TransactionInProgressWarning is added when hasOutgoingTransaction true and permission not loading`() {
// Given
every { isGaslessFeeSupportedForNetwork(any()) } returns false
val quoteModel = buildQuoteModel(
feeState = SwapFeeState.Enough,
isBalanceEnough = true,
permissionState = PermissionDataState.Empty,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
providerType = ExchangeProviderType.DEX,
hasOutgoingTransaction = true,
)
val feeStatus = buildFeeStatus(fromCurrency)
// When
val notifications = sut.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeStatus,
selectedFeeType = FeeType.NORMAL,
providerName = "TestProvider",
hideFee = false,
)
// Then
assertThat(notifications.any { it is SwapNotificationUM.Error.TransactionInProgressWarning }).isTrue()
}
// region — local helpers
@Suppress("LongParameterList")
private fun buildQuoteModel(
feeState: SwapFeeState = SwapFeeState.Enough,
isBalanceEnough: Boolean = true,
permissionState: PermissionDataState = PermissionDataState.Empty,
includeFeeInAmount: IncludeFeeInAmount = IncludeFeeInAmount.Excluded,
providerType: ExchangeProviderType = ExchangeProviderType.DEX,
txFeeState: TxFeeState = TxFeeState.Empty,
hasOutgoingTransaction: Boolean = false,
): SwapState.QuotesLoadedState {
val fromStatus = buildSwapCurrencyStatusForFromCurrency()
val toStatus = buildSwapCurrencyStatusForFromCurrency()
val fromInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("1.0"), 18),
amountFiat = BigDecimal("100.00"),
swapCurrencyStatus = fromStatus,
)
val toInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("0.05"), 18),
amountFiat = BigDecimal("100.00"),
swapCurrencyStatus = toStatus,
)
return SwapState.QuotesLoadedState(
fromTokenInfo = fromInfo,
toTokenInfo = toInfo,
priceImpact = PriceImpact.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
isBalanceEnough = isBalanceEnough,
feeState = feeState,
hasOutgoingTransaction = hasOutgoingTransaction,
includeFeeInAmount = includeFeeInAmount,
),
permissionState = permissionState,
txFee = txFeeState,
currencyCheck = null,
validationResult = null,
minAdaValue = null,
swapProvider = SwapProvider(
providerId = "p",
rateTypes = listOf(RateType.FLOAT),
name = "TestProvider",
type = providerType,
imageLarge = "",
termsOfUse = null,
privacyPolicy = null,
isRecommended = false,
slippage = null,
isExtraIdSupported = false,
),
)
}
private fun buildSwapCurrencyStatusForFromCurrency(): SwapCurrencyStatus {
val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) {
every { amount } returns BigDecimal("1.0")
every { fiatRate } returns BigDecimal("2000.00")
every { fiatAmount } returns BigDecimal("2000.00")
every { networkAddress } returns mockk<NetworkAddress>(relaxed = true)
every { pendingTransactions } returns emptySet()
}
val cryptoCurrencyStatus = CryptoCurrencyStatus(currency = fromCurrency, value = statusValue)
val userWallet: UserWallet = mockk(relaxed = true) {
every { walletId } returns UserWalletId("aabbccdd")
}
val account = com.tangem.domain.models.account.Account.CryptoPortfolio.createMainAccount(userWallet.walletId)
return SwapCurrencyStatus(
userWallet = userWallet,
status = cryptoCurrencyStatus,
account = account,
)
}
private fun buildFeeStatus(currency: CryptoCurrency): CryptoCurrencyStatus {
val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) {
every { amount } returns BigDecimal("0.5")
}
return CryptoCurrencyStatus(currency = currency, value = statusValue)
}
private fun buildLegacyFee(feeType: FeeType): TxFee.Legacy {
val fee: Fee = mockk(relaxed = true)
return TxFee.Legacy(
feeValue = BigDecimal("0.001"),
feeFiatFormatted = "$2.00",
feeCryptoFormatted = "0.001 ETH",
feeIncludeOtherNativeFee = BigDecimal.ZERO,
feeFiatFormattedWithNative = "$2.00",
feeCryptoFormattedWithNative = "0.001 ETH",
cryptoSymbol = "ETH",
feeType = feeType,
fee = fee,
)
}
// endregion
}