diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index e3360199cf..595595a42b 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -150,5 +150,9 @@ { "name": "AND_15235_VISA_MULTIPLE_CARDS", "version": "5.40" + }, + { + "name": "AND_15715_SWAP_BEST_DEX_RATE_ENABLED", + "version": "undefined" } ] diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignUseCaseTest.kt index 4621315e38..d619014b2f 100644 --- a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignUseCaseTest.kt +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignUseCaseTest.kt @@ -47,7 +47,7 @@ internal class SignUseCaseTest { val hash = byteArrayOf(1, 2, 3) val signature = byteArrayOf(9, 9) - every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer + every { cardSdkConfigRepository.getCommonSigner(any(), any(), any()) } returns signer coEvery { walletManagersFacade.getOrCreateWalletManager(coldWallet.walletId, network) } returns walletManager coEvery { signer.sign(eq(hash), eq(walletManagerKey)) } returns CompletionResult.Success(signature) @@ -68,7 +68,7 @@ internal class SignUseCaseTest { val walletManager: WalletManager = mockk { every { wallet } returns mockk { every { publicKey } returns walletManagerKey } } val error: TangemError = mockk() - every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer + every { cardSdkConfigRepository.getCommonSigner(any(), any(), any()) } returns signer coEvery { walletManagersFacade.getOrCreateWalletManager(coldWallet.walletId, network) } returns walletManager coEvery { signer.sign(any(), any()) } returns CompletionResult.Failure(error) @@ -86,7 +86,7 @@ internal class SignUseCaseTest { val signer: TransactionSigner = mockk() val publicKeySlot = slot() - every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer + every { cardSdkConfigRepository.getCommonSigner(any(), any(), any()) } returns signer coEvery { signer.sign(eq(hashes), capture(publicKeySlot)) } returns CompletionResult.Success(signatures) // Act @@ -98,7 +98,7 @@ internal class SignUseCaseTest { assertThat(publicKeySlot.captured.seedKey).isEqualTo(publicKey) assertThat(publicKeySlot.captured.derivationType).isNull() // Card is not backed up (backupStatus == null) and not a twin, so its id is passed to the signer - verify(exactly = 1) { cardSdkConfigRepository.getCommonSigner(cardId = coldWallet.cardId, twinKey = null) } + verify(exactly = 1) { cardSdkConfigRepository.getCommonSigner(cardId = coldWallet.cardId, twinKey = null, any()) } } @Test @@ -127,7 +127,7 @@ internal class SignUseCaseTest { val signer: TransactionSigner = mockk() val error: TangemError = mockk { every { message } returns "Signing canceled" } - every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer + every { cardSdkConfigRepository.getCommonSigner(any(), any(), any()) } returns signer coEvery { signer.sign(any>(), any()) } returns CompletionResult.Failure(error) // Act @@ -147,7 +147,7 @@ internal class SignUseCaseTest { // Assert assertThat(result.getOrNull()).isEmpty() - verify(exactly = 0) { cardSdkConfigRepository.getCommonSigner(any(), any()) } + verify(exactly = 0) { cardSdkConfigRepository.getCommonSigner(any(), any(), any()) } verify(exactly = 0) { getHotTransactionSigner(any()) } } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt index 41e06a2c94..9462e334c9 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt @@ -110,7 +110,7 @@ data class PriceImpact( } fun shouldShowWarning(): Boolean { - return type.ordinal > Type.LOW.ordinal || amountSignificance.ordinal > AmountSignificance.LOW.ordinal + return type.ordinal > Type.LOW.ordinal && amountSignificance.ordinal > AmountSignificance.LOW.ordinal } companion object { diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt index f3ea6a28c3..91abcfd896 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt @@ -9,4 +9,5 @@ interface SwapFeatureToggles { val isSwapRateExperienceEnabled: Boolean val isSwapPredefinedButtonsEnabled: Boolean val isExpressShareButtonEnabled: Boolean + val isSwapBestDexRateEnabled: Boolean } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt index 166682b781..5d5fe50852 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt @@ -63,6 +63,9 @@ enum class ExchangeProviderType(val providerName: String) { DEX_BRIDGE("DEX/Bridge"), ; + /** Returns true for DEX-based providers ([DEX] and [DEX_BRIDGE]). */ + fun isDex(): Boolean = this == DEX || this == DEX_BRIDGE + companion object { fun getSwapProviderTypes(): List { return listOf(CEX, DEX, DEX_BRIDGE) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 72c809308b..5b41f9ef63 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -95,7 +95,7 @@ data class PriceImpact( } fun shouldShowWarning(): Boolean { - return type.ordinal > Type.LOW.ordinal || amountSignificance.ordinal > AmountSignificance.LOW.ordinal + return type.ordinal > Type.LOW.ordinal && amountSignificance.ordinal > AmountSignificance.LOW.ordinal } companion object { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index 79cfd4ab71..1babbc9152 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -47,4 +47,9 @@ internal class DefaultSwapFeatureToggles @Inject constructor( get() = featureTogglesManager.isFeatureEnabled( toggle = FeatureToggles.AND_15489_EXPRESS_SHARE_BUTTON_ENABLED, ) + + override val isSwapBestDexRateEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.AND_15715_SWAP_BEST_DEX_RATE_ENABLED, + ) && isSwapIntegratedApproveEnabled } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderResolver.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderResolver.kt new file mode 100644 index 0000000000..8db946ff18 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderResolver.kt @@ -0,0 +1,150 @@ +package com.tangem.feature.swap.converters + +import com.tangem.feature.swap.domain.models.domain.SwapProvider +import com.tangem.feature.swap.domain.models.ui.PermissionDataState +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.model.consideredProvidersStates +import com.tangem.feature.swap.models.states.ProviderState +import com.tangem.feature.swap.models.states.ProviderState.AdditionalBadge +import com.tangem.utils.isNullOrZero +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Pure provider-level decisions for the swap UI: + * - [findBest] — which provider is the "best" among the loaded quotes, and + * - [resolveBadge] — which [ProviderState.AdditionalBadge] a provider row should show. + */ +internal object SwapProviderResolver { + + private val FCA_RESTRICTED_PROVIDER_IDS = setOf( + "changelly", + "changenow", + "okx-cross-chain", + "okx-on-chain", + "simpleswap", + ) + + /** + * Picks the best provider among [states]. + * + * When [isSwapBestDexRateEnabled] is on and at least one DEX/DEX_BRIDGE provider is present, the + * best-rated DEX provider wins; otherwise the overall best-rated provider is returned (the best + * CEX when no DEX is available). "Best rated" = lowest from/to fiat ratio (most output per unit + * of input). Returns null when [states] is empty. + * + * @param isSwapBestDexRateEnabled whether the Best DEX Rate feature toggle is on. + */ + fun findBest( + states: Map, + isSwapBestDexRateEnabled: Boolean, + ): SwapProvider? { + if (!isSwapBestDexRateEnabled) return findBestRated(states) + val dexStates = states.filterKeys { it.type.isDex() } + return if (dexStates.isNotEmpty()) { + findBestRated(dexStates) + } else { + findBestRated(states) + } + } + + /** + * Resolves the badge for a single provider row. + * + * Priority: FCA restriction → permission required → recommended → best rate → none. A best-rate + * badge is shown only when more than one provider is considered, FCA restrictions are not applied, + * and this row's quote carries no price-impact warning. Which best-rate badge it is depends on the + * provider mix (only relevant when [isSwapBestDexRateEnabled] is on): + * - [AdditionalBadge.BestTrade] ("Best rate") — always on the overall best-rated provider, + * regardless of its type. + * - [AdditionalBadge.BestDexRate] ("Best DEX rate") — only when both CEX and DEX providers are + * present and a CEX is the overall best (so the best DEX is not the overall best); it is then + * shown on the best-rated DEX. When a DEX already is the overall best, or the set is CEX-only / + * DEX-only, no separate "Best DEX rate" badge is shown. + * + * When [isSwapBestDexRateEnabled] is off, only the overall best provider gets [AdditionalBadge.BestTrade] + * (legacy behaviour) and [AdditionalBadge.BestDexRate] is never produced. + * + * @param states all loaded quotes — used to find the best providers and to count considered providers. + * @param provider the provider this row represents. + * @param needApplyFCARestrictions whether FCA restrictions apply to the current user. + * @param state this provider's [SwapState]; price-impact and permission are read from it when it + * is a [SwapState.QuotesLoadedState]. Null for error rows (which only resolve to FCA / recommended / none). + * @param isSwapBestDexRateEnabled whether the Best DEX Rate feature toggle is on. + */ + fun resolveBadge( + states: Map, + provider: SwapProvider, + needApplyFCARestrictions: Boolean, + state: SwapState? = null, + isSwapBestDexRateEnabled: Boolean, + ): AdditionalBadge { + val priceImpact = (state as? SwapState.QuotesLoadedState)?.priceImpact + val permissionState = (state as? SwapState.QuotesLoadedState)?.permissionState + + val isNeedBestRateBadge = states.consideredProvidersStates().size > 1 + val isBestRateBadgeAllowed = !needApplyFCARestrictions && isNeedBestRateBadge && + priceImpact != null && !priceImpact.shouldShowWarning() + + return when { + needApplyFCARestrictions && provider.isFCARestricted() -> AdditionalBadge.FCAWarningList + permissionState is PermissionDataState.PermissionRequired -> AdditionalBadge.PermissionRequired + provider.isRecommended -> AdditionalBadge.Recommended + isBestRateBadgeAllowed -> resolveBestRateBadge(states, provider, isSwapBestDexRateEnabled) + else -> AdditionalBadge.Empty + } + } + + /** + * Picks the best-rate badge for [provider] once it has passed the eligibility gate in [resolveBadge]. + * Returns [AdditionalBadge.Empty] when this row is neither the overall best nor the eligible best DEX. + */ + private fun resolveBestRateBadge( + states: Map, + provider: SwapProvider, + isSwapBestDexRateEnabled: Boolean, + ): AdditionalBadge { + val overallBest = findBestRated(states) + val isOverallBest = provider.providerId == overallBest?.providerId + + // Toggle off → legacy behaviour: only the overall best provider gets the "Best rate" badge. + if (!isSwapBestDexRateEnabled) { + return if (isOverallBest) AdditionalBadge.BestTrade else AdditionalBadge.Empty + } + + val dexStates = states.filterKeys { it.type.isDex() } + val hasDex = dexStates.isNotEmpty() + val hasCex = states.keys.any { !it.type.isDex() } + val bestDex = findBestRated(dexStates) + val isBestDex = bestDex != null && provider.providerId == bestDex.providerId + // Both types present and a CEX is the overall best (i.e. overall best != best DEX). + val isCexBeatsDex = hasDex && hasCex && overallBest?.providerId != bestDex?.providerId + + return when { + isOverallBest -> AdditionalBadge.BestTrade + isCexBeatsDex && isBestDex -> AdditionalBadge.BestDexRate + else -> AdditionalBadge.Empty + } + } + + /** Best provider following the default best-rate behaviour over all providers. */ + private fun findBestRated(states: Map): SwapProvider? { + return states.minByOrNull { entry -> entry.value.rateRatio() }?.key + } + + private fun SwapProvider.isFCARestricted(): Boolean = providerId in FCA_RESTRICTED_PROVIDER_IDS + + private fun SwapState.QuotesLoadedState.rateRatio(): BigDecimal { + val fromAmountFiat = fromTokenInfo.amountFiat + val toAmountFiat = toTokenInfo.amountFiat + return if (!fromAmountFiat.isNullOrZero() && !toAmountFiat.isNullOrZero()) { + fromAmountFiat.divide( + toAmountFiat, + toTokenInfo.swapCurrencyStatus.currency.decimals, + RoundingMode.HALF_UP, + ) + } else { + BigDecimal.ZERO + } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderStateBuilder.kt index cddf47c4a4..830a02a8f7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapProviderStateBuilder.kt @@ -7,56 +7,36 @@ import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.PermissionDataState +import com.tangem.feature.swap.domain.models.ui.SwapState import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo import com.tangem.feature.swap.models.states.PercentDifference import com.tangem.feature.swap.models.states.ProviderState /** * Builds [ProviderState.Content] for the swap provider list / row. - * - * Pure: takes everything it needs as parameters. Designed to be unit-tested in isolation. */ internal object SwapProviderStateBuilder { - private val FCA_RESTRICTED_PROVIDER_IDS = setOf( - "changelly", - "changenow", - "okx-cross-chain", - "okx-on-chain", - "simpleswap", - ) - /** * Provider row on the main swap screen — shows the exchange rate `1 base ≈ rate quote` * (see [SwapRateFormatter]) and allows the user to open the provider picker. */ - @Suppress("LongParameterList") fun buildContentClickable( provider: SwapProvider, - fromTokenInfo: TokenSwapInfo, - toTokenInfo: TokenSwapInfo, - permissionState: PermissionDataState, + state: SwapState.QuotesLoadedState, selectionType: ProviderState.SelectionType, - isBestRate: Boolean, - isNeedBestRateBadge: Boolean, - needApplyFCARestrictions: Boolean, + additionalBadge: ProviderState.AdditionalBadge, onProviderClick: (String) -> Unit, ): ProviderState.Content { val rateString = SwapRateFormatter.formatRate( - from = fromTokenInfo.swapCurrencyStatus.currency, - to = toTokenInfo.swapCurrencyStatus.currency, - fromAmount = fromTokenInfo.tokenAmount.value, - toAmount = toTokenInfo.tokenAmount.value, + from = state.fromTokenInfo.swapCurrencyStatus.currency, + to = state.toTokenInfo.swapCurrencyStatus.currency, + fromAmount = state.fromTokenInfo.tokenAmount.value, + toAmount = state.toTokenInfo.tokenAmount.value, ) return provider.toContent( subtitle = stringReference(rateString), - additionalBadge = resolveBadge( - provider = provider, - needApplyFCARestrictions = needApplyFCARestrictions, - permissionState = permissionState, - isBestRate = isBestRate, - isNeedBestRateBadge = isNeedBestRateBadge, - ), + additionalBadge = additionalBadge, selectionType = selectionType, percentLowerThenBest = PercentDifference.Empty, approvalSettings = ProviderState.ApprovalSettings.Empty, @@ -71,30 +51,21 @@ internal object SwapProviderStateBuilder { @Suppress("LongParameterList") fun buildContentSelectable( provider: SwapProvider, - toTokenInfo: TokenSwapInfo, - permissionState: PermissionDataState, + state: SwapState.QuotesLoadedState, pricesLowerBest: Map, selectionType: ProviderState.SelectionType, - isBestRate: Boolean = false, - isNeedBestRateBadge: Boolean = false, - needApplyFCARestrictions: Boolean, + additionalBadge: ProviderState.AdditionalBadge, onProviderClick: (String) -> Unit, onApprovalSelectClick: (SwapProvider) -> Unit = {}, ): ProviderState.Content { return provider.toContent( - subtitle = buildSelectableSubtitle(toTokenInfo), - additionalBadge = resolveBadge( - provider = provider, - needApplyFCARestrictions = needApplyFCARestrictions, - permissionState = permissionState, - isBestRate = isBestRate, - isNeedBestRateBadge = isNeedBestRateBadge, - ), + subtitle = buildSelectableSubtitle(state.toTokenInfo), + additionalBadge = additionalBadge, selectionType = selectionType, percentLowerThenBest = pricesLowerBest[provider.providerId] ?.let(PercentDifference::Value) ?: PercentDifference.Value(0f), - approvalSettings = when (permissionState) { + approvalSettings = when (state.permissionState) { is PermissionDataState.PermissionSettings -> ProviderState.ApprovalSettings.Content( onApprovalSelectClick = { onApprovalSelectClick(provider) }, ) @@ -112,15 +83,12 @@ internal object SwapProviderStateBuilder { provider: SwapProvider, alertText: TextReference, selectionType: ProviderState.SelectionType, - needApplyFCARestrictions: Boolean, + additionalBadge: ProviderState.AdditionalBadge, onProviderClick: (String) -> Unit, ): ProviderState.Content { return provider.toContent( subtitle = alertText, - additionalBadge = resolveBadge( - provider = provider, - needApplyFCARestrictions = needApplyFCARestrictions, - ), + additionalBadge = additionalBadge, selectionType = selectionType, percentLowerThenBest = PercentDifference.Empty, approvalSettings = ProviderState.ApprovalSettings.Empty, @@ -139,27 +107,6 @@ internal object SwapProviderStateBuilder { return stringReference(toAmount) } - private fun resolveBadge( - provider: SwapProvider, - needApplyFCARestrictions: Boolean, - permissionState: PermissionDataState? = null, - isBestRate: Boolean = false, - isNeedBestRateBadge: Boolean = false, - ): ProviderState.AdditionalBadge { - return when { - needApplyFCARestrictions && provider.isFCARestricted() -> - ProviderState.AdditionalBadge.FCAWarningList - permissionState is PermissionDataState.PermissionRequired -> - ProviderState.AdditionalBadge.PermissionRequired - provider.isRecommended -> - ProviderState.AdditionalBadge.Recommended - isNeedBestRateBadge && isBestRate && !needApplyFCARestrictions -> - ProviderState.AdditionalBadge.BestTrade - else -> - ProviderState.AdditionalBadge.Empty - } - } - @Suppress("LongParameterList") private fun SwapProvider.toContent( subtitle: TextReference, @@ -183,6 +130,4 @@ internal object SwapProviderStateBuilder { approvalSettings = approvalSettings, ) } - - private fun SwapProvider.isFCARestricted(): Boolean = providerId in FCA_RESTRICTED_PROVIDER_IDS } \ No newline at end of file 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 ad918f5f78..e824992d30 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 @@ -29,7 +29,6 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.R import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format @@ -81,6 +80,7 @@ import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.feature.swap.analytics.SwapEvents import com.tangem.feature.swap.analytics.SwapQuotePerformanceTracker import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent +import com.tangem.feature.swap.converters.SwapProviderResolver import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter import com.tangem.feature.swap.domain.AllowPermissionsHandler import com.tangem.feature.swap.domain.GetSwapUiModeUseCase @@ -108,12 +108,12 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.send.api.entity.FeeItem import com.tangem.features.send.api.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.features.send.impl.R import com.tangem.features.swap.SwapComponent import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.Provider import com.tangem.utils.coroutines.* import com.tangem.utils.extensions.filterIf -import com.tangem.utils.isNullOrZero import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay @@ -125,8 +125,6 @@ import java.math.RoundingMode import java.util.Locale import javax.inject.Inject -typealias SuccessLoadedSwapData = Map - @Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped @@ -231,14 +229,6 @@ internal class SwapModel @Inject constructor( private val isFiatInput = mutableStateOf(false) private var userCountry: UserCountry? = null - private val isUserResolvableError: (SwapState) -> Boolean = { swapState -> - swapState is SwapState.SwapError && - ( - swapState.error is ExpressDataError.ExchangeTooSmallAmountError || - swapState.error is ExpressDataError.ExchangeTooBigAmountError - ) - } - private val fromTokenBalanceJobHolder = JobHolder() private val toTokenBalanceJobHolder = JobHolder() private val swapPairsJobHolder = JobHolder() @@ -1043,7 +1033,7 @@ internal class SwapModel @Inject constructor( ) } - val successStates = providersState.getLastLoadedSuccessStates() + val successStates = dataState.getLastLoadedSuccessStates() val pricesLowerBest = getPricesLowerBest(provider.providerId, successStates) uiState = stateBuilder.updateProvidersBottomSheetContent( uiState = uiState, @@ -1097,16 +1087,20 @@ internal class SwapModel @Inject constructor( } private fun setupQuotesLoadedUiState(provider: SwapProvider, state: SwapState.QuotesLoadedState) { - val loadedStates = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates() - val bestRatedProviderId = findBestQuoteProvider(loadedStates)?.providerId ?: provider.providerId + val loadedStates = dataState.getLastLoadedSuccessStates() + val additionalBadge = SwapProviderResolver.resolveBadge( + provider = provider, + needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), + states = loadedStates, + state = state, + isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled, + ) uiState = stateBuilder.createQuotesLoadedState( uiStateHolder = uiState, quoteModel = state, feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency, swapProvider = provider, - bestRatedProviderId = bestRatedProviderId, - isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1, - needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), + additionalBadge = additionalBadge, swapFee = getSelectedSwapFee(), feeError = feeSelectorRepository.state.value as? FeeSelectorUM.Error, ) @@ -1181,6 +1175,14 @@ internal class SwapModel @Inject constructor( private fun setupErrorUiState(provider: SwapProvider, state: SwapState.SwapError) { singleTaskScheduler.cancelTask() + val loadedStates = dataState.getLastLoadedSuccessStates() + val additionalBadge = SwapProviderResolver.resolveBadge( + provider = provider, + needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), + states = loadedStates, + state = state, + isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled, + ) uiState = stateBuilder.createQuotesErrorState( uiStateHolder = uiState, swapProvider = provider, @@ -1188,7 +1190,7 @@ internal class SwapModel @Inject constructor( toSwapCurrencyStatus = dataState.toSwapCurrencyStatus, expressDataError = state.error, balanceStatus = state.balanceStatus, - needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), + additionalBadge = additionalBadge, swapFee = getSelectedSwapFee(), ) sendErrorAnalyticsEvent(state.error, provider) @@ -1234,7 +1236,10 @@ internal class SwapModel @Inject constructor( return if (consideredProviders.isNotEmpty()) { val successLoadedData = consideredProviders.getLastLoadedSuccessStates() - val bestQuotesProvider = findBestQuoteProvider(successLoadedData) + val bestQuotesProvider = SwapProviderResolver.findBest( + states = successLoadedData, + isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled, + ) val currentSelected = dataState.selectedProvider if (currentSelected != null && consideredProviders.keys.contains(currentSelected)) { // logic for always choose best if already selected provider @@ -1976,17 +1981,15 @@ internal class SwapModel @Inject constructor( onProviderClick = { providerId -> singleTaskScheduler.cancelTask() analyticsEventHandler.send(SwapEvents.ProviderClicked()) - val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates() + val states = dataState.getLastLoadedSuccessStates() val pricesLowerBest = getPricesLowerBest(providerId, states) - val bestRatedProviderId = findBestQuoteProvider(states)?.providerId ?: providerId uiState = stateBuilder.showSelectProviderBottomSheet( uiState = uiState, selectedProviderId = providerId, pricesLowerBest = pricesLowerBest, providersStates = dataState.lastLoadedSwapStates, + isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled, needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), - bestRatedProviderId = bestRatedProviderId, - isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1, ) { uiState = stateBuilder.dismissBottomSheet(uiState) } }, onProviderSelect = { providerId -> @@ -1996,7 +1999,7 @@ internal class SwapModel @Inject constructor( val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus val isNotNullCurrency = fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null if (provider != null && swapState != null && isNotNullCurrency) { - modelScope.launch { + modelScope.launch(dispatchers.default) { feeSelectorRepository.state.value = FeeSelectorUM.Loading feeSelectorReloadTrigger.triggerUpdate() } @@ -2156,24 +2159,6 @@ internal class SwapModel @Inject constructor( return selectedProvider } - private fun findBestQuoteProvider(state: SuccessLoadedSwapData): SwapProvider? { - // finding best quotes - return state.minByOrNull { entry -> - val toTokenInfo = entry.value.toTokenInfo - val fromAmountFiat = entry.value.fromTokenInfo.amountFiat - val toAmountFiat = toTokenInfo.amountFiat - if (!fromAmountFiat.isNullOrZero() && !toAmountFiat.isNullOrZero()) { - fromAmountFiat.divide( - toAmountFiat, - toTokenInfo.swapCurrencyStatus.currency.decimals, - RoundingMode.HALF_UP, - ) - } else { - BigDecimal.ZERO - } - }?.key - } - private fun getPricesLowerBest(selectedProviderId: String, state: SuccessLoadedSwapData): Map { val selectedProviderEntry = state.filter { entry -> entry.key.providerId == selectedProviderId }.entries.firstOrNull() @@ -2257,17 +2242,6 @@ internal class SwapModel @Inject constructor( ) } - private fun Map.getLastLoadedSuccessStates(): SuccessLoadedSwapData { - return this.filter { entry -> entry.value is SwapState.QuotesLoadedState } - .mapValues { entry -> entry.value as SwapState.QuotesLoadedState } - } - - private fun Map.consideredProvidersStates(): Map { - return this.filter { entry -> - entry.value is SwapState.QuotesLoadedState || isUserResolvableError(entry.value) - } - } - private fun sendNoticePermissionNeededEvent() { val sendTokenSymbol = dataState.fromSwapCurrencyStatus?.currency?.symbol ?: return val receiveTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index 44368feeef..3d87e38110 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -2,11 +2,14 @@ package com.tangem.feature.swap.model import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.domain.SwapPairLeast import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.SwapState import java.math.BigDecimal +typealias SuccessLoadedSwapData = Map + data class SwapProcessDataState( // Initial network id val fromSwapCurrencyStatus: SwapCurrencyStatus? = null, @@ -30,4 +33,28 @@ data class SwapProcessDataState( fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? { return lastLoadedSwapStates[selectedProvider] as? SwapState.QuotesLoadedState } + + fun getLastLoadedSuccessStates(): SuccessLoadedSwapData { + return lastLoadedSwapStates.filter { entry -> entry.value is SwapState.QuotesLoadedState } + .mapValues { entry -> entry.value as SwapState.QuotesLoadedState } + } +} + +internal fun Map.getLastLoadedSuccessStates(): SuccessLoadedSwapData { + return this.filter { entry -> entry.value is SwapState.QuotesLoadedState } + .mapValues { entry -> entry.value as SwapState.QuotesLoadedState } +} + +internal fun Map.consideredProvidersStates(): Map { + fun isUserResolvableError(swapState: SwapState): Boolean { + return swapState is SwapState.SwapError && + ( + swapState.error is ExpressDataError.ExchangeTooSmallAmountError || + swapState.error is ExpressDataError.ExchangeTooBigAmountError + ) + } + + return this.filter { entry -> + entry.value is SwapState.QuotesLoadedState || isUserResolvableError(entry.value) + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt index b36a3305f1..f5d8f4a5ca 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt @@ -47,6 +47,7 @@ sealed class ProviderState { sealed class AdditionalBadge { data object FCAWarningList : AdditionalBadge() data object BestTrade : AdditionalBadge() + data object BestDexRate : AdditionalBadge() data object Empty : AdditionalBadge() data object PermissionRequired : AdditionalBadge() data object Recommended : AdditionalBadge() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt index c5ce3a57e2..f598e7fca5 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt @@ -160,6 +160,7 @@ private fun ProviderContentState( when (state.additionalBadge) { ProviderState.AdditionalBadge.FCAWarningList -> FCABadgeItem(badgeModifier) ProviderState.AdditionalBadge.BestTrade -> BestTradeItem(badgeModifier) + ProviderState.AdditionalBadge.BestDexRate -> BestDexRateItem(badgeModifier) ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem(badgeModifier) ProviderState.AdditionalBadge.Recommended -> RecommendedItem(badgeModifier) ProviderState.AdditionalBadge.Empty -> Unit @@ -403,6 +404,24 @@ private fun BestTradeItem(modifier: Modifier = Modifier) { } } +@Composable +private fun BestDexRateItem(modifier: Modifier = Modifier) { + Box( + modifier = modifier.background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = TangemTheme.shapes.roundedCornersLarge, + ), + ) { + Text( + text = stringResourceSafe(R.string.express_provider_best_dex_rate), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.icon.accent, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6), + maxLines = 1, + ) + } +} + @Composable private fun PermissionBadgeItem(modifier: Modifier = Modifier) { Box( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItemSimple.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItemSimple.kt index f95a657f21..e242163b27 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItemSimple.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ProviderItemSimple.kt @@ -92,7 +92,9 @@ private fun SimpleProviderTrailing(state: ProviderState) { .size(TangemTheme.dimens.size20) .clip(RoundedCornerShape(TangemTheme.dimens.radius4)), ) - if (state.additionalBadge is ProviderState.AdditionalBadge.BestTrade) { + if (state.additionalBadge is ProviderState.AdditionalBadge.BestTrade || + state.additionalBadge is ProviderState.AdditionalBadge.BestDexRate + ) { SimpleBestRateBadge( modifier = Modifier .align(Alignment.BottomEnd) 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 3b95b37203..ab06eb4cb0 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 @@ -36,6 +36,7 @@ import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tokens.model.Amount import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +import com.tangem.feature.swap.converters.SwapProviderResolver import com.tangem.feature.swap.converters.SwapProviderStateBuilder import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount @@ -43,6 +44,7 @@ import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.model.SwapNotificationsFactory import com.tangem.feature.swap.model.SwapProcessDataState +import com.tangem.feature.swap.model.getLastLoadedSuccessStates import com.tangem.feature.swap.models.* import com.tangem.feature.swap.models.SwapButton.Mode import com.tangem.feature.swap.models.states.* @@ -529,9 +531,7 @@ internal class StateBuilder( quoteModel: SwapState.QuotesLoadedState, feeCryptoCurrencyStatus: CryptoCurrencyStatus?, swapProvider: SwapProvider, - bestRatedProviderId: String, - isNeedBestRateBadge: Boolean, - needApplyFCARestrictions: Boolean, + additionalBadge: ProviderState.AdditionalBadge, swapFee: SwapFee?, feeError: FeeSelectorUM.Error?, ): SwapStateHolder { @@ -641,13 +641,9 @@ internal class StateBuilder( changeCardsButtonState = ChangeCardsButtonState.ENABLED, providerState = SwapProviderStateBuilder.buildContentClickable( provider = swapProvider, - fromTokenInfo = quoteModel.fromTokenInfo, - toTokenInfo = quoteModel.toTokenInfo, - permissionState = quoteModel.permissionState, + state = quoteModel, selectionType = ProviderState.SelectionType.CLICK, - isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(), - isNeedBestRateBadge = isNeedBestRateBadge, - needApplyFCARestrictions = needApplyFCARestrictions, + additionalBadge = additionalBadge, onProviderClick = actions.onProviderClick, ), priceImpact = priceImpact, @@ -769,7 +765,7 @@ internal class StateBuilder( toSwapCurrencyStatus: SwapCurrencyStatus?, balanceStatus: SwapBalanceStatus, expressDataError: ExpressDataError, - needApplyFCARestrictions: Boolean, + additionalBadge: ProviderState.AdditionalBadge, swapFee: SwapFee?, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder @@ -789,7 +785,7 @@ internal class StateBuilder( expressDataError = expressDataError, onProviderClick = actions.onProviderClick, selectionType = ProviderState.SelectionType.CLICK, - needApplyFCARestrictions = needApplyFCARestrictions, + additionalBadge = additionalBadge, ) val type = TransactionCardType.ReadOnly( accountTitleUM = getCardAccountTitle( @@ -837,7 +833,7 @@ internal class StateBuilder( expressDataError: ExpressDataError, onProviderClick: (String) -> Unit, selectionType: ProviderState.SelectionType, - needApplyFCARestrictions: Boolean, + additionalBadge: ProviderState.AdditionalBadge, ): ProviderState { return when (expressDataError) { is ExpressDataError.ExchangeTooSmallAmountError -> { @@ -848,7 +844,7 @@ internal class StateBuilder( wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)), ), selectionType = selectionType, - needApplyFCARestrictions = needApplyFCARestrictions, + additionalBadge = additionalBadge, onProviderClick = onProviderClick, ) } @@ -860,7 +856,7 @@ internal class StateBuilder( wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)), ), selectionType = selectionType, - needApplyFCARestrictions = needApplyFCARestrictions, + additionalBadge = additionalBadge, onProviderClick = onProviderClick, ) } @@ -1177,19 +1173,24 @@ internal class StateBuilder( pricesLowerBest: Map, providersStates: Map, needApplyFCARestrictions: Boolean, - bestRatedProviderId: String, - isNeedBestRateBadge: Boolean, + isSwapBestDexRateEnabled: Boolean, onDismiss: () -> Unit, ): SwapStateHolder { + val successStates = providersStates.getLastLoadedSuccessStates() val availableProvidersStates = providersStates.entries .mapNotNull { entry -> + val additionalBadge = SwapProviderResolver.resolveBadge( + states = successStates, + provider = entry.key, + needApplyFCARestrictions = needApplyFCARestrictions, + state = entry.value, + isSwapBestDexRateEnabled = isSwapBestDexRateEnabled, + ) entry.convertToProviderBottomSheetState( pricesLowerBest = pricesLowerBest, onProviderSelect = actions.onProviderSelect, - needApplyFCARestrictions = needApplyFCARestrictions, onApprovalSelectClick = actions.onApproveTypeSelect, - bestRatedProviderId = bestRatedProviderId, - isNeedBestRateBadge = isNeedBestRateBadge, + additionalBadge = additionalBadge, ) } .sortedWith(ProviderPercentDiffComparator) @@ -1294,23 +1295,18 @@ internal class StateBuilder( pricesLowerBest: Map, onProviderSelect: (String) -> Unit, onApprovalSelectClick: (SwapProvider) -> Unit, - needApplyFCARestrictions: Boolean, - bestRatedProviderId: String, - isNeedBestRateBadge: Boolean, + additionalBadge: ProviderState.AdditionalBadge, ): ProviderState? { - val provider = this.key - return when (val state = this.value) { + val (provider, state) = this + return when (state) { is SwapState.EmptyAmountState, is SwapState.Transfer -> null is SwapState.QuotesLoadedState -> { SwapProviderStateBuilder.buildContentSelectable( provider = provider, - toTokenInfo = state.toTokenInfo, - permissionState = state.permissionState, + state = state, pricesLowerBest = pricesLowerBest, selectionType = ProviderState.SelectionType.SELECT, - needApplyFCARestrictions = needApplyFCARestrictions, - isBestRate = bestRatedProviderId == provider.providerId && !state.priceImpact.shouldShowWarning(), - isNeedBestRateBadge = isNeedBestRateBadge, + additionalBadge = additionalBadge, onProviderClick = onProviderSelect, onApprovalSelectClick = onApprovalSelectClick, ) @@ -1321,7 +1317,7 @@ internal class StateBuilder( expressDataError = state.error, onProviderClick = onProviderSelect, selectionType = ProviderState.SelectionType.SELECT, - needApplyFCARestrictions = needApplyFCARestrictions, + additionalBadge = additionalBadge, ) } } diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapButtonTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapButtonTest.kt index 87b6790953..e608eb2f17 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapButtonTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapButtonTest.kt @@ -23,6 +23,7 @@ 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.ProviderState import com.tangem.feature.swap.ui.StateBuilder import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.Provider @@ -105,9 +106,7 @@ internal class StateBuilderSwapButtonTest { quoteModel = state, feeCryptoCurrencyStatus = null, swapProvider = buildProvider(ExchangeProviderType.CEX), - bestRatedProviderId = "p", - isNeedBestRateBadge = false, - needApplyFCARestrictions = false, + additionalBadge = ProviderState.AdditionalBadge.Empty, swapFee = null, feeError = null, ) @@ -132,9 +131,7 @@ internal class StateBuilderSwapButtonTest { quoteModel = state, feeCryptoCurrencyStatus = null, swapProvider = buildProvider(ExchangeProviderType.CEX), - bestRatedProviderId = "p", - isNeedBestRateBadge = false, - needApplyFCARestrictions = false, + additionalBadge = ProviderState.AdditionalBadge.Empty, swapFee = null, feeError = null, ) @@ -163,9 +160,7 @@ internal class StateBuilderSwapButtonTest { quoteModel = state, feeCryptoCurrencyStatus = null, swapProvider = buildProvider(ExchangeProviderType.CEX), - bestRatedProviderId = "p", - isNeedBestRateBadge = false, - needApplyFCARestrictions = false, + additionalBadge = ProviderState.AdditionalBadge.Empty, swapFee = null, feeError = null, ) @@ -189,9 +184,7 @@ internal class StateBuilderSwapButtonTest { quoteModel = state, feeCryptoCurrencyStatus = null, swapProvider = buildProvider(ExchangeProviderType.CEX), - bestRatedProviderId = "p", - isNeedBestRateBadge = false, - needApplyFCARestrictions = false, + additionalBadge = ProviderState.AdditionalBadge.Empty, swapFee = buildSwapFee(), feeError = null, ) @@ -219,9 +212,7 @@ internal class StateBuilderSwapButtonTest { quoteModel = state, feeCryptoCurrencyStatus = null, swapProvider = buildProvider(ExchangeProviderType.CEX), - bestRatedProviderId = "p", - isNeedBestRateBadge = false, - needApplyFCARestrictions = false, + additionalBadge = ProviderState.AdditionalBadge.Empty, swapFee = buildSwapFee(), feeError = null, ) diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderResolverTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderResolverTest.kt new file mode 100644 index 0000000000..b723105404 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderResolverTest.kt @@ -0,0 +1,523 @@ +package com.tangem.feature.swap.converters + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.SwapProvider +import com.tangem.feature.swap.domain.models.ui.PermissionDataState +import com.tangem.feature.swap.domain.models.ui.PriceImpact +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.models.states.ProviderState +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Tests for [SwapProviderResolver] — best-provider selection ([SwapProviderResolver.findBest]) and + * row-badge resolution ([SwapProviderResolver.resolveBadge]). + * + * Ranking metric: best provider == lowest `from/to` fiat ratio == highest `to` fiat output for the + * same `from` input. "Best DEX Rate" prefers the best-rated DEX/DEX_BRIDGE provider when the feature + * is on and any DEX is present. + */ +internal class SwapProviderResolverTest { + + private val cex1 = provider(id = "cex1", type = ExchangeProviderType.CEX) + private val cex2 = provider(id = "cex2", type = ExchangeProviderType.CEX) + private val dex1 = provider(id = "dex1", type = ExchangeProviderType.DEX) + private val dexBridge = provider(id = "dexBridge", type = ExchangeProviderType.DEX_BRIDGE) + + /** Mirror of the provider ids the resolver treats as FCA restricted. */ + private val fcaRestrictedProviderIds = setOf( + "changelly", + "changenow", + "okx-cross-chain", + "okx-on-chain", + "simpleswap", + ) + + // region findBest + + @Test + fun `GIVEN best dex rate on AND a DEX present WHEN findBest THEN best DEX is selected`() { + // CEX has the best overall rate (highest output), but a DEX is present. + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall + dex1 to quote(fromFiat = "100", toFiat = "110"), // best among DEX + dexBridge to quote(fromFiat = "100", toFiat = "105"), + ) + + val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true) + + assertThat(best).isEqualTo(dex1) + } + + @Test + fun `GIVEN best dex rate on AND no DEX present WHEN findBest THEN best CEX fallback`() { + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "110"), + cex2 to quote(fromFiat = "100", toFiat = "120"), // best CEX + ) + + val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true) + + assertThat(best).isEqualTo(cex2) + } + + @Test + fun `GIVEN best dex rate off WHEN findBest THEN best overall regardless of type`() { + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall (a CEX) + dex1 to quote(fromFiat = "100", toFiat = "110"), + ) + + val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = false) + + assertThat(best).isEqualTo(cex1) + } + + @Test + fun `GIVEN DEX_BRIDGE is the best DEX WHEN findBest with best dex rate on THEN DEX_BRIDGE selected`() { + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "130"), + dex1 to quote(fromFiat = "100", toFiat = "108"), + dexBridge to quote(fromFiat = "100", toFiat = "115"), // best among DEX-based + ) + + val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true) + + assertThat(best).isEqualTo(dexBridge) + } + + // endregion + + // region resolveBadge + + // --- Both CEX + DEX present, a DEX is the overall best → only "Best rate" (BestTrade) on that DEX. + + @Test + fun `GIVEN both types present AND DEX is overall best WHEN resolveBadge for that DEX THEN BestTrade`() { + val states = mapOf( + dex1 to quote(fromFiat = "100", toFiat = "120"), // best overall AND best DEX + cex1 to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = dex1, + needApplyFCARestrictions = false, + state = states.getValue(dex1), + isSwapBestDexRateEnabled = true, + ) + + // Overall best is the DEX → it gets the single "Best rate" badge, NOT "Best DEX rate". + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade) + } + + @Test + fun `GIVEN both types present AND DEX is overall best WHEN resolveBadge for the CEX THEN Empty`() { + val states = mapOf( + dex1 to quote(fromFiat = "100", toFiat = "120"), // best overall + cex1 to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = cex1, + needApplyFCARestrictions = false, + state = states.getValue(cex1), + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty) + } + + // --- Both CEX + DEX present, a CEX is the overall best → BestTrade on the CEX, BestDexRate on the best DEX. + + @Test + fun `GIVEN both types present AND CEX is overall best WHEN resolveBadge for the CEX THEN BestTrade`() { + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall (a CEX) + dex1 to quote(fromFiat = "100", toFiat = "110"), // best DEX + dexBridge to quote(fromFiat = "100", toFiat = "105"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = cex1, + needApplyFCARestrictions = false, + state = states.getValue(cex1), + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade) + } + + @Test + fun `GIVEN both types present AND CEX is overall best WHEN resolveBadge for the best DEX THEN BestDexRate`() { + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall (a CEX) + dex1 to quote(fromFiat = "100", toFiat = "110"), // best DEX + dexBridge to quote(fromFiat = "100", toFiat = "105"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = dex1, + needApplyFCARestrictions = false, + state = states.getValue(dex1), + isSwapBestDexRateEnabled = true, + ) + + // CEX wins overall, so the best DEX additionally gets the "Best DEX rate" badge. + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestDexRate) + } + + @Test + fun `GIVEN both types present AND CEX is overall best WHEN resolveBadge for a non-best DEX THEN Empty`() { + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall + dex1 to quote(fromFiat = "100", toFiat = "110"), // best DEX + dexBridge to quote(fromFiat = "100", toFiat = "105"), // not the best DEX + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = dexBridge, + needApplyFCARestrictions = false, + state = states.getValue(dexBridge), + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty) + } + + // --- DEX-only → only "Best rate" (BestTrade) on the best DEX; no separate "Best DEX rate". + + @Test + fun `GIVEN DEX-only providers WHEN resolveBadge for the best DEX THEN BestTrade`() { + val states = mapOf( + dex1 to quote(fromFiat = "100", toFiat = "120"), // best DEX + dexBridge to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = dex1, + needApplyFCARestrictions = false, + state = states.getValue(dex1), + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade) + } + + @Test + fun `GIVEN DEX-only providers WHEN resolveBadge for a non-best DEX THEN Empty`() { + val states = mapOf( + dex1 to quote(fromFiat = "100", toFiat = "120"), // best DEX + dexBridge to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = dexBridge, + needApplyFCARestrictions = false, + state = states.getValue(dexBridge), + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty) + } + + // --- CEX-only → only "Best rate" (BestTrade) on the best CEX (no DEX exists). + + @Test + fun `GIVEN CEX-only providers WHEN resolveBadge for the best CEX THEN BestTrade`() { + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "120"), // best CEX + cex2 to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = cex1, + needApplyFCARestrictions = false, + state = states.getValue(cex1), + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade) + } + + // --- Toggle off → only the overall best gets BestTrade; "Best DEX rate" is never produced. + + @Test + fun `GIVEN toggle off AND both types present with CEX best WHEN resolveBadge for the CEX THEN BestTrade`() { + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall + dex1 to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = cex1, + needApplyFCARestrictions = false, + state = states.getValue(cex1), + isSwapBestDexRateEnabled = false, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade) + } + + @Test + fun `GIVEN toggle off AND both types present with CEX best WHEN resolveBadge for the DEX THEN Empty`() { + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall + dex1 to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = dex1, + needApplyFCARestrictions = false, + state = states.getValue(dex1), + isSwapBestDexRateEnabled = false, + ) + + // Toggle off → no "Best DEX rate" badge even though a DEX is present and not the overall best. + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty) + } + + @Test + fun `GIVEN FCA restricted provider AND restrictions on WHEN resolveBadge THEN FCAWarningList`() { + val restricted = provider(id = "changelly", type = ExchangeProviderType.CEX, isRecommended = true) + val states = mapOf( + restricted to quote(fromFiat = "100", toFiat = "120"), + cex2 to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = restricted, + needApplyFCARestrictions = true, + state = states.getValue(restricted), + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList) + } + + @Test + fun `GIVEN restrictions on WHEN resolveBadge for each FCA restricted id THEN FCAWarningList`() { + // FCA badge must win regardless of rate — the restricted provider here is NOT the best rate. + fcaRestrictedProviderIds.forEach { restrictedId -> + val restricted = provider(id = restrictedId, type = ExchangeProviderType.CEX) + val states = mapOf( + restricted to quote(fromFiat = "100", toFiat = "110"), + cex2 to quote(fromFiat = "100", toFiat = "120"), // best, but not FCA restricted + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = restricted, + needApplyFCARestrictions = true, + state = states.getValue(restricted), + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList) + } + } + + @Test + fun `GIVEN FCA restricted provider but restrictions off WHEN resolveBadge THEN no FCA badge`() { + val restricted = provider(id = "changelly", type = ExchangeProviderType.CEX) + val states = mapOf( + restricted to quote(fromFiat = "100", toFiat = "120"), // best rated + cex2 to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = restricted, + needApplyFCARestrictions = false, + state = states.getValue(restricted), + isSwapBestDexRateEnabled = false, + ) + + // Restrictions are off → the restricted id is ignored and the normal best-rate badge wins. + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade) + } + + @Test + fun `GIVEN restrictions on for a non-restricted best provider WHEN resolveBadge THEN best rate suppressed`() { + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "120"), // best & not restricted + cex2 to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = cex1, + needApplyFCARestrictions = true, + state = states.getValue(cex1), + isSwapBestDexRateEnabled = true, + ) + + // FCA restrictions globally on suppress the best-rate badge even for non-restricted providers. + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty) + } + + @Test + fun `GIVEN permission required WHEN resolveBadge THEN PermissionRequired`() { + val states = mapOf( + cex1 to quote( + fromFiat = "100", + toFiat = "120", + permission = PermissionDataState.PermissionRequired(isResetApproval = false, spenderAddress = "0x"), + ), + cex2 to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = cex1, + needApplyFCARestrictions = false, + state = states.getValue(cex1), + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired) + } + + @Test + fun `GIVEN recommended provider WHEN resolveBadge THEN Recommended takes priority over best rate`() { + val recommended = provider(id = "cex1", type = ExchangeProviderType.CEX, isRecommended = true) + val states = mapOf( + recommended to quote(fromFiat = "100", toFiat = "120"), // also the best rate + cex2 to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = recommended, + needApplyFCARestrictions = false, + state = states.getValue(recommended), + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Recommended) + } + + @Test + fun `GIVEN only one considered provider WHEN resolveBadge THEN Empty`() { + val states = mapOf(cex1 to quote(fromFiat = "100", toFiat = "120")) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = cex1, + needApplyFCARestrictions = false, + state = states.getValue(cex1), + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty) + } + + @Test + fun `GIVEN provider is not the best rated WHEN resolveBadge THEN Empty`() { + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "120"), // best + cex2 to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = cex2, // not the best + needApplyFCARestrictions = false, + state = states.getValue(cex2), + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty) + } + + @Test + fun `GIVEN best rated provider but price impact warning WHEN resolveBadge THEN Empty`() { + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "120", priceImpactWarning = true), // best, but warning + cex2 to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = cex1, + needApplyFCARestrictions = false, + state = states.getValue(cex1), + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty) + } + + @Test + fun `GIVEN no quote state (error row) WHEN resolveBadge THEN Empty`() { + val states = mapOf( + cex1 to quote(fromFiat = "100", toFiat = "120"), + cex2 to quote(fromFiat = "100", toFiat = "110"), + ) + + val badge = SwapProviderResolver.resolveBadge( + states = states, + provider = cex1, + needApplyFCARestrictions = false, + state = null, + isSwapBestDexRateEnabled = true, + ) + + assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty) + } + + // endregion + + private fun provider( + id: String, + type: ExchangeProviderType, + isRecommended: Boolean = false, + ): SwapProvider = mockk { + every { providerId } returns id + every { this@mockk.type } returns type + every { this@mockk.isRecommended } returns isRecommended + } + + private fun quote( + fromFiat: String, + toFiat: String, + priceImpactWarning: Boolean = false, + permission: PermissionDataState = PermissionDataState.Empty, + ): SwapState.QuotesLoadedState { + val currency = mockk { + every { decimals } returns 6 + } + val swapStatus = mockk { + every { this@mockk.currency } returns currency + } + val fromInfo = mockk { + every { amountFiat } returns BigDecimal(fromFiat) + } + val toInfo = mockk { + every { amountFiat } returns BigDecimal(toFiat) + every { swapCurrencyStatus } returns swapStatus + } + return mockk { + every { fromTokenInfo } returns fromInfo + every { toTokenInfo } returns toInfo + every { permissionState } returns permission + every { priceImpact } returns mockk { every { shouldShowWarning() } returns priceImpactWarning } + } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderStateBuilderTest.kt index 1e93944a6c..832725438b 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/converters/SwapProviderStateBuilderTest.kt @@ -8,6 +8,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.PermissionDataState +import com.tangem.feature.swap.domain.models.ui.SwapState import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.feature.swap.models.states.PercentDifference @@ -20,6 +21,14 @@ import org.junit.jupiter.api.Test import java.math.BigDecimal import java.util.Locale +/** + * Tests for [SwapProviderStateBuilder]. + * + * Badge selection lives in [SwapProviderResolver]; here the badge is supplied as [additionalBadge] + * and the builder is expected to render it verbatim. These tests therefore focus on the builder's + * own responsibilities — subtitle formatting, percent-delta mapping, provider identity, and passing + * the badge through — not on badge-decision logic. + */ internal class SwapProviderStateBuilderTest { private var originalLocale: Locale = Locale.getDefault() @@ -40,24 +49,20 @@ internal class SwapProviderStateBuilderTest { // region buildContentClickable @Test - fun `GIVEN best rate AND no FCA AND no permission WHEN buildContentClickable THEN BestTrade badge`() { - val provider = provider(id = "1inch", isRecommended = false) + fun `GIVEN a badge WHEN buildContentClickable THEN it is rendered with a rate subtitle`() { + val provider = provider(id = "1inch") val from = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE) val to = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("3000")) val result = SwapProviderStateBuilder.buildContentClickable( provider = provider, - fromTokenInfo = from, - toTokenInfo = to, - permissionState = PermissionDataState.Empty, + state = quoteState(toTokenInfo = to, fromTokenInfo = from), selectionType = ProviderState.SelectionType.CLICK, - isBestRate = true, - isNeedBestRateBadge = true, - needApplyFCARestrictions = false, + additionalBadge = ProviderState.AdditionalBadge.BestDexRate, onProviderClick = onProviderClick, ) - assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestTrade) + assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestDexRate) assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Empty) assertThat(result.subtitle).isInstanceOf(TextReference.Str::class.java) val subtitle = result.subtitle as TextReference.Str @@ -65,106 +70,16 @@ internal class SwapProviderStateBuilderTest { assertThat(subtitle.value).contains("USDT") } - @Test - fun `GIVEN recommended provider WHEN buildContentClickable THEN Recommended badge`() { - val provider = provider(id = "any", isRecommended = true) - val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE) - - val result = SwapProviderStateBuilder.buildContentClickable( - provider = provider, - fromTokenInfo = info, - toTokenInfo = info, - permissionState = PermissionDataState.Empty, - selectionType = ProviderState.SelectionType.CLICK, - isBestRate = true, - isNeedBestRateBadge = true, - needApplyFCARestrictions = false, - onProviderClick = onProviderClick, - ) - - assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Recommended) - } - - @Test - fun `GIVEN permission required WHEN buildContentClickable THEN PermissionRequired badge`() { - val provider = provider(id = "any", isRecommended = false) - val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE) - - val result = SwapProviderStateBuilder.buildContentClickable( - provider = provider, - fromTokenInfo = info, - toTokenInfo = info, - permissionState = PermissionDataState.PermissionRequired( - isResetApproval = false, - spenderAddress = "0xspender", - ), - selectionType = ProviderState.SelectionType.CLICK, - isBestRate = true, - isNeedBestRateBadge = true, - needApplyFCARestrictions = false, - onProviderClick = onProviderClick, - ) - - assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired) - } - - @Test - fun `GIVEN FCA restricted provider WHEN buildContentClickable THEN FCAWarningList badge`() { - val provider = provider(id = "changelly", isRecommended = true) - val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE) - - val result = SwapProviderStateBuilder.buildContentClickable( - provider = provider, - fromTokenInfo = info, - toTokenInfo = info, - permissionState = PermissionDataState.PermissionRequired( - isResetApproval = false, - spenderAddress = "0xspender", - ), - selectionType = ProviderState.SelectionType.CLICK, - isBestRate = true, - isNeedBestRateBadge = true, - needApplyFCARestrictions = true, - onProviderClick = onProviderClick, - ) - - assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList) - } - - @Test - fun `GIVEN best rate badge disabled WHEN buildContentClickable THEN Empty badge`() { - val provider = provider(id = "any", isRecommended = false) - val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE) - - val result = SwapProviderStateBuilder.buildContentClickable( - provider = provider, - fromTokenInfo = info, - toTokenInfo = info, - permissionState = PermissionDataState.Empty, - selectionType = ProviderState.SelectionType.CLICK, - isBestRate = true, - isNeedBestRateBadge = false, - needApplyFCARestrictions = false, - onProviderClick = onProviderClick, - ) - - assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty) - } - @Test fun `GIVEN provider WHEN buildContentClickable THEN content carries provider identity`() { - val provider = provider(id = "1inch", isRecommended = false, name = "1inch", iconUrl = "https://x") + val provider = provider(id = "1inch", name = "1inch", iconUrl = "https://x") val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE) val result = SwapProviderStateBuilder.buildContentClickable( provider = provider, - fromTokenInfo = info, - toTokenInfo = info, - permissionState = PermissionDataState.Empty, + state = quoteState(toTokenInfo = info), selectionType = ProviderState.SelectionType.CLICK, - isBestRate = false, - isNeedBestRateBadge = false, - needApplyFCARestrictions = false, + additionalBadge = ProviderState.AdditionalBadge.Empty, onProviderClick = onProviderClick, ) @@ -182,16 +97,15 @@ internal class SwapProviderStateBuilderTest { @Test fun `GIVEN provider in pricesLowerBest WHEN buildContentSelectable THEN percentLowerThenBest is mapped`() { - val provider = provider(id = "1inch", isRecommended = false) + val provider = provider(id = "1inch") val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100")) val result = SwapProviderStateBuilder.buildContentSelectable( provider = provider, - toTokenInfo = info, - permissionState = PermissionDataState.Empty, + state = quoteState(toTokenInfo = info), pricesLowerBest = mapOf("1inch" to 0.5f), selectionType = ProviderState.SelectionType.SELECT, - needApplyFCARestrictions = false, + additionalBadge = ProviderState.AdditionalBadge.Empty, onProviderClick = onProviderClick, ) @@ -203,16 +117,15 @@ internal class SwapProviderStateBuilderTest { @Test fun `GIVEN provider not in pricesLowerBest WHEN buildContentSelectable THEN percentLowerThenBest is zero`() { - val provider = provider(id = "any", isRecommended = false) + val provider = provider(id = "any") val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100")) val result = SwapProviderStateBuilder.buildContentSelectable( provider = provider, - toTokenInfo = info, - permissionState = PermissionDataState.Empty, + state = quoteState(toTokenInfo = info), pricesLowerBest = emptyMap(), selectionType = ProviderState.SelectionType.SELECT, - needApplyFCARestrictions = false, + additionalBadge = ProviderState.AdditionalBadge.Empty, onProviderClick = onProviderClick, ) @@ -220,84 +133,22 @@ internal class SwapProviderStateBuilderTest { } @Test - fun `GIVEN best rate AND no FCA AND no permission WHEN buildContentSelectable THEN BestTrade badge`() { - val provider = provider(id = "any", isRecommended = false) + fun `GIVEN a badge WHEN buildContentSelectable THEN it is rendered`() { + val provider = provider(id = "any") val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100")) val result = SwapProviderStateBuilder.buildContentSelectable( provider = provider, - toTokenInfo = info, - permissionState = PermissionDataState.Empty, + state = quoteState(toTokenInfo = info), pricesLowerBest = emptyMap(), selectionType = ProviderState.SelectionType.SELECT, - needApplyFCARestrictions = false, - isBestRate = true, - isNeedBestRateBadge = true, + additionalBadge = ProviderState.AdditionalBadge.BestTrade, onProviderClick = onProviderClick, ) assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestTrade) - } - - @Test - fun `GIVEN isNeedBestRateBadge false WHEN buildContentSelectable THEN no BestTrade badge`() { - val provider = provider(id = "any", isRecommended = false) - val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100")) - - val result = SwapProviderStateBuilder.buildContentSelectable( - provider = provider, - toTokenInfo = info, - permissionState = PermissionDataState.Empty, - pricesLowerBest = emptyMap(), - selectionType = ProviderState.SelectionType.SELECT, - needApplyFCARestrictions = false, - isBestRate = true, - isNeedBestRateBadge = false, - onProviderClick = onProviderClick, - ) - - assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty) - } - - @Test - fun `GIVEN isBestRate false AND badge enabled WHEN buildContentSelectable THEN no BestTrade badge`() { - val provider = provider(id = "any", isRecommended = false) - val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100")) - - val result = SwapProviderStateBuilder.buildContentSelectable( - provider = provider, - toTokenInfo = info, - permissionState = PermissionDataState.Empty, - pricesLowerBest = emptyMap(), - selectionType = ProviderState.SelectionType.SELECT, - needApplyFCARestrictions = false, - isBestRate = false, - isNeedBestRateBadge = true, - onProviderClick = onProviderClick, - ) - - assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty) - } - - @Test - fun `GIVEN permission required WHEN buildContentSelectable THEN PermissionRequired badge`() { - val provider = provider(id = "any", isRecommended = false) - val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100")) - - val result = SwapProviderStateBuilder.buildContentSelectable( - provider = provider, - toTokenInfo = info, - permissionState = PermissionDataState.PermissionRequired( - isResetApproval = false, - spenderAddress = "0xspender", - ), - pricesLowerBest = emptyMap(), - selectionType = ProviderState.SelectionType.SELECT, - needApplyFCARestrictions = false, - onProviderClick = onProviderClick, - ) - - assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired) + assertThat(result.subtitle).isInstanceOf(TextReference.Str::class.java) + assertThat((result.subtitle as TextReference.Str).value).contains("USDT") } // endregion @@ -305,67 +156,23 @@ internal class SwapProviderStateBuilderTest { // region buildAvailableFrom @Test - fun `GIVEN alert text WHEN buildAvailableFrom THEN subtitle is the alert text`() { - val provider = provider(id = "any", isRecommended = false) + fun `GIVEN alert text WHEN buildAvailableFrom THEN subtitle is the alert text and badge is rendered`() { + val provider = provider(id = "any") val alert: TextReference = stringReference("min amount 0.01 ETH") val result = SwapProviderStateBuilder.buildAvailableFrom( provider = provider, alertText = alert, selectionType = ProviderState.SelectionType.SELECT, - needApplyFCARestrictions = false, + additionalBadge = ProviderState.AdditionalBadge.FCAWarningList, onProviderClick = onProviderClick, ) assertThat(result.subtitle).isEqualTo(alert) assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Empty) - } - - @Test - fun `GIVEN FCA restricted WHEN buildAvailableFrom THEN FCAWarningList badge`() { - val provider = provider(id = "okx-on-chain", isRecommended = true) - - val result = SwapProviderStateBuilder.buildAvailableFrom( - provider = provider, - alertText = TextReference.EMPTY, - selectionType = ProviderState.SelectionType.SELECT, - needApplyFCARestrictions = true, - onProviderClick = onProviderClick, - ) - assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList) } - @Test - fun `GIVEN recommended WHEN buildAvailableFrom THEN Recommended badge`() { - val provider = provider(id = "any", isRecommended = true) - - val result = SwapProviderStateBuilder.buildAvailableFrom( - provider = provider, - alertText = TextReference.EMPTY, - selectionType = ProviderState.SelectionType.SELECT, - needApplyFCARestrictions = false, - onProviderClick = onProviderClick, - ) - - assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Recommended) - } - - @Test - fun `GIVEN no flags WHEN buildAvailableFrom THEN Empty badge`() { - val provider = provider(id = "any", isRecommended = false) - - val result = SwapProviderStateBuilder.buildAvailableFrom( - provider = provider, - alertText = TextReference.EMPTY, - selectionType = ProviderState.SelectionType.SELECT, - needApplyFCARestrictions = false, - onProviderClick = onProviderClick, - ) - - assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty) - } - // endregion // region buildSelectableSubtitle @@ -386,7 +193,6 @@ internal class SwapProviderStateBuilderTest { private fun provider( id: String, - isRecommended: Boolean, name: String = "Provider", iconUrl: String = "https://icon", ): SwapProvider = mockk { @@ -394,7 +200,16 @@ internal class SwapProviderStateBuilderTest { every { this@mockk.name } returns name every { imageLarge } returns iconUrl every { type } returns ExchangeProviderType.DEX - every { this@mockk.isRecommended } returns isRecommended + } + + private fun quoteState( + toTokenInfo: TokenSwapInfo, + fromTokenInfo: TokenSwapInfo = toTokenInfo, + permissionState: PermissionDataState = PermissionDataState.Empty, + ): SwapState.QuotesLoadedState = mockk { + every { this@mockk.fromTokenInfo } returns fromTokenInfo + every { this@mockk.toTokenInfo } returns toTokenInfo + every { this@mockk.permissionState } returns permissionState } private fun tokenInfo(symbol: String, decimals: Int, amount: BigDecimal): TokenSwapInfo {