diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapTransactionErrorStateConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapTransactionErrorStateConverter.kt index 653bb2b5e3..e3eb7465a2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapTransactionErrorStateConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapTransactionErrorStateConverter.kt @@ -4,6 +4,7 @@ import com.tangem.common.ui.alerts.TransactionErrorDialogFactory import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.feature.swap.domain.models.ui.SwapTransactionState +import com.tangem.feature.swap.model.toExpressError import com.tangem.feature.swap.models.SwapAlertUM import com.tangem.feature.swap.utils.getExpressErrorMessage import com.tangem.utils.converter.Converter @@ -17,14 +18,14 @@ internal class SwapTransactionErrorStateConverter( return when (value) { is SwapTransactionState.Error.TransactionError -> { when (val error = value.error) { - is SendTransactionError.UserCancelledError -> return null + is SendTransactionError.UserCancelledError -> null null -> SwapAlertUM.genericError(onDismiss) else -> transactionErrorDialogFactory.create(error, onDismiss, onSupportClick) } } is SwapTransactionState.Error.ExpressError -> { SwapAlertUM.expressErrorAlert( - message = getExpressErrorMessage(value.error), + message = getExpressErrorMessage(value.error.toExpressError()), onConfirmClick = { onSupportClick(value.error.code.toString()) }, ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index a1ea402b4b..7778741e7a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -222,6 +222,7 @@ internal class SwapModel @Inject constructor( private val fromTokenBalanceJobHolder = JobHolder() private val toTokenBalanceJobHolder = JobHolder() + private val swapPairsJobHolder = JobHolder() private var isAmountChangedByUser: Boolean = false private var lastPermissionNotificationTokens: Pair? = null @@ -527,6 +528,11 @@ internal class SwapModel @Inject constructor( private fun initSwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) { modelScope.launch { + uiState = stateBuilder.createInitialLoadingState( + uiStateHolder = uiState, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) swapInteractor.getPair( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, @@ -537,9 +543,11 @@ internal class SwapModel @Inject constructor( }, ).fold( ifLeft = { error -> - handleSwapNotSupported( + uiState = stateBuilder.createInitialErrorState( + uiStateHolder = uiState, fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, + expressError = error, + onRetry = { retrySwapPairs(fromSwapCurrencyStatus, toSwapCurrencyStatus) }, ) TangemLogger.e("Error getting swap pair", error) }, @@ -555,6 +563,22 @@ internal class SwapModel @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, ) } else { + uiState = stateBuilder.updateCurrenciesState( + uiStateHolder = uiState, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, + ) + }, + ), + ), + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + shouldResetAmount = false, + ) dataState = dataState.copy( pairs = pairs, selectedPairProviders = providerList, @@ -569,7 +593,12 @@ internal class SwapModel @Inject constructor( } }, ) - } + }.saveIn(swapPairsJobHolder) + } + + private fun retrySwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) { + if (swapPairsJobHolder.isActive) return + initSwapPairs(fromSwapCurrencyStatus, toSwapCurrencyStatus) } @Suppress("UnusedPrivateMember") diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 5406397bbd..2bff6bb705 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.express.models.ExpressError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork @@ -49,9 +50,15 @@ internal class SwapNotificationsFactory( ) } - fun getNotAvailableStateNotifications(fromCurrencyName: String): ImmutableList { + fun getErrorStateNotification( + expressError: ExpressError, + onRetryClick: () -> Unit, + ): ImmutableList { return persistentListOf( - SwapNotificationUM.Warning.NoAvailableTokensToSwap(fromCurrencyName), + SwapNotificationUM.Warning.ExpressErrorWarning( + expressError = expressError, + onConfirmClick = onRetryClick, + ), ) } @@ -363,14 +370,55 @@ internal class SwapNotificationsFactory( crypto(fromToken.symbol, fromToken.decimals) }, ) - else -> SwapNotificationUM.Warning.ExpressError( - expressDataError, - onConfirmClick = onRetryClick, - ) + else -> { + val expressError = expressDataError.toExpressError() + SwapNotificationUM.Warning.ExpressErrorWarning( + expressError = expressError, + onConfirmClick = onRetryClick, + ) + } } } private fun needShowNetworkFeeCoverageWarningShow(quoteModel: SwapState.QuotesLoadedState): Boolean { return quoteModel.currencyCheck?.existentialDeposit == null } +} + +@Deprecated("Remove with ExpressDataError") +@Suppress("CyclomaticComplexMethod") +internal fun ExpressDataError.toExpressError(): ExpressError = when (this) { + is ExpressDataError.BadRequest -> ExpressError.BadRequest(code) + is ExpressDataError.SwapsAreUnavailableNowError -> ExpressError.Forbidden(code) + is ExpressDataError.ExchangeProviderNotFoundError -> ExpressError.ProviderNotFoundError(code) + is ExpressDataError.ExchangeProviderNotActiveError -> ExpressError.ProviderNotActiveError(code) + is ExpressDataError.ExchangeProviderNotAvailableError -> ExpressError.ProviderNotAvailableError(code) + is ExpressDataError.ExchangeProviderProviderInternalError -> ExpressError.ProviderInternalError(code) + is ExpressDataError.ExchangeNotPossibleError -> ExpressError.ExchangeNotPossibleError(code) + is ExpressDataError.ExchangeNotEnoughBalanceError -> ExpressError.NotEnoughBalanceError(code) + is ExpressDataError.ExchangeInvalidAddressError -> ExpressError.InvalidAddressError(code) + is ExpressDataError.ExchangeTooSmallAmountError -> ExpressError.AmountError.TooSmallError(code, amount.value) + is ExpressDataError.ExchangeTooBigAmountError -> ExpressError.AmountError.TooBigError(code, amount.value) + is ExpressDataError.ExchangeNotEnoughAllowanceError -> ExpressError.AmountError.NotEnoughAllowanceError( + code = code, + amount = currentAllowance, + ) + is ExpressDataError.ExchangeInvalidFromDecimalsError -> ExpressError.InvalidFromDecimalsError( + code = code, + receivedFromDecimals = receivedFromDecimals, + expressFromDecimals = expressFromDecimals, + ) + is ExpressDataError.ProviderDifferentAmountError -> ExpressError.ProviderDifferentAmountError( + code = code, + fromAmount = fromAmount, + fromProviderAmount = fromProviderAmount, + decimals = decimals, + ) + is ExpressDataError.InvalidSignatureError -> ExpressError.InvalidSignatureError(code) + is ExpressDataError.InvalidRequestIdError -> ExpressError.InvalidRequestIdError(code) + is ExpressDataError.InvalidPayoutAddressError -> ExpressError.InvalidPayoutAddressError(code) + is ExpressDataError.UnknownErrorWithCode -> ExpressError.InternalError(code) + ExpressDataError.UnknownError -> ExpressError.UnknownError + ExpressDataError.TooLargeSolanaTransactionError -> ExpressError.TooLargeSolanaTransactionError() + ExpressDataError.DexActiveSupplyError -> ExpressError.DexActiveSupplyError() } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index f45012a0b1..95f8c7c400 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -86,6 +86,7 @@ sealed interface TransactionCardType { val onFocusChanged: ((Boolean) -> Unit), override val inputError: InputError, override val accountTitleUM: AccountTitleUM, + val isEnabled: Boolean, ) : TransactionCardType data class ReadOnly( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index ff6f8b47ce..1cb665edc9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -1,14 +1,14 @@ package com.tangem.feature.swap.models.states import com.tangem.common.ui.R +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference -import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.express.models.ExpressError import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.utils.getExpressErrorMessage import com.tangem.feature.swap.utils.getExpressErrorTitle @@ -171,12 +171,12 @@ internal object SwapNotificationUM { ), ) - data class ExpressError( - val expressDataError: ExpressDataError, + data class ExpressErrorWarning( + val expressError: ExpressError, val onConfirmClick: () -> Unit, ) : Warning( - title = getExpressErrorTitle(expressDataError), - subtitle = getExpressErrorMessage(expressDataError), + title = getExpressErrorTitle(expressError), + subtitle = getExpressErrorMessage(expressError), buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( text = resourceReference(R.string.warning_button_refresh), onClick = onConfirmClick, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt index 2a6b4ed8ea..8e2e551bd2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -31,12 +32,19 @@ import com.tangem.core.ui.res.TangemTheme internal fun AutoSizeTextField( textFieldValue: TextFieldValue, focusRequester: FocusRequester, + isEnabled: Boolean, onAmountChange: (String) -> Unit, onFocusChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current + LaunchedEffect(isEnabled) { + if (!isEnabled) { + focusManager.clearFocus() + } + } + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { var shrunkFontSize = TangemTheme.typography.h2.fontSize val calculateIntrinsics = @Composable { @@ -77,6 +85,7 @@ internal fun AutoSizeTextField( imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal, ), + enabled = isEnabled, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), decorationBox = { innerTextField -> if (textFieldValue.text.isBlank()) { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index db56f3b0a1..deb2acdde2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -14,7 +14,9 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.* import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.parseBigDecimalOrNull import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.express.models.ExpressError import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -38,6 +40,7 @@ import com.tangem.utils.Provider import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.StringsSigns.TILDE_SIGN +import com.tangem.utils.extensions.orZero import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -106,11 +109,13 @@ internal class StateBuilder( swapCurrencyStatus = fromSwapCurrencyStatus, emptyAmountState = emptyAmountState, isFromCard = true, + isEnabled = toSwapCurrencyStatus != null, ), receiveCardData = createCardState( swapCurrencyStatus = toSwapCurrencyStatus, emptyAmountState = emptyAmountState, isFromCard = false, + isEnabled = true, ), notifications = persistentListOf(), isInsufficientFunds = false, @@ -128,6 +133,78 @@ internal class StateBuilder( ) } + fun createInitialErrorState( + fromSwapCurrencyStatus: SwapCurrencyStatus?, + uiStateHolder: SwapStateHolder, + expressError: ExpressError, + onRetry: () -> Unit, + ): SwapStateHolder { + return uiStateHolder.copy( + sendCardData = (uiStateHolder.sendCardData as? SwapCardState.SwapCardData)?.copy( + type = (uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable)?.copy( + isEnabled = false, + ) ?: uiStateHolder.sendCardData.type, + ) ?: uiStateHolder.sendCardData, + notifications = notificationsFactory.getErrorStateNotification( + expressError = expressError, + onRetryClick = onRetry, + ), + permissionUM = SwapPermissionUM.Empty, + fee = FeeItemState.Empty, + swapButton = fromSwapCurrencyStatus?.let { + SwapButton( + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), + isEnabled = false, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, + onClick = actions.onSwapClick, + ) + } ?: uiStateHolder.swapButton, + changeCardsButtonState = ChangeCardsButtonState.ENABLED, + providerState = ProviderState.Empty(), + priceImpact = PriceImpact.Empty, + tosState = null, + ) + } + + fun createInitialLoadingState( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + uiStateHolder: SwapStateHolder, + ): SwapStateHolder { + val fromCurrency = fromSwapCurrencyStatus.currency + val toCurrency = toSwapCurrencyStatus.currency + if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder + if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder + return uiStateHolder.copy( + sendCardData = uiStateHolder.sendCardData.copy( + type = TransactionCardType.Inputtable( + onAmountChanged = actions.onAmountChanged, + onFocusChanged = actions.onAmountSelected, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), + isEnabled = true, + ), + ), + receiveCardData = uiStateHolder.receiveCardData.copy( + type = TransactionCardType.ReadOnly( + accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), + ), + ), + notifications = persistentListOf(), + fee = FeeItemState.Empty, + swapButton = SwapButton( + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), + isEnabled = false, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, + onClick = {}, + ), + providerState = ProviderState.Empty(), + changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, + priceImpact = PriceImpact.Empty, + shouldShowMaxAmount = shouldShowMaxAmount(fromCurrency, toCurrency), + ) + } + fun updateCurrenciesState( uiStateHolder: SwapStateHolder, emptyAmountState: SwapState.EmptyAmountState, @@ -141,12 +218,14 @@ internal class StateBuilder( emptyAmountState = emptyAmountState, isFromCard = true, shouldResetAmount = shouldResetAmount, + isEnabled = toSwapCurrencyStatus != null, ), receiveCardData = uiStateHolder.receiveCardData.updateCurrencyStatus( swapCurrencyStatus = toSwapCurrencyStatus, emptyAmountState = emptyAmountState, isFromCard = false, shouldResetAmount = shouldResetAmount, + isEnabled = true, ), notifications = persistentListOf(), isInsufficientFunds = false, @@ -169,6 +248,7 @@ internal class StateBuilder( emptyAmountState: SwapState.EmptyAmountState, shouldResetAmount: Boolean, isFromCard: Boolean, + isEnabled: Boolean, ): SwapCardState { val cardType = if (isFromCard) { TransactionCardType.Inputtable( @@ -176,6 +256,7 @@ internal class StateBuilder( onFocusChanged = actions.onAmountSelected, inputError = TransactionCardType.InputError.Empty, accountTitleUM = getCardAccountTitle(swapCurrencyStatus?.account, true), + isEnabled = isEnabled, ) } else { TransactionCardType.ReadOnly( @@ -188,6 +269,7 @@ internal class StateBuilder( swapCurrencyStatus = swapCurrencyStatus, emptyAmountState = emptyAmountState, isFromCard = isFromCard, + isEnabled = isEnabled, ) } else if (shouldResetAmount) { copy( @@ -218,6 +300,7 @@ internal class StateBuilder( swapCurrencyStatus: SwapCurrencyStatus?, emptyAmountState: SwapState.EmptyAmountState, isFromCard: Boolean, + isEnabled: Boolean, ): SwapCardState { return if (swapCurrencyStatus == null) { getEmptyCardState(isFromCard = isFromCard, emptyAmountState = emptyAmountState) @@ -229,6 +312,7 @@ internal class StateBuilder( onFocusChanged = actions.onAmountSelected, inputError = TransactionCardType.InputError.Empty, accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, true), + isEnabled = isEnabled, ) } else { TransactionCardType.ReadOnly( @@ -328,6 +412,7 @@ internal class StateBuilder( onFocusChanged = actions.onAmountSelected, inputError = TransactionCardType.InputError.Empty, accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), + isEnabled = true, ), ), receiveCardData = uiStateHolder.receiveCardData.copy( @@ -509,7 +594,7 @@ internal class StateBuilder( private fun getSwapButtonEnabled(notifications: ImmutableList, priceImpact: PriceImpact): Boolean { return notifications.none { notification -> notification is SwapNotificationUM.Error || notification is NotificationUM.Error || - notification is SwapNotificationUM.Warning.ExpressError || + notification is SwapNotificationUM.Warning.ExpressErrorWarning || notification is SwapNotificationUM.Warning.ExpressGeneralError || notification is SwapNotificationUM.Warning.NoAvailableTokensToSwap || notification is SwapNotificationUM.Warning.SwapNotSupported || @@ -686,7 +771,7 @@ internal class StateBuilder( minTxAmount: BigDecimal?, ): SwapStateHolder { if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState - val amountToSend = amountRaw.toBigDecimalOrNull() + val amountToSend = amountRaw.parseBigDecimalOrNull() val sendInput = if (minTxAmount != null && amountToSend != null && amountToSend < minTxAmount) { val minAmountFormatted = minTxAmount.format { crypto(cryptoCurrency = fromSwapCurrencyStatus.currency, ignoreSymbolPosition = true) @@ -711,7 +796,7 @@ internal class StateBuilder( ), amountEquivalent = getFormattedFiatAmount( fromSwapCurrencyStatus.status.value.fiatRate?.let { fiatRate -> - amountToSend?.multiply(fiatRate) + amountToSend?.multiply(fiatRate).orZero() }, ), type = sendInput, @@ -731,12 +816,14 @@ internal class StateBuilder( emptyAmountState = emptyAmountState, isFromCard = true, shouldResetAmount = false, + isEnabled = toSwapCurrencyStatus != null, ), receiveCardData = uiState.receiveCardData.updateCurrencyStatus( swapCurrencyStatus = toSwapCurrencyStatus, emptyAmountState = emptyAmountState, isFromCard = false, shouldResetAmount = false, + isEnabled = true, ), ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index 0b5497b495..d511cdb1ce 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -296,25 +296,16 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie } else { TangemTheme.colors.text.warning } - AnimatedContent( - targetState = type.accountTitleUM, - label = "", - ) { currentAccountTitle -> - if (currentAccountTitle != null) { - AccountTitle( - accountTitleUM = currentAccountTitle, - textColor = titleColor, - ) - } else { - TextShimmer( - text = stringResourceSafe(R.string.swapping_to_title), - style = TangemTheme.typography.subtitle2, - ) - } - } + AccountTitle( + accountTitleUM = type.accountTitleUM, + textColor = titleColor, + ) SpacerW16() if (balance.isNotBlank()) { - AnimatedContent(targetState = balance, label = "") { balanceText -> + AnimatedContent( + targetState = balance, + label = "", + ) { balanceText -> Text( text = balanceText, color = TangemTheme.colors.text.tertiary, @@ -391,6 +382,7 @@ private fun Content( modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD), focusRequester = focusRequester, textFieldValue = textFieldValue ?: TextFieldValue(), + isEnabled = type.isEnabled, onAmountChange = { type.onAmountChanged(it) }, onFocusChange = type.onFocusChanged, ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt index 04fc89a1a1..c64731f361 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt @@ -24,12 +24,13 @@ internal object SwapTransactionCardPreview { name = AccountNameUM.DefaultMain.value, icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), ), + isEnabled = true, ), amountTextFieldValue = TextFieldValue(), amountEquivalent = stringReference("1 000 000"), currencyIconState = CurrencyIconState.Loading, tokenSymbol = stringReference("DAI"), - balance = "123", + balance = "123123123.123123", isBalanceHidden = false, ) @@ -63,6 +64,7 @@ internal object SwapTransactionCardPreview { onFocusChanged = {}, inputError = TransactionCardType.InputError.Empty, accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_from_title)), + isEnabled = false, ), amountEquivalent = stringReference("$0.00"), amountTextFieldValue = null, @@ -74,6 +76,7 @@ internal object SwapTransactionCardPreview { onFocusChanged = {}, inputError = TransactionCardType.InputError.Empty, accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)), + isEnabled = false, ), ) } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/utils/SwapUtils.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/utils/SwapUtils.kt index 3093dcb07b..93787c70f4 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/utils/SwapUtils.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/utils/SwapUtils.kt @@ -5,48 +5,50 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.simple +import com.tangem.domain.express.models.ExpressError import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.presentation.R -internal fun getExpressErrorMessage(expressDataError: ExpressDataError): TextReference { - return when (expressDataError) { - is ExpressDataError.SwapsAreUnavailableNowError -> resourceReference( +internal fun getExpressErrorMessage(expressError: ExpressError): TextReference { + return when (expressError) { + is ExpressError.InternalError, + is ExpressError.Forbidden, + -> resourceReference( id = R.string.express_error_swap_unavailable, - formatArgs = wrappedList(expressDataError.code), + formatArgs = wrappedList(expressError.code), ) - is ExpressDataError.ExchangeNotPossibleError -> resourceReference( + is ExpressError.ExchangeNotPossibleError -> resourceReference( id = R.string.warning_express_pair_unavailable_message, - formatArgs = wrappedList(expressDataError.code), + formatArgs = wrappedList(expressError.code), ) - is ExpressDataError.UnknownError -> resourceReference(R.string.common_unknown_error) - is ExpressDataError.ExchangeProviderNotActiveError, - is ExpressDataError.ExchangeProviderNotFoundError, - is ExpressDataError.ExchangeProviderNotAvailableError, - is ExpressDataError.ExchangeProviderProviderInternalError, + is ExpressError.UnknownError -> resourceReference(R.string.common_unknown_error) + is ExpressError.ProviderNotActiveError, + is ExpressError.ProviderNotFoundError, + is ExpressError.ProviderNotAvailableError, + is ExpressError.ProviderInternalError, -> resourceReference( id = R.string.express_error_swap_pair_unavailable, - formatArgs = wrappedList(expressDataError.code), + formatArgs = wrappedList(expressError.code), ) - is ExpressDataError.ProviderDifferentAmountError -> resourceReference( + is ExpressError.ProviderDifferentAmountError -> resourceReference( R.string.express_error_provider_amount_roundup, formatArgs = wrappedList( - expressDataError.code, - expressDataError.fromProviderAmount.format { simple(decimals = expressDataError.decimals) }, + expressError.code, + expressError.fromProviderAmount.format { simple(decimals = expressError.decimals) }, ), ) - else -> resourceReference(R.string.express_error_code, wrappedList(expressDataError.code.toString())) + else -> resourceReference(R.string.express_error_code, wrappedList(expressError.code.toString())) } } -internal fun getExpressErrorTitle(expressDataError: ExpressDataError): TextReference { - return when (expressDataError) { - is ExpressDataError.ExchangeNotPossibleError -> resourceReference( +internal fun getExpressErrorTitle(expressError: ExpressError): TextReference { + return when (expressError) { + is ExpressError.ExchangeNotPossibleError -> resourceReference( id = R.string.warning_express_pair_unavailable_title, - formatArgs = wrappedList(expressDataError.code), + formatArgs = wrappedList(expressError.code), ) - is ExpressDataError.UnknownError -> resourceReference(R.string.common_error) + is ExpressError.UnknownError -> resourceReference(R.string.common_error) else -> resourceReference(R.string.warning_express_refresh_required_title) } } diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt index 82ce65c84e..3f217d42d1 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt @@ -231,6 +231,115 @@ internal class DefaultInitialCurrenciesResolverTest { assertThat(to).isNull() } + @Test + fun `GIVEN Token on ETH network with higher fiat vs Coin on BTC network with lower fiat WHEN no initial currency THEN Token is selected as FROM`() = + runTest { + val ethTokenId = mockCurrencyId(rawNetworkId = "ethereum", contractAddress = "0xUSDT") + val btcCoinId = mockCurrencyId(rawNetworkId = "bitcoin", contractAddress = "") + + val ethToken = mockk(relaxed = true) { + every { id } returns ethTokenId + } + val btcCoin = mockk(relaxed = true) { + every { id } returns btcCoinId + } + + val tokenStatus = createCurrencyStatus(ethToken, fiatAmount = BigDecimal("500")) + val coinStatus = createCurrencyStatus(btcCoin, fiatAmount = BigDecimal("100")) + + // currencies list order must match the order passed to setupAvailability + val accountStatus = createCryptoPortfolioAccountStatus(listOf(coinStatus, tokenStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(btcCoin to true, ethToken to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(tokenStatus) + assertThat(to).isNull() + } + + @Test + fun `GIVEN Token on ETH with highest fiat vs Coin on BTC with mid fiat vs Coin on SOL with lowest fiat WHEN no initial currency THEN Token is selected as FROM`() = + runTest { + val usdtId = mockCurrencyId(rawNetworkId = "ethereum", contractAddress = "0xUSDT") + val btcId = mockCurrencyId(rawNetworkId = "bitcoin", contractAddress = "") + val solId = mockCurrencyId(rawNetworkId = "solana", contractAddress = "") + + val usdtToken = mockk(relaxed = true) { + every { id } returns usdtId + } + val btcCoin = mockk(relaxed = true) { + every { id } returns btcId + } + val solCoin = mockk(relaxed = true) { + every { id } returns solId + } + + val usdtStatus = createCurrencyStatus(usdtToken, fiatAmount = BigDecimal("1000")) + val btcStatus = createCurrencyStatus(btcCoin, fiatAmount = BigDecimal("500")) + val solStatus = createCurrencyStatus(solCoin, fiatAmount = BigDecimal("100")) + + // list order: btcStatus, solStatus, usdtStatus → setupAvailability must match + val accountStatus = createCryptoPortfolioAccountStatus(listOf(btcStatus, solStatus, usdtStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(btcCoin to true, solCoin to true, usdtToken to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(usdtStatus) + assertThat(to).isNull() + } + + @Test + fun `GIVEN Coin on BTC with highest fiat vs Token on ETH with mid fiat vs Token on SOL with lowest fiat WHEN no initial currency THEN Coin is selected as FROM`() = + runTest { + val btcId = mockCurrencyId(rawNetworkId = "bitcoin", contractAddress = "") + val usdtId = mockCurrencyId(rawNetworkId = "ethereum", contractAddress = "0xUSDT") + val usdcId = mockCurrencyId(rawNetworkId = "solana", contractAddress = "EPjFWdd5") + + val btcCoin = mockk(relaxed = true) { + every { id } returns btcId + } + val usdtToken = mockk(relaxed = true) { + every { id } returns usdtId + } + val usdcToken = mockk(relaxed = true) { + every { id } returns usdcId + } + + val btcStatus = createCurrencyStatus(btcCoin, fiatAmount = BigDecimal("2000")) + val usdtStatus = createCurrencyStatus(usdtToken, fiatAmount = BigDecimal("600")) + val usdcStatus = createCurrencyStatus(usdcToken, fiatAmount = BigDecimal("150")) + + // list order: usdtStatus, usdcStatus, btcStatus → setupAvailability must match + val accountStatus = createCryptoPortfolioAccountStatus(listOf(usdtStatus, usdcStatus, btcStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(usdtToken to true, usdcToken to true, btcCoin to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(btcStatus) + assertThat(to).isNull() + } + // endregion // region initial currency tests @@ -747,6 +856,294 @@ internal class DefaultInitialCurrenciesResolverTest { // endregion + // region multi-account balance selection (no initial currency) + + @Test + fun `GIVEN two accounts where secondary has higher balance WHEN no initial currency THEN picks token from secondary account`() = + runTest { + // Main account: currency with low balance. + val mainCurrency = mockCryptoCurrency() + val mainStatus = createCurrencyStatus(mainCurrency, fiatAmount = BigDecimal("10")) + val mainAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(mainStatus), + derivationIndexValue = 0, + ) + + // Secondary account: currency with higher balance. + val secondaryCurrency = mockCryptoCurrency() + val secondaryStatus = createCurrencyStatus(secondaryCurrency, fiatAmount = BigDecimal("500")) + val secondaryAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(secondaryStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(mainAccount, secondaryAccount)) + setupAvailability(linkedMapOf(mainCurrency to true)) + setupAvailability(linkedMapOf(secondaryCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // Global max-balance candidate lives in the secondary account. + assertThat(from?.status).isSameInstanceAs(secondaryStatus) + assertThat(to).isNull() + } + + @Test + fun `GIVEN two accounts each with several currencies where highest fiat token is in account 2 WHEN no initial currency THEN that token wins`() = + runTest { + val c1 = mockCryptoCurrency() + val c2 = mockCryptoCurrency() + val c3 = mockCryptoCurrency() + val c4 = mockCryptoCurrency() + + val s1 = createCurrencyStatus(c1, fiatAmount = BigDecimal("100")) + val s2 = createCurrencyStatus(c2, fiatAmount = BigDecimal("200")) + // c3 is a Token in account 2 with the highest fiat. + val s3 = createCurrencyStatus(c3, fiatAmount = BigDecimal("999")) + val s4 = createCurrencyStatus(c4, fiatAmount = BigDecimal("50")) + + val account1 = createCryptoPortfolioAccountStatus( + currencies = listOf(s1, s2), + derivationIndexValue = 0, + ) + val account2 = createCryptoPortfolioAccountStatus( + currencies = listOf(s4, s3), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(account1, account2)) + setupAvailability(linkedMapOf(c1 to true, c2 to true)) + setupAvailability(linkedMapOf(c4 to true, c3 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(s3) + assertThat(to).isNull() + } + + @Test + fun `GIVEN two accounts all balances are zero or null WHEN no initial currency THEN falls back to first currency of first account`() = + runTest { + val c1 = mockCryptoCurrency() + val c2 = mockCryptoCurrency() + + val s1 = createCurrencyStatus(c1, fiatAmount = BigDecimal.ZERO) + val s2 = createCurrencyStatus(c2, fiatAmount = null) + + val account1 = createCryptoPortfolioAccountStatus( + currencies = listOf(s1), + derivationIndexValue = 0, + ) + val account2 = createCryptoPortfolioAccountStatus( + currencies = listOf(s2), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(account1, account2)) + setupAvailability(linkedMapOf(c1 to true)) + setupAvailability(linkedMapOf(c2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // cryptoPortfolioAccountsMap.entries.firstOrNull()?.value?.firstOrNull() = s1. + assertThat(from?.status).isSameInstanceAs(s1) + assertThat(to).isNull() + } + + @Test + fun `GIVEN first account is empty and second account has currencies with balance WHEN no initial currency THEN picks highest balance from second account`() = + runTest { + val c1 = mockCryptoCurrency() + val c2 = mockCryptoCurrency() + + val s1 = createCurrencyStatus(c1, fiatAmount = BigDecimal("150")) + val s2 = createCurrencyStatus(c2, fiatAmount = BigDecimal("300")) + + // account1 has no currencies at all. + val account1 = createCryptoPortfolioAccountStatus( + currencies = emptyList(), + derivationIndexValue = 0, + ) + val account2 = createCryptoPortfolioAccountStatus( + currencies = listOf(s1, s2), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(account1, account2)) + // account1 is empty so rampStateManager is called with an empty list for it. + coEvery { rampStateManager.availableForSwap(userWalletId, emptyList()) } returns emptyMap() + setupAvailability(linkedMapOf(c1 to true, c2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(s2) + assertThat(to).isNull() + } + + // endregion + + // region initial currency + CurrencyPosition.ANY going to TO, scoped to same account + + @Test + fun `GIVEN selected zero-balance currency in account 1 goes to TO WHEN account 2 has higher-balance currency THEN FROM is picked only from account 1`() = + runTest { + // Account 1: the initial currency (available, zero balance → TO) + a companion. + val initialId = mockCurrencyId("ethereum", "0xDAI") + val initialCurrency = mockCryptoCurrency(id = initialId) + val initialInAccount1 = mockCryptoCurrency(id = initialId) + val companion1 = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(initialInAccount1, fiatAmount = BigDecimal.ZERO) + val companionStatus = createCurrencyStatus(companion1, fiatAmount = BigDecimal("75")) + + val account1 = createCryptoPortfolioAccountStatus( + currencies = listOf(initialStatus, companionStatus), + derivationIndexValue = 0, + ) + + // Account 2: has a currency with much higher balance that must NOT be chosen as FROM. + val highBalanceCurrency = mockCryptoCurrency() + val highBalanceStatus = createCurrencyStatus(highBalanceCurrency, fiatAmount = BigDecimal("9999")) + val account2 = createCryptoPortfolioAccountStatus( + currencies = listOf(highBalanceStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(account1, account2)) + setupAvailability(linkedMapOf(initialInAccount1 to true, companion1 to true)) + setupAvailability(linkedMapOf(highBalanceCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // FROM must be from account1, never the high-balance token from account2. + assertThat(from?.status).isSameInstanceAs(companionStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN selected zero-balance currency goes to TO WHEN same account has multiple currencies THEN FROM is the highest-balance one within same account`() = + runTest { + val initialId = mockCurrencyId("solana", "") + val initialCurrency = mockCryptoCurrency(id = initialId) + val initialInAccount = mockCryptoCurrency(id = initialId) + + val lowBalance = mockCryptoCurrency() + val midBalance = mockCryptoCurrency() + val highBalance = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(initialInAccount, fiatAmount = BigDecimal.ZERO) + val lowStatus = createCurrencyStatus(lowBalance, fiatAmount = BigDecimal("10")) + val midStatus = createCurrencyStatus(midBalance, fiatAmount = BigDecimal("100")) + val highStatus = createCurrencyStatus(highBalance, fiatAmount = BigDecimal("500")) + + val account1 = createCryptoPortfolioAccountStatus( + currencies = listOf(initialStatus, lowStatus, midStatus, highStatus), + derivationIndexValue = 0, + ) + + // Account 2 has an even higher balance that must NOT be picked. + val outsiderCurrency = mockCryptoCurrency() + val outsiderStatus = createCurrencyStatus(outsiderCurrency, fiatAmount = BigDecimal("10000")) + val account2 = createCryptoPortfolioAccountStatus( + currencies = listOf(outsiderStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(account1, account2)) + setupAvailability(linkedMapOf(initialInAccount to true, lowBalance to true, midBalance to true, highBalance to true)) + setupAvailability(linkedMapOf(outsiderCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // FROM must be the highest-balance token within account 1 only. + assertThat(from?.status).isSameInstanceAs(highStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + // endregion + + // region isSameTokenAs dedupe across accounts + + @Test + fun `GIVEN same token in two accounts where selected is in account 1 WHEN going to TO THEN duplicate in account 2 is not considered and FROM is other account 1 currency`() = + runTest { + val sharedNetworkId = "polygon" + val sharedContract = "0xUSDC" + + // Same token in two accounts (different IDs due to different derivations). + val idInAccount1 = mockCurrencyId(sharedNetworkId, sharedContract) + val idInAccount2 = mockCurrencyId(sharedNetworkId, sharedContract) + + val initialCurrency = mockCryptoCurrency(id = idInAccount1) + val usdcInAccount1 = mockCryptoCurrency(id = idInAccount1) + val usdcInAccount2 = mockCryptoCurrency(id = idInAccount2) + + // Account 1 also has a distinct companion currency. + val account1Companion = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(usdcInAccount1, fiatAmount = BigDecimal.ZERO) + val companionStatus = createCurrencyStatus(account1Companion, fiatAmount = BigDecimal("200")) + + val account1 = createCryptoPortfolioAccountStatus( + currencies = listOf(initialStatus, companionStatus), + derivationIndexValue = 0, + ) + + // Account 2 has the duplicate token with a large balance — must NOT be picked. + val duplicateStatus = createCurrencyStatus(usdcInAccount2, fiatAmount = BigDecimal("5000")) + val account2 = createCryptoPortfolioAccountStatus( + currencies = listOf(duplicateStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(account1, account2)) + setupAvailability(linkedMapOf(usdcInAccount1 to true, account1Companion to true)) + setupAvailability(linkedMapOf(usdcInAccount2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // FROM must be the account1 companion (scoped to account1, duplicate in account2 excluded). + assertThat(from?.status).isSameInstanceAs(companionStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + // endregion + // region helpers private fun mockCryptoCurrency( diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt new file mode 100644 index 0000000000..e8724ad601 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt @@ -0,0 +1,592 @@ +package com.tangem.feature.swap + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +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.ui.PriceImpact +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.models.states.ProviderState +import com.tangem.feature.swap.models.states.SwapNotificationUM +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.Nested +import org.junit.jupiter.api.Test + +internal class StateBuilderInitialStateTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isBalanceHiddenProvider: Provider = mockk() + private val appCurrencyProvider: Provider = mockk() + private val isAccountsModeProvider: Provider = mockk() + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + + private lateinit var sut: StateBuilder + + private val appCurrency = AppCurrency.Default + + @BeforeEach + fun setup() { + every { isBalanceHiddenProvider() } returns false + every { appCurrencyProvider() } returns appCurrency + every { isAccountsModeProvider() } returns false + + sut = StateBuilder( + actions = actions, + isBalanceHiddenProvider = isBalanceHiddenProvider, + appCurrencyProvider = appCurrencyProvider, + isAccountsModeProvider = isAccountsModeProvider, + iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + ) + } + + // region createInitialLoadingState (no-arg overload) + + @Nested + inner class `createInitialLoadingState no-arg` { + + @Test + fun `should return loading state with disabled swap button`() { + val result = sut.createInitialLoadingState() + + assertThat(result.swapButton.isEnabled).isFalse() + assertThat(result.swapButton.isInProgress).isTrue() + assertThat(result.swapButton.isHoldToConfirm).isFalse() + } + + @Test + fun `should return loading state with empty send and receive cards`() { + val result = sut.createInitialLoadingState() + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.Empty::class.java) + assertThat(result.receiveCardData).isInstanceOf(SwapCardState.Empty::class.java) + } + + @Test + fun `should return loading state with Empty fee`() { + val result = sut.createInitialLoadingState() + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `should return loading state with DISABLED changeCardsButtonState`() { + val result = sut.createInitialLoadingState() + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.DISABLED) + } + + @Test + fun `should return loading state with Empty providerState`() { + val result = sut.createInitialLoadingState() + + assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java) + } + + @Test + fun `should return loading state with null walletInteractionIcon`() { + val result = sut.createInitialLoadingState() + + assertThat(result.swapButton.walletInteractionIcon).isNull() + } + } + + // endregion + + // region createInitialReadyState + + @Nested + inner class CreateInitialReadyState { + + private val userWalletId = UserWalletId("aabbccdd") + private val userWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + private val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + private val baseState get() = sut.createInitialLoadingState() + + @Test + fun `GIVEN both currencies non-null WHEN called THEN sendCard is SwapCardData`() { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.SwapCardData::class.java) + } + + @Test + fun `GIVEN both currencies non-null WHEN called THEN receiveCard is SwapCardData`() { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.receiveCardData).isInstanceOf(SwapCardState.SwapCardData::class.java) + } + + @Test + fun `GIVEN fromCurrency is null WHEN called THEN sendCard is Empty`() { + val toStatus = buildSwapCurrencyStatus(userWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.Empty::class.java) + } + + @Test + fun `GIVEN fromCurrency is null WHEN called THEN swapButton has no walletInteractionIcon`() { + val toStatus = buildSwapCurrencyStatus(userWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.walletInteractionIcon).isNull() + } + + @Test + fun `GIVEN cold wallet WHEN called THEN swapButton isHoldToConfirm is false`() { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.isHoldToConfirm).isFalse() + } + + @Test + fun `GIVEN hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val hotWallet: UserWallet.Hot = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + val fromStatus = buildSwapCurrencyStatus(hotWallet) + val toStatus = buildSwapCurrencyStatus(hotWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + + @Test + fun `WHEN called THEN changeCardsButtonState is ENABLED`() { + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED) + } + + @Test + fun `WHEN called THEN swapButton is disabled`() { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `WHEN called THEN providerState is Empty`() { + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + ) + + assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java) + } + + @Test + fun `WHEN called THEN priceImpact is Empty`() { + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + ) + + assertThat(result.priceImpact).isEqualTo(PriceImpact.Empty) + } + + @Test + fun `WHEN called THEN notifications is empty`() { + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + ) + + assertThat(result.notifications).isEmpty() + } + } + + // endregion + + // region createInitialErrorState + + @Nested + inner class CreateInitialErrorState { + + private val userWalletId = UserWalletId("aabbccdd") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + private val hotWallet: UserWallet.Hot = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + private val expressError: ExpressError = ExpressError.UnknownError + + private fun buildBaseStateWithSwapCardData(userWallet: UserWallet): SwapStateHolder { + val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + val loading = sut.createInitialLoadingState() + return sut.createInitialReadyState( + uiStateHolder = loading, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + } + + @Test + fun `GIVEN fromSwapCurrencyStatus is null WHEN called THEN swapButton comes from uiStateHolder`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + val originalButton = baseState.swapButton + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = null, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.swapButton).isEqualTo(originalButton) + } + + @Test + fun `GIVEN fromSwapCurrencyStatus non-null with cold wallet WHEN called THEN swapButton isHoldToConfirm is false`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = fromStatus, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.swapButton.isHoldToConfirm).isFalse() + } + + @Test + fun `GIVEN fromSwapCurrencyStatus non-null with hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val baseState = buildBaseStateWithSwapCardData(hotWallet) + val fromStatus = buildSwapCurrencyStatus(hotWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = fromStatus, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + + @Test + fun `WHEN called THEN swapButton is disabled`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = fromStatus, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `WHEN called THEN notifications contains ExpressErrorWarning`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = fromStatus, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Warning.ExpressErrorWarning::class.java) + } + + @Test + fun `WHEN called THEN permissionUM is Empty`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = null, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.permissionUM).isEqualTo(SwapPermissionUM.Empty) + } + + @Test + fun `WHEN called THEN fee is Empty`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = null, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `WHEN called THEN changeCardsButtonState is ENABLED`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = null, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED) + } + + @Test + fun `WHEN called THEN tosState is null`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = null, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.tosState).isNull() + } + + @Test + fun `GIVEN sendCardData is SwapCardData with Inputtable type WHEN called THEN sendCard type becomes disabled`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = null, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + val inputtable = sendCard?.type as? TransactionCardType.Inputtable + assertThat(inputtable?.isEnabled).isFalse() + } + } + + // endregion + + // region createInitialLoadingState (two-arg overload) + + @Nested + inner class `createInitialLoadingState two-arg overload` { + + private val userWalletId = UserWalletId("aabbccdd") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + @Test + fun `GIVEN uiState has non-SwapCardData send card WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + // loadingState has SwapCardState.Empty cards — not SwapCardData + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createInitialLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = loadingState, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN uiState has SwapCardData cards WHEN called THEN changeCardsButtonState is UPDATE_IN_PROGRESS`() { + val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val readyState = sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val result = sut.createInitialLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = readyState, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.UPDATE_IN_PROGRESS) + } + + @Test + fun `GIVEN cold wallet WHEN called THEN swapButton isHoldToConfirm is false`() { + val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val readyState = sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val result = sut.createInitialLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = readyState, + ) + + assertThat(result.swapButton.isHoldToConfirm).isFalse() + } + + @Test + fun `GIVEN hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val hotWallet: UserWallet.Hot = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + val fromStatus = buildSwapCurrencyStatus(hotWallet) + val toStatus = buildSwapCurrencyStatus(hotWallet) + // need a base state that has SwapCardData — use readyState built with hotWallet + val readyState = sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val result = sut.createInitialLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = readyState, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + } + + // endregion +} + +// --- Helpers shared across StateBuilder test files --- + +internal fun buildSwapCurrencyStatus( + userWallet: UserWallet, +): SwapCurrencyStatus { + val userWalletId = userWallet.walletId + val account = Account.CryptoPortfolio.createMainAccount(userWalletId) + val currency: CryptoCurrency = mockk(relaxed = true) { + every { symbol } returns "ETH" + every { decimals } returns 18 + every { name } returns "Ethereum" + every { network } returns mockk(relaxed = true) { + every { id } returns mockk(relaxed = true) + every { name } returns "Ethereum" + every { currencySymbol } returns "ETH" + every { rawId } returns "ethereum" + } + } + val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) { + every { amount } returns java.math.BigDecimal("1.0") + every { fiatRate } returns java.math.BigDecimal("2000.00") + every { fiatAmount } returns java.math.BigDecimal("2000.00") + } + val cryptoCurrencyStatus = CryptoCurrencyStatus(currency = currency, value = statusValue) + return SwapCurrencyStatus( + userWallet = userWallet, + status = cryptoCurrencyStatus, + account = account, + ) +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt new file mode 100644 index 0000000000..c1f52f0e5d --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt @@ -0,0 +1,408 @@ +package com.tangem.feature.swap + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.models.states.ProviderState +import com.tangem.feature.swap.models.states.SwapNotificationUM +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.Nested +import org.junit.jupiter.api.Test + +internal class StateBuilderPairsTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isBalanceHiddenProvider: Provider = mockk() + private val appCurrencyProvider: Provider = mockk() + private val isAccountsModeProvider: Provider = mockk() + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + + private lateinit var sut: StateBuilder + + private val userWalletId = UserWalletId("aabbccdd") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + private val hotWallet: UserWallet.Hot = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + private val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference("$0.00"), + ) + + @BeforeEach + fun setup() { + every { isBalanceHiddenProvider() } returns false + every { appCurrencyProvider() } returns AppCurrency.Default + every { isAccountsModeProvider() } returns false + + sut = StateBuilder( + actions = actions, + isBalanceHiddenProvider = isBalanceHiddenProvider, + appCurrencyProvider = appCurrencyProvider, + isAccountsModeProvider = isAccountsModeProvider, + iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + ) + } + + // region createSwapNotSupportedState + + @Nested + inner class CreateSwapNotSupportedState { + + @Test + fun `GIVEN uiState sendCardData is not SwapCardData WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = loadingState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN valid SwapCardData state WHEN called THEN swapButton is disabled`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN valid SwapCardData state WHEN called THEN changeCardsButtonState is DISABLED`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.DISABLED) + } + + @Test + fun `GIVEN valid state WHEN called THEN notifications contain SwapNotSupported warning`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Warning.SwapNotSupported::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN fee is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN providerState is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java) + } + + @Test + fun `GIVEN valid state with cold wallet WHEN called THEN swapButton isHoldToConfirm is false`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.isHoldToConfirm).isFalse() + } + + @Test + fun `GIVEN valid state with hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val baseState = buildReadyState(hotWallet) + val fromStatus = buildSwapCurrencyStatus(hotWallet) + val toStatus = buildSwapCurrencyStatus(hotWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + + @Test + fun `GIVEN valid state WHEN called THEN sendCardData type is ReadOnly`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + assertThat(sendCard?.type).isInstanceOf(TransactionCardType.ReadOnly::class.java) + } + } + + // endregion + + // region updateCurrenciesState + + @Nested + inner class UpdateCurrenciesState { + + @Test + fun `GIVEN both currencies null WHEN called THEN sendCard is Empty`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.Empty::class.java) + } + + @Test + fun `GIVEN both currencies non-null WHEN called THEN sendCard is SwapCardData`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + shouldResetAmount = false, + ) + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.SwapCardData::class.java) + } + + @Test + fun `WHEN called THEN notifications is cleared`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + assertThat(result.notifications).isEmpty() + } + + @Test + fun `WHEN called THEN isInsufficientFunds is false`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + assertThat(result.isInsufficientFunds).isFalse() + } + + @Test + fun `WHEN called THEN fee is Empty`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `WHEN called THEN changeCardsButtonState is ENABLED`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED) + } + + @Test + fun `GIVEN hot wallet fromStatus WHEN called THEN swapButton isHoldToConfirm is true`() { + val baseState = buildReadyState(hotWallet) + val fromStatus = buildSwapCurrencyStatus(hotWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + + @Test + fun `GIVEN toSwapCurrencyStatus is null WHEN called THEN sendCard isEnabled is false`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + val inputtable = sendCard?.type as? TransactionCardType.Inputtable + assertThat(inputtable?.isEnabled).isFalse() + } + } + + // endregion + + // region updateCurrencyBalanceStatus + + @Nested + inner class UpdateCurrencyBalanceStatus { + + @Test + fun `GIVEN balance hidden flag true WHEN called THEN sendCardData isBalanceHidden is true`() { + every { isBalanceHiddenProvider() } returns true + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateCurrencyBalanceStatus( + uiState = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + emptyAmountState = emptyAmountState, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + assertThat(sendCard?.isBalanceHidden).isTrue() + } + + @Test + fun `GIVEN balance hidden flag false WHEN called THEN sendCardData isBalanceHidden is false`() { + every { isBalanceHiddenProvider() } returns false + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateCurrencyBalanceStatus( + uiState = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + emptyAmountState = emptyAmountState, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + assertThat(sendCard?.isBalanceHidden).isFalse() + } + + @Test + fun `GIVEN fromStatus null WHEN called THEN sendCard becomes Empty`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.updateCurrencyBalanceStatus( + uiState = baseState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + emptyAmountState = emptyAmountState, + ) + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.Empty::class.java) + } + } + + // endregion + + // --- Helpers --- + + private fun buildReadyState(userWallet: UserWallet): SwapStateHolder { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + return sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + } +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt new file mode 100644 index 0000000000..e335298820 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt @@ -0,0 +1,844 @@ +package com.tangem.feature.swap + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +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.ExpressDataError +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.ui.* +import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.models.states.ProviderState +import com.tangem.feature.swap.models.states.SwapNotificationUM +import com.tangem.feature.swap.ui.StateBuilder +import com.tangem.utils.Provider +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class StateBuilderQuotesTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isBalanceHiddenProvider: Provider = mockk() + private val appCurrencyProvider: Provider = mockk() + private val isAccountsModeProvider: Provider = mockk() + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + + private lateinit var sut: StateBuilder + + private val userWalletId = UserWalletId("aabbccdd") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + private val hotWallet: UserWallet.Hot = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + private val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + + @BeforeEach + fun setup() { + every { isBalanceHiddenProvider() } returns false + every { appCurrencyProvider() } returns AppCurrency.Default + every { isAccountsModeProvider() } returns false + every { iGaslessFeeSupportedForNetwork(any()) } returns false + + sut = StateBuilder( + actions = actions, + isBalanceHiddenProvider = isBalanceHiddenProvider, + appCurrencyProvider = appCurrencyProvider, + isAccountsModeProvider = isAccountsModeProvider, + iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + ) + } + + // region createQuotesLoadingState + + @Nested + inner class CreateQuotesLoadingState { + + @Test + fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = loadingState, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN valid SwapCardData state WHEN called THEN providerState is Loading`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + assertThat(result.providerState).isInstanceOf(ProviderState.Loading::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN changeCardsButtonState is UPDATE_IN_PROGRESS`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.UPDATE_IN_PROGRESS) + } + + @Test + fun `GIVEN valid state WHEN called THEN swapButton is disabled`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN valid state WHEN called THEN fee is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN notifications is cleared`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + assertThat(result.notifications).isEmpty() + } + + @Test + fun `GIVEN hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val baseState = buildReadyState(hotWallet) + val fromStatus = buildSwapCurrencyStatus(hotWallet) + val toStatus = buildSwapCurrencyStatus(hotWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + + @Test + fun `GIVEN valid state WHEN called THEN receiveCardData amountTextFieldValue is null`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData + assertThat(receiveCard?.amountTextFieldValue).isNull() + } + } + + // endregion + + // region createQuotesLoadedState + + @Nested + inner class CreateQuotesLoadedState { + + @Test + fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = loadingState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN valid state with hideFee true WHEN called THEN fee is Empty`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = true, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN valid state with hideFee false and single fee WHEN called THEN fee is Content`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel( + userWallet = coldWallet, + isBalanceEnough = true, + txFeeState = TxFeeState.SingleFeeState(fee = buildTxFeeLegacy(FeeType.NORMAL)), + ) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Content::class.java) + } + + @Test + fun `GIVEN valid state with sufficient balance WHEN called THEN isInsufficientFunds is false`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.isInsufficientFunds).isFalse() + } + + @Test + fun `GIVEN valid state with insufficient balance WHEN called THEN isInsufficientFunds is true`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel( + coldWallet, + isBalanceEnough = false, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + ) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.isInsufficientFunds).isTrue() + } + + @Test + fun `GIVEN valid state WHEN called THEN changeCardsButtonState is ENABLED`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED) + } + + @Test + fun `GIVEN valid state with hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val baseState = buildReadyState(hotWallet) + val quoteModel = buildQuoteModel(hotWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + + @Test + fun `GIVEN provider with termsOfUse WHEN called THEN tosState has tosLink`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider(termsOfUse = "https://example.com/tos") + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.tosState?.tosLink).isNotNull() + } + + @Test + fun `GIVEN provider without termsOfUse WHEN called THEN tosState has null tosLink`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider(termsOfUse = null) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.tosState?.tosLink).isNull() + } + + @Test + fun `GIVEN no blocking notifications WHEN called THEN swapButton is enabled`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.swapButton.isEnabled).isTrue() + } + + @Test + fun `GIVEN multiple fee state WHEN called THEN fee is Content with isClickable true`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel( + userWallet = coldWallet, + isBalanceEnough = true, + txFeeState = TxFeeState.MultipleFeeState( + normalFee = buildTxFeeLegacy(FeeType.NORMAL), + priorityFee = buildTxFeeLegacy(FeeType.PRIORITY), + ), + ) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + val feeContent = result.fee as? FeeItemState.Content + assertThat(feeContent?.isClickable).isTrue() + } + } + + // endregion + + // region createQuotesErrorState + + @Nested + inner class CreateQuotesErrorState { + + @Test + fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = TokenSwapInfo( + tokenAmount = buildSwapAmount(), + amountFiat = BigDecimal.ZERO, + swapCurrencyStatus = fromStatus, + ) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = loadingState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN valid state WHEN called THEN swapButton is disabled`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN valid state WHEN called THEN fee is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN permissionUM is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result.permissionUM).isEqualTo(SwapPermissionUM.Empty) + } + + @Test + fun `GIVEN toSwapCurrencyStatus null WHEN called THEN receiveCardData is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result.receiveCardData).isInstanceOf(SwapCardState.Empty::class.java) + } + + @Test + fun `GIVEN toSwapCurrencyStatus non-null WHEN called THEN receiveCardData is SwapCardData`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = toStatus, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result.receiveCardData).isInstanceOf(SwapCardState.SwapCardData::class.java) + } + + @Test + fun `GIVEN ExchangeTooSmallAmountError WHEN called THEN providerState is Content`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.ExchangeTooSmallAmountError( + amount = buildSwapAmount(), + code = 100, + ), + needApplyFCARestrictions = false, + ) + + assertThat(result.providerState).isInstanceOf(ProviderState.Content::class.java) + } + + @Test + fun `GIVEN UnknownError WHEN called THEN providerState is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java) + } + } + + // endregion + + // region createQuotesEmptyAmountState + + @Nested + inner class CreateQuotesEmptyAmountState { + + @Test + fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = loadingState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN valid SwapCardData state WHEN called THEN swapButton is disabled`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN valid state WHEN called THEN notifications is empty`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result.notifications).isEmpty() + } + + @Test + fun `GIVEN valid state WHEN called THEN isInsufficientFunds is false`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result.isInsufficientFunds).isFalse() + } + + @Test + fun `GIVEN valid state WHEN called THEN fee is Empty`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN changeCardsButtonState is ENABLED`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED) + } + + @Test + fun `GIVEN valid state WHEN called THEN providerState is Empty`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN receiveCard amountTextFieldValue is 0`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData + assertThat(receiveCard?.amountTextFieldValue?.text).isEqualTo("0") + } + + @Test + fun `GIVEN fromSwapCurrencyStatus with hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val baseState = buildReadyState(hotWallet) + val fromStatus = buildSwapCurrencyStatus(hotWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + } + + // endregion + + // --- Helpers --- + + private fun buildReadyState(userWallet: UserWallet): SwapStateHolder { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + return sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + } + + private fun buildQuoteModel( + userWallet: UserWallet, + isBalanceEnough: Boolean, + includeFeeInAmount: IncludeFeeInAmount = IncludeFeeInAmount.Excluded, + txFeeState: TxFeeState = TxFeeState.Empty, + ): SwapState.QuotesLoadedState { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + + val fromTokenInfo = TokenSwapInfo( + tokenAmount = buildSwapAmount(), + amountFiat = BigDecimal("100.00"), + swapCurrencyStatus = fromStatus, + ) + val toTokenInfo = TokenSwapInfo( + tokenAmount = buildSwapAmount(value = BigDecimal("0.05")), + amountFiat = BigDecimal("100.00"), + swapCurrencyStatus = toStatus, + ) + + return SwapState.QuotesLoadedState( + fromTokenInfo = fromTokenInfo, + toTokenInfo = toTokenInfo, + priceImpact = PriceImpact.Empty, + preparedSwapConfigState = PreparedSwapConfigState( + isBalanceEnough = isBalanceEnough, + feeState = SwapFeeState.Enough, + hasOutgoingTransaction = false, + includeFeeInAmount = includeFeeInAmount, + ), + permissionState = PermissionDataState.Empty, + txFee = txFeeState, + currencyCheck = null, + validationResult = null, + minAdaValue = null, + swapProvider = buildSwapProvider(), + ) + } + + private fun buildSwapProvider( + termsOfUse: String? = null, + privacyPolicy: String? = null, + ) = SwapProvider( + providerId = "provider-id", + name = "TestProvider", + type = ExchangeProviderType.DEX, + imageLarge = "https://example.com/icon.png", + termsOfUse = termsOfUse, + privacyPolicy = privacyPolicy, + isRecommended = false, + slippage = null, + ) + + private fun buildSwapAmount(value: BigDecimal = BigDecimal("1.0")) = SwapAmount( + value = value, + decimals = 18, + ) + + private fun buildTokenSwapInfo(swapCurrencyStatus: SwapCurrencyStatus) = TokenSwapInfo( + tokenAmount = buildSwapAmount(), + amountFiat = BigDecimal.ZERO, + swapCurrencyStatus = swapCurrencyStatus, + ) + + private fun buildTxFeeLegacy(feeType: FeeType): TxFee.Legacy { + val fee: com.tangem.blockchain.common.transaction.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, + ) + } +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt new file mode 100644 index 0000000000..ed55977ec8 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt @@ -0,0 +1,603 @@ +package com.tangem.feature.swap + +import com.google.common.truth.Truth.assertThat +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.swap.models.SwapCurrencyStatus +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.ui.* +import com.tangem.feature.swap.model.SwapProcessDataState +import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.models.states.ProviderState +import com.tangem.feature.swap.models.states.SwapNotificationUM +import com.tangem.feature.swap.ui.StateBuilder +import com.tangem.utils.Provider +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class StateBuilderSwapDataTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isBalanceHiddenProvider: Provider = mockk() + private val appCurrencyProvider: Provider = mockk() + private val isAccountsModeProvider: Provider = mockk() + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + + private lateinit var sut: StateBuilder + + private val userWalletId = UserWalletId("aabbccdd") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + private val hotWallet: UserWallet.Hot = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + private val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + + @BeforeEach + fun setup() { + every { isBalanceHiddenProvider() } returns false + every { appCurrencyProvider() } returns AppCurrency.Default + every { isAccountsModeProvider() } returns false + every { iGaslessFeeSupportedForNetwork(any()) } returns false + + sut = StateBuilder( + actions = actions, + isBalanceHiddenProvider = isBalanceHiddenProvider, + appCurrencyProvider = appCurrencyProvider, + isAccountsModeProvider = isAccountsModeProvider, + iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + ) + } + + // region createSwapInProgressState + + @Nested + inner class CreateSwapInProgressState { + + @Test + fun `WHEN called THEN swapButton isInProgress becomes true`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createSwapInProgressState(baseState) + + assertThat(result.swapButton.isInProgress).isTrue() + } + + @Test + fun `WHEN called THEN swapButton isEnabled becomes false`() { + val baseState = buildReadyState(coldWallet) + // force enable the button by overriding manually + val stateWithEnabled = baseState.copy( + swapButton = baseState.swapButton.copy(isEnabled = true), + ) + + val result = sut.createSwapInProgressState(stateWithEnabled) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `WHEN called THEN all other fields remain unchanged`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createSwapInProgressState(baseState) + + assertThat(result.sendCardData).isEqualTo(baseState.sendCardData) + assertThat(result.receiveCardData).isEqualTo(baseState.receiveCardData) + assertThat(result.fee).isEqualTo(baseState.fee) + assertThat(result.changeCardsButtonState).isEqualTo(baseState.changeCardsButtonState) + } + } + + // endregion + + // region createSilentLoadState + + @Nested + inner class CreateSilentLoadState { + + @Test + fun `WHEN called THEN changeCardsButtonState is UPDATE_IN_PROGRESS`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createSilentLoadState(baseState) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.UPDATE_IN_PROGRESS) + } + + @Test + fun `GIVEN notifications without PermissionNeeded WHEN called THEN notifications remain unchanged`() { + val errorNotification = SwapNotificationUM.Warning.SwapNotSupported + val baseState = buildReadyState(coldWallet).copy( + notifications = persistentListOf(errorNotification), + ) + + val result = sut.createSilentLoadState(baseState) + + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications[0]).isEqualTo(errorNotification) + } + + @Test + fun `GIVEN notifications with PermissionNeeded WHEN called THEN PermissionNeeded is removed`() { + val permissionNeeded = SwapNotificationUM.Info.PermissionNeeded( + providerName = "TestProvider", + fromTokenSymbol = "ETH", + onApproveClick = {}, + ) + val otherNotification = SwapNotificationUM.Warning.SwapNotSupported + val baseState = buildReadyState(coldWallet).copy( + notifications = listOf(permissionNeeded, otherNotification).toImmutableList(), + ) + + val result = sut.createSilentLoadState(baseState) + + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications[0]).isEqualTo(otherNotification) + } + } + + // endregion + + // region updateSwapAmount + + @Nested + inner class UpdateSwapAmount { + + @Test + fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateSwapAmount( + uiState = loadingState, + amountFormatted = "1.5", + amountRaw = "1.5", + fromSwapCurrencyStatus = fromStatus, + minTxAmount = null, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN amount is above minTxAmount WHEN called THEN inputError is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateSwapAmount( + uiState = baseState, + amountFormatted = "2.0", + amountRaw = "2.0", + fromSwapCurrencyStatus = fromStatus, + minTxAmount = BigDecimal("1.0"), + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + val inputtable = sendCard?.type as? TransactionCardType.Inputtable + assertThat(inputtable?.inputError).isEqualTo(TransactionCardType.InputError.Empty) + } + + @Test + fun `GIVEN amount is below minTxAmount WHEN called THEN inputError is WrongAmount`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateSwapAmount( + uiState = baseState, + amountFormatted = "0.5", + amountRaw = "0.5", + fromSwapCurrencyStatus = fromStatus, + minTxAmount = BigDecimal("1.0"), + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + val inputtable = sendCard?.type as? TransactionCardType.Inputtable + assertThat(inputtable?.inputError).isEqualTo(TransactionCardType.InputError.WrongAmount) + } + + @Test + fun `GIVEN minTxAmount is null WHEN called THEN inputError is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateSwapAmount( + uiState = baseState, + amountFormatted = "0.001", + amountRaw = "0.001", + fromSwapCurrencyStatus = fromStatus, + minTxAmount = null, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + val inputtable = sendCard?.type as? TransactionCardType.Inputtable + assertThat(inputtable?.inputError).isEqualTo(TransactionCardType.InputError.Empty) + } + + @Test + fun `WHEN called THEN sendCardData amountTextFieldValue text is updated`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateSwapAmount( + uiState = baseState, + amountFormatted = "3.14", + amountRaw = "3.14", + fromSwapCurrencyStatus = fromStatus, + minTxAmount = null, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + assertThat(sendCard?.amountTextFieldValue?.text).isEqualTo("3.14") + } + } + + // endregion + + // region updateBalanceHiddenState + + @Nested + inner class UpdateBalanceHiddenState { + + @Test + fun `GIVEN isBalanceHidden true WHEN called THEN sendCardData isBalanceHidden is true`() { + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val baseState = sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val result = sut.updateBalanceHiddenState(baseState, isBalanceHidden = true) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + assertThat(sendCard?.isBalanceHidden).isTrue() + } + + @Test + fun `GIVEN isBalanceHidden true WHEN called THEN receiveCardData isBalanceHidden is true`() { + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val baseState = sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val result = sut.updateBalanceHiddenState(baseState, isBalanceHidden = true) + + val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData + assertThat(receiveCard?.isBalanceHidden).isTrue() + } + + @Test + fun `GIVEN isBalanceHidden false WHEN called THEN both cards isBalanceHidden is false`() { + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val baseState = sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val result = sut.updateBalanceHiddenState(baseState, isBalanceHidden = false) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData + assertThat(sendCard?.isBalanceHidden).isFalse() + assertThat(receiveCard?.isBalanceHidden).isFalse() + } + + @Test + fun `GIVEN sendCard is Empty type WHEN called THEN sendCard remains Empty type`() { + val loadingState = sut.createInitialLoadingState() + + val result = sut.updateBalanceHiddenState(loadingState, isBalanceHidden = true) + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.Empty::class.java) + } + } + + // endregion + + // region loadingPermissionState + + @Nested + inner class LoadingPermissionState { + + @Test + fun `WHEN called THEN swapButton isEnabled is false`() { + val baseState = buildReadyState(coldWallet).copy( + swapButton = buildReadyState(coldWallet).swapButton.copy(isEnabled = true), + ) + + val result = sut.loadingPermissionState(baseState) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `WHEN called THEN swapButton isInProgress is false`() { + val baseState = buildReadyState(coldWallet).copy( + swapButton = buildReadyState(coldWallet).swapButton.copy(isInProgress = true), + ) + + val result = sut.loadingPermissionState(baseState) + + assertThat(result.swapButton.isInProgress).isFalse() + } + + @Test + fun `GIVEN notifications without PermissionNeeded WHEN called THEN ApprovalInProgressWarning is prepended`() { + val existingNotification = SwapNotificationUM.Warning.SwapNotSupported + val baseState = buildReadyState(coldWallet).copy( + notifications = persistentListOf(existingNotification), + ) + + val result = sut.loadingPermissionState(baseState) + + assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.ApprovalInProgressWarning::class.java) + } + + @Test + fun `GIVEN notifications with PermissionNeeded WHEN called THEN PermissionNeeded is replaced by ApprovalInProgressWarning`() { + val permissionNeeded = SwapNotificationUM.Info.PermissionNeeded( + providerName = "TestProvider", + fromTokenSymbol = "ETH", + onApproveClick = {}, + ) + val baseState = buildReadyState(coldWallet).copy( + notifications = persistentListOf(permissionNeeded), + ) + + val result = sut.loadingPermissionState(baseState) + + assertThat(result.notifications).doesNotContain(permissionNeeded) + assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.ApprovalInProgressWarning::class.java) + } + } + + // endregion + + // region dismissBottomSheet + + @Nested + inner class DismissBottomSheet { + + @Test + fun `GIVEN bottomSheetConfig is null WHEN called THEN bottomSheetConfig remains null`() { + val baseState = buildReadyState(coldWallet) + assertThat(baseState.bottomSheetConfig).isNull() + + val result = sut.dismissBottomSheet(baseState) + + assertThat(result.bottomSheetConfig).isNull() + } + + @Test + fun `GIVEN bottomSheetConfig is shown WHEN called THEN bottomSheetConfig isShown becomes false`() { + val baseState = buildReadyState(coldWallet).copy( + bottomSheetConfig = com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = mockk(relaxed = true), + ), + ) + + val result = sut.dismissBottomSheet(baseState) + + assertThat(result.bottomSheetConfig?.isShown).isFalse() + } + } + + // endregion + + // region addNotification + + @Nested + inner class AddNotification { + + @Test + fun `GIVEN a message WHEN called THEN notifications contains GenericError`() { + val baseState = buildReadyState(coldWallet) + val message = com.tangem.core.ui.extensions.stringReference("Something went wrong") + + val result = sut.addNotification( + uiState = baseState, + message = message, + onClick = {}, + ) + + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.GenericError::class.java) + } + + @Test + fun `GIVEN null message WHEN called THEN notifications contains GenericError`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.addNotification( + uiState = baseState, + message = null, + onClick = {}, + ) + + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.GenericError::class.java) + } + } + + // endregion + + // region createSuccessState + + @Nested + inner class CreateSuccessState { + + @Test + fun `GIVEN valid state WHEN called THEN successState is not null`() { + val baseState = buildReadyStateWithContentProvider(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val dataState = SwapProcessDataState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + selectedFee = null, + ) + val swapTransactionState = buildSwapTransactionState() + + val result = sut.createSuccessState( + uiState = baseState, + swapTransactionState = swapTransactionState, + dataState = dataState, + onExploreClick = {}, + onStatusClick = {}, + txUrl = "https://example.com/tx/abc", + ) + + assertThat(result.successState).isNotNull() + } + + @Test + fun `GIVEN CEX provider WHEN called THEN shouldShowStatusButton is true`() { + val baseState = buildReadyStateWithContentProvider( + coldWallet, + providerType = ExchangeProviderType.CEX, + ) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val dataState = SwapProcessDataState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + selectedFee = null, + ) + val swapTransactionState = buildSwapTransactionState() + + val result = sut.createSuccessState( + uiState = baseState, + swapTransactionState = swapTransactionState, + dataState = dataState, + onExploreClick = {}, + onStatusClick = {}, + txUrl = "https://example.com/tx/abc", + ) + + assertThat(result.successState?.shouldShowStatusButton).isTrue() + } + + @Test + fun `GIVEN DEX provider WHEN called THEN shouldShowStatusButton is false`() { + val baseState = buildReadyStateWithContentProvider( + coldWallet, + providerType = ExchangeProviderType.DEX, + ) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val dataState = SwapProcessDataState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + selectedFee = null, + ) + val swapTransactionState = buildSwapTransactionState() + + val result = sut.createSuccessState( + uiState = baseState, + swapTransactionState = swapTransactionState, + dataState = dataState, + onExploreClick = {}, + onStatusClick = {}, + txUrl = "https://example.com/tx/abc", + ) + + assertThat(result.successState?.shouldShowStatusButton).isFalse() + } + + @Test + fun `GIVEN txUrl WHEN called THEN successState txUrl matches`() { + val baseState = buildReadyStateWithContentProvider(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val dataState = SwapProcessDataState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + selectedFee = null, + ) + val swapTransactionState = buildSwapTransactionState() + val expectedUrl = "https://etherscan.io/tx/0xabc" + + val result = sut.createSuccessState( + uiState = baseState, + swapTransactionState = swapTransactionState, + dataState = dataState, + onExploreClick = {}, + onStatusClick = {}, + txUrl = expectedUrl, + ) + + assertThat(result.successState?.txUrl).isEqualTo(expectedUrl) + } + } + + // endregion + + // --- Helpers --- + + private fun buildReadyState(userWallet: UserWallet): SwapStateHolder { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + return sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + } + + private fun buildReadyStateWithContentProvider( + userWallet: UserWallet, + providerType: ExchangeProviderType = ExchangeProviderType.DEX, + ): SwapStateHolder { + val baseState = buildReadyState(userWallet) + return baseState.copy( + providerState = ProviderState.Content( + id = "provider-id", + name = "TestProvider", + type = providerType.providerName, + iconUrl = "https://example.com/icon.png", + subtitle = com.tangem.core.ui.extensions.stringReference("1 ETH ≈ 2000 USDT"), + additionalBadge = ProviderState.AdditionalBadge.Empty, + selectionType = ProviderState.SelectionType.CLICK, + namePrefix = ProviderState.PrefixType.NONE, + onProviderClick = {}, + ), + ) + } + + private fun buildSwapTransactionState(): SwapTransactionState.TxSent { + return SwapTransactionState.TxSent( + fromAmount = "1.0 ETH", + toAmount = "2000 USDT", + fromAmountValue = BigDecimal("1.0"), + toAmountValue = BigDecimal("2000"), + txHash = "0xabc", + timestamp = System.currentTimeMillis(), + ) + } +} \ No newline at end of file