From f72c0f448e1866fc672ea4b9ca78b9ba68bf5eb3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Jun 2026 11:40:39 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../converters/AmountStateConverter.kt | 2 - .../ui/amountScreen/models/AmountState.kt | 2 - .../amountScreen/ui/AmountFieldContainer.kt | 35 ++-- .../converters/AmountStateConverterTest.kt | 62 ------- .../analytics/StakingAnalyticsEvent.kt | 13 ++ .../impl/presentation/model/StakingModel.kt | 34 +++- .../state/events/StakingAlertUM.kt | 5 + .../SetInitialDataStateTransformer.kt | 2 - .../AmountRequirementStateTransformer.kt | 52 +++--- .../model/StakingModelP2PSumLimitTest.kt | 131 +++++++++++++++ .../model/StakingModelTestBase.kt | 4 +- .../state/events/StakingAlertUMTest.kt | 31 ++++ .../AmountRequirementStateTransformerTest.kt | 152 ++++++++++++++++++ 13 files changed, 409 insertions(+), 116 deletions(-) delete mode 100644 common/ui/src/test/kotlin/com/tangem/common/ui/amountScreen/converters/AmountStateConverterTest.kt create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelP2PSumLimitTest.kt create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUMTest.kt create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt index 50543caa9d..a8d3ad4fe0 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt @@ -38,7 +38,6 @@ class AmountStateConverter( private val iconStateConverter: CryptoCurrencyToIconStateConverter, private val isBalanceHidden: Boolean, private val accountTitleUM: AccountTitleUM, - private val isMaxButtonVisible: Boolean = true, ) : Converter { private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) { @@ -73,7 +72,6 @@ class AmountStateConverter( amountTextField = amountFieldConverter.convert(value.value), isPrimaryButtonEnabled = false, appCurrency = appCurrency, - isMaxButtonVisible = isMaxButtonVisible, ) } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt index c7b3fa5da0..09e6c0d211 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt @@ -24,7 +24,6 @@ sealed class AmountState { * @param isEditingDisabled indicated whether amount is editable * @param reduceAmountBy reduces amount to be sent by specified value * @param isIgnoreReduce ignores reduce amount value - * @param isMaxButtonVisible indicates whether the "Max" button is shown */ data class Data( override val isPrimaryButtonEnabled: Boolean, @@ -38,7 +37,6 @@ sealed class AmountState { val isEditingDisabled: Boolean = false, val reduceAmountBy: BigDecimal = BigDecimal.ZERO, val isIgnoreReduce: Boolean = false, - val isMaxButtonVisible: Boolean = true, ) : AmountState() data object Empty : AmountState() { diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt index 6f8d27de62..12335b79ad 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt @@ -88,7 +88,6 @@ internal fun LazyListScope.amountFieldV2( @Composable private fun AmountInfo(amountUM: AmountState, onMaxAmountClick: () -> Unit, modifier: Modifier = Modifier) { val tokenIconState = (amountUM as? AmountState.Data)?.tokenIconState ?: CurrencyIconState.Loading - val isMaxButtonVisible = (amountUM as? AmountState.Data)?.isMaxButtonVisible != false Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -107,24 +106,22 @@ private fun AmountInfo(amountUM: AmountState, onMaxAmountClick: () -> Unit, modi amountUM = amountUM, modifier = Modifier.weight(1f), ) - if (isMaxButtonVisible) { - Text( - text = stringResourceSafe(R.string.send_max_amount), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.primary1, - modifier = Modifier - .padding(end = 16.dp) - .clip(RoundedCornerShape(16.dp)) - .background(TangemTheme.colors.button.secondary) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(), - onClick = onMaxAmountClick, - ) - .padding(horizontal = 12.dp, vertical = 4.dp) - .testTag(SendScreenTestTags.MAX_BUTTON), - ) - } + Text( + text = stringResourceSafe(R.string.send_max_amount), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding(end = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.button.secondary) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = onMaxAmountClick, + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + .testTag(SendScreenTestTags.MAX_BUTTON), + ) } } diff --git a/common/ui/src/test/kotlin/com/tangem/common/ui/amountScreen/converters/AmountStateConverterTest.kt b/common/ui/src/test/kotlin/com/tangem/common/ui/amountScreen/converters/AmountStateConverterTest.kt deleted file mode 100644 index b612d7f357..0000000000 --- a/common/ui/src/test/kotlin/com/tangem/common/ui/amountScreen/converters/AmountStateConverterTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.tangem.common.ui.amountScreen.converters - -import com.google.common.truth.Truth.assertThat -import com.tangem.common.ui.account.AccountTitleUM -import com.tangem.common.ui.amountScreen.AmountScreenClickIntents -import com.tangem.common.ui.amountScreen.models.AmountParameters -import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter -import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.extensions.stringReference -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import io.mockk.every -import io.mockk.mockk -import org.junit.jupiter.api.Test -import java.math.BigDecimal - -internal class AmountStateConverterTest { - - private val currency = mockk { - every { symbol } returns "ETH" - every { decimals } returns 18 - every { name } returns "Ethereum" - } - private val status = CryptoCurrencyStatus(currency = currency, value = mockk(relaxed = true)) - private val iconStateConverter = mockk { - every { convert(any()) } returns CurrencyIconState.Loading - } - private val clickIntents = mockk(relaxed = true) - private val accountTitleUM = mockk() - - private fun convert(isMaxButtonVisible: Boolean = true): AmountState = AmountStateConverter( - clickIntents = clickIntents, - appCurrency = AppCurrency.Default, - cryptoCurrencyStatus = status, - maxEnterAmount = EnterAmountBoundary( - amount = BigDecimal.ONE, - fiatAmount = BigDecimal.TEN, - fiatRate = BigDecimal.ONE, - ), - iconStateConverter = iconStateConverter, - isBalanceHidden = false, - accountTitleUM = accountTitleUM, - isMaxButtonVisible = isMaxButtonVisible, - ).convert(AmountParameters(title = stringReference("Wallet"), value = "")) - - @Test - fun `GIVEN no isMaxButtonVisible param WHEN convert THEN Data isMaxButtonVisible is true`() { - val result = convert() - - assertThat((result as AmountState.Data).isMaxButtonVisible).isTrue() - } - - @Test - fun `GIVEN isMaxButtonVisible false WHEN convert THEN Data isMaxButtonVisible is false`() { - val result = convert(isMaxButtonVisible = false) - - assertThat((result as AmountState.Data).isMaxButtonVisible).isFalse() - } -} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt index 65407e56ca..30060c605e 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt @@ -181,6 +181,19 @@ sealed class StakingAnalyticsEvent( AnalyticsParam.BLOCKCHAIN to blockchain, ), ) + + data class SumLimitError( + val token: String, + val blockchain: String, + val maxAmount: String, + ) : StakingAnalyticsEvent( + event = "Error - Sum Limit", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + AnalyticsParam.BLOCKCHAIN to blockchain, + AnalyticsParam.ERROR_MESSAGE to "Maximum amount: $maxAmount", + ), + ) } enum class StakeScreenSource { diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 8afe46564e..da5b4c29e8 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -97,6 +97,7 @@ import com.tangem.features.staking.impl.presentation.state.utils.withStubUnstake import com.tangem.lib.crypto.BlockchainUtils.isTon import com.tangem.utils.Provider import com.tangem.utils.coroutines.* +import com.tangem.utils.extensions.isPositive import com.tangem.utils.extensions.isSingleItem import com.tangem.utils.extensions.orZero import com.tangem.utils.logging.TangemLogger @@ -642,6 +643,31 @@ internal class StakingModel @Inject constructor( integration = integration, ), ) + checkSumLimitExceeded() + } + + private fun checkSumLimitExceeded() { + val maxLimit = (integration as? P2PEthPoolIntegration) + ?.enterArgs?.amountRequirement?.maximum + ?.takeIf { it.isPositive() } + ?: return + + val enteredAmount = (uiState.value.amountState as? AmountState.Data) + ?.amountTextField?.cryptoAmount?.value + ?: return + + if (enteredAmount > maxLimit) { + // Pass the max limit as a stable crypto-formatted value (e.g. "0.15 ETH"); the event + // builds the hardcoded English "Error Message" from it, so the model needs no resources. + val formattedMax = maxLimit.format { crypto(cryptoCurrencyStatus.currency) } + analyticsEventHandler.send( + StakingAnalyticsEvent.SumLimitError( + token = cryptoCurrencyStatus.currency.symbol, + blockchain = cryptoCurrencyStatus.currency.network.name, + maxAmount = formattedMax, + ), + ) + } } override fun onAmountPasteTriggerDismiss() { @@ -658,6 +684,7 @@ internal class StakingModel @Inject constructor( integration = integration, ), ) + checkSumLimitExceeded() } override fun onCurrencyChangeClick(isFiat: Boolean) { @@ -1099,7 +1126,12 @@ internal class StakingModel @Inject constructor( } override fun showPrimaryClickAlert() { - messageSender.send(StakingAlertUM.stakeMoreClickUnavailable(cryptoCurrencyStatus.currency)) + val message = if (integration is P2PEthPoolIntegration) { + StakingAlertUM.stakeMoreClickUnavailableNoTargets() + } else { + StakingAlertUM.stakeMoreClickUnavailable(cryptoCurrencyStatus.currency) + } + messageSender.send(message) } override fun onOpenLearnMoreAboutApproveClick() { diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt index acba4a78d1..a10e3cbaf0 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt @@ -54,6 +54,11 @@ internal object StakingAlertUM { ), ) + fun stakeMoreClickUnavailableNoTargets(): DialogMessage = DialogMessage( + title = null, + message = resourceReference(R.string.staking_no_validators_error_message), + ) + fun rewardsMinimumRequirementsError(cryptoCurrencyName: String, cryptoAmountValue: String): DialogMessage = DialogMessage( title = null, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index b6105d4f37..58203a7cab 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -19,7 +19,6 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.StakingBalanceEntry import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.staking.model.P2PEthPoolIntegration import com.tangem.domain.staking.model.Period import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.StakingTarget @@ -262,7 +261,6 @@ internal class SetInitialDataStateTransformer( walletTitle = stringReference(userWalletProvider().name), prefixText = resourceReference(R.string.common_from), ).convert(account), - isMaxButtonVisible = integration !is P2PEthPoolIntegration, ).convert( AmountParameters( title = stringReference(userWalletProvider().name), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt index 5421794d27..05a795e7cf 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt @@ -96,12 +96,18 @@ internal class AmountRequirementStateTransformer( return when (actionType) { is StakingActionCommonType.Enter -> { - val enterRequirements = integration.enterArgs?.amountRequirement - enterRequirements?.getError(amountDecimal, R.string.staking_amount_requirement_error) + integration.enterArgs?.amountRequirement?.getError( + amount = amountDecimal, + minErrorRes = R.string.staking_amount_requirement_error, + maxErrorRes = R.string.staking_max_amount_requirement_error, + ) } is StakingActionCommonType.Exit -> { - val exitRequirements = integration.exitArgs?.amountRequirement - exitRequirements?.getError(amountDecimal, R.string.staking_unstake_amount_requirement_error) + integration.exitArgs?.amountRequirement?.getError( + amount = amountDecimal, + minErrorRes = R.string.staking_unstake_amount_requirement_error, + maxErrorRes = R.string.staking_max_amount_requirement_error, + ) } else -> null } @@ -118,31 +124,25 @@ internal class AmountRequirementStateTransformer( return isEnterOrExit && isTron && !isIntegerOnly } - private fun StakingAmountRequirement.getError(amount: BigDecimal, @StringRes errorTextRes: Int): TextReference? { + private fun StakingAmountRequirement.getError( + amount: BigDecimal, + @StringRes minErrorRes: Int, + @StringRes maxErrorRes: Int, + ): TextReference? { + if (!isRequired) return null + val isExceedsMinRequirement = minimum?.compareTo(amount) == 1 - val isExceedsMaxRequirement = if (maximum?.isPositive() == true) { - maximum?.compareTo(amount) == -1 - } else { - maxAmount.amount?.compareTo(amount) == -1 + val effectiveMax = maximum?.takeIf { it.isPositive() } ?: maxAmount.amount + val isExceedsMaxRequirement = effectiveMax?.compareTo(amount) == -1 + + val (errorRes, boundary) = when { + isExceedsMinRequirement -> minErrorRes to minimum + isExceedsMaxRequirement -> maxErrorRes to effectiveMax + else -> return null } - val errorText = when { - isExceedsMinRequirement -> { - minimum.format { - crypto(cryptoCurrencyStatus.currency) - } - } - isExceedsMaxRequirement -> { - maximum.format { - crypto(cryptoCurrencyStatus.currency) - } - } - else -> "" - } - return resourceReference( - errorTextRes, - wrappedList(errorText), - ).takeIf { isRequired && (isExceedsMinRequirement || isExceedsMaxRequirement) } + val formatted = boundary.format { crypto(cryptoCurrencyStatus.currency) } + return resourceReference(errorRes, wrappedList(formatted)) } data class Data( diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelP2PSumLimitTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelP2PSumLimitTest.kt new file mode 100644 index 0000000000..eddadd08d8 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelP2PSumLimitTest.kt @@ -0,0 +1,131 @@ +package com.tangem.features.staking.impl.presentation.model + +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.domain.staking.analytics.StakingAnalyticsEvent +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault +import com.tangem.domain.staking.model.ethpool.VaultLimitInfo +import com.tangem.domain.tokens.model.Amount +import com.tangem.features.staking.impl.presentation.state.StakingStep +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Model-level tests for the P2P ETH pool staking integration: + * verifies that [StakingAnalyticsEvent.SumLimitError] is sent when the entered amount + * exceeds the vault's computed maximum (= limit − totalAssets). + * + * Fixture: + * vault address = "0xabc" totalAssets = 5 + * limit "0xabc" limit = 10 + * → maximum = 10 − 5 = 5.0 (scale=1, RoundingMode.FLOOR) + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelP2PSumLimitTest : StakingModelTestBase() { + + override val testIntegrationId: StakingIntegrationID = StakingIntegrationID.P2PEthPool + + private val vaultAddress = "0xabc" + private val testVault = P2PEthPoolVault( + vaultAddress = vaultAddress, + displayName = "Test Vault", + apy = BigDecimal("4.5"), + baseApy = BigDecimal("4.0"), + capacity = BigDecimal("1000"), + totalAssets = BigDecimal("5"), + feePercent = BigDecimal("0.1"), + isPrivate = false, + isGenesis = false, + isSmoothingPool = false, + isErc20 = false, + tokenName = null, + tokenSymbol = null, + createdAt = 0L, + ) + + // maximum = 10 − 5 = 5.0 (FLOOR scale=1) + private val testLimits = mapOf( + vaultAddress to VaultLimitInfo(limit = BigDecimal("10"), coefficient = null), + ) + + @BeforeEach + fun setUpP2P() { + coEvery { p2pEthPoolRepository.getVaultsSync() } returns listOf(testVault) + coEvery { p2pEthPoolRepository.getVaultLimitsSyncOrNull() } returns testLimits + } + + /** + * Helper: returns a [MutableStateFlow] whose value has [amountState] set to an + * [AmountState.Data] mock with the given [cryptoAmountValue]. + * The flow is also wired to [stateController.uiState]. + */ + private fun stubUiStateWithCryptoAmount(cryptoAmountValue: BigDecimal): MutableStateFlow { + val amountData = mockk(relaxed = true) { + every { amountTextField } returns mockk(relaxed = true) { + every { cryptoAmount } returns Amount( + currencySymbol = "ETH", + value = cryptoAmountValue, + decimals = 18, + ) + } + } + val uiStateFlow = MutableStateFlow( + mockk(relaxed = true) { + every { currentStep } returns StakingStep.InitialInfo + every { amountState } returns amountData + }, + ) + every { stateController.uiState } returns uiStateFlow + return uiStateFlow + } + + // ----- Test A --------------------------------------------------------------- + + @Test + fun `GIVEN P2P vault max=5 WHEN amount 6 entered THEN SumLimitError analytics sent`() = runTest { + stubUiStateWithCryptoAmount(BigDecimal("6")) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountValueChange("6") + + verify { + analyticsEventHandler.send( + match { it is StakingAnalyticsEvent.SumLimitError } + ) + } + + model.onDestroy() + } + + // ----- Test B --------------------------------------------------------------- + + @Test + fun `GIVEN P2P vault max=5 WHEN amount 4 entered THEN SumLimitError analytics NOT sent`() = runTest { + stubUiStateWithCryptoAmount(BigDecimal("4")) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountValueChange("4") + + verify(exactly = 0) { + analyticsEventHandler.send( + match { it is StakingAnalyticsEvent.SumLimitError } + ) + } + + model.onDestroy() + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt index c0d02afd92..92689585c7 100644 --- a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt @@ -57,8 +57,8 @@ internal abstract class StakingModelTestBase { protected val testUserWalletId = UserWalletId("1234567890ABCDEF") protected val testCryptoCurrency: CryptoCurrency = mockk(relaxed = true) - private val testIntegrationId = StakingIntegrationID.StakeKit.Coin.Solana - private val testParams = StakingComponent.Params( + protected open val testIntegrationId: StakingIntegrationID = StakingIntegrationID.StakeKit.Coin.Solana + private val testParams get() = StakingComponent.Params( userWalletId = testUserWalletId, cryptoCurrency = testCryptoCurrency, integrationId = testIntegrationId, diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUMTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUMTest.kt new file mode 100644 index 0000000000..df060f6a6e --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUMTest.kt @@ -0,0 +1,31 @@ +package com.tangem.features.staking.impl.presentation.state.events + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.staking.impl.R +import io.mockk.mockk +import org.junit.jupiter.api.Test + +internal class StakingAlertUMTest { + + @Test + fun `noTargets dialog uses no validators string and has no title`() { + val message = StakingAlertUM.stakeMoreClickUnavailableNoTargets() + + assertThat(message.title).isNull() + assertThat((message.message as TextReference.Res).id) + .isEqualTo(R.string.staking_no_validators_error_message) + } + + @Test + fun `default stake more dialog uses stake more unavailability string`() { + val currency: CryptoCurrency = mockk(relaxed = true) + + val message = StakingAlertUM.stakeMoreClickUnavailable(currency) + + assertThat(message.title).isNull() + assertThat((message.message as TextReference.Res).id) + .isEqualTo(R.string.staking_stake_more_button_unavailability_reason) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt new file mode 100644 index 0000000000..b470fd8de2 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt @@ -0,0 +1,152 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.amount + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.amountScreen.models.AmountFieldModel +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.staking.model.StakingIntegration +import com.tangem.domain.staking.model.common.StakingActionArgs +import com.tangem.domain.staking.model.common.StakingAmountRequirement +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.tokens.model.Amount +import com.tangem.features.staking.impl.R +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class AmountRequirementStateTransformerTest { + + private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) + + private fun amountState(enteredCrypto: BigDecimal): AmountState.Data = AmountState.Data( + isPrimaryButtonEnabled = true, + accountTitleUM = mockk(relaxed = true), + availableBalanceCrypto = mockk(relaxed = true), + availableBalanceFiat = mockk(relaxed = true), + tokenName = mockk(relaxed = true), + tokenIconState = mockk(relaxed = true), + amountTextField = AmountFieldModel( + value = enteredCrypto.toPlainString(), + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + cryptoAmount = Amount(currencySymbol = "ETH", value = enteredCrypto, decimals = 18), + fiatAmount = Amount(currencySymbol = "USD", value = BigDecimal.ZERO, decimals = 2), + isFiatValue = false, + fiatValue = "0", + isFiatUnavailable = false, + isValuePasted = false, + onValuePastedTriggerDismiss = {}, + isError = false, + isWarning = false, + error = stringReference(""), + ), + appCurrency = mockk(relaxed = true), + ) + + private fun enterIntegrationWith(minimum: BigDecimal?, maximum: BigDecimal?): StakingIntegration = mockk { + every { enterArgs } returns StakingActionArgs( + amountRequirement = StakingAmountRequirement( + isRequired = true, + minimum = minimum, + maximum = maximum, + ), + isPartialAmountDisabled = false, + ) + } + + private fun exitIntegrationWith(minimum: BigDecimal?, maximum: BigDecimal?): StakingIntegration = mockk { + every { exitArgs } returns StakingActionArgs( + amountRequirement = StakingAmountRequirement( + isRequired = true, + minimum = minimum, + maximum = maximum, + ), + isPartialAmountDisabled = false, + ) + } + + @Test + fun `WHEN amount exceeds positive maximum THEN max amount error string is used`() { + val transformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxAmount = EnterAmountBoundary(amount = BigDecimal("100"), fiatAmount = null, fiatRate = null), + integration = enterIntegrationWith(minimum = BigDecimal("0.01"), maximum = BigDecimal("0.15")), + actionType = StakingActionCommonType.Enter(skipEnterAmount = false), + ) + + val result = transformer.transform(amountState(BigDecimal("0.2"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_max_amount_requirement_error) + } + + @Test + fun `WHEN amount below minimum THEN min amount error string is used`() { + val transformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxAmount = EnterAmountBoundary(amount = BigDecimal("100"), fiatAmount = null, fiatRate = null), + integration = enterIntegrationWith(minimum = BigDecimal("0.1"), maximum = null), + actionType = StakingActionCommonType.Enter(skipEnterAmount = false), + ) + + val result = transformer.transform(amountState(BigDecimal("0.05"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_amount_requirement_error) + } + + @Test + fun `WHEN Exit action and amount below exit minimum THEN unstake min error string is used`() { + val transformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxAmount = EnterAmountBoundary(amount = BigDecimal("100"), fiatAmount = null, fiatRate = null), + integration = exitIntegrationWith(minimum = BigDecimal("0.1"), maximum = null), + actionType = StakingActionCommonType.Exit(partiallyUnstakeDisabled = false), + ) + + val result = transformer.transform(amountState(BigDecimal("0.05"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_unstake_amount_requirement_error) + } + + @Test + fun `WHEN Enter action and maximum is null and amount exceeds balance cap THEN max error string is used`() { + val transformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxAmount = EnterAmountBoundary(amount = BigDecimal("0.5"), fiatAmount = null, fiatRate = null), + integration = enterIntegrationWith(minimum = BigDecimal("0.01"), maximum = null), + actionType = StakingActionCommonType.Enter(skipEnterAmount = false), + ) + + val result = transformer.transform(amountState(BigDecimal("0.6"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_max_amount_requirement_error) + } + + @Test + fun `WHEN Exit action and amount exceeds staked balance THEN max amount error string is used`() { + val transformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxAmount = EnterAmountBoundary(amount = BigDecimal("0.5"), fiatAmount = null, fiatRate = null), + integration = exitIntegrationWith(minimum = BigDecimal("0.01"), maximum = null), + actionType = StakingActionCommonType.Exit(partiallyUnstakeDisabled = false), + ) + + val result = transformer.transform(amountState(BigDecimal("0.6"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_max_amount_requirement_error) + } +} \ No newline at end of file