Updated on 2026-08-14
This commit is contained in:
commit
06a915a0f4
2186 changed files with 101417 additions and 36593 deletions
|
|
@ -1,5 +0,0 @@
|
|||
package com.tangem.features.yield.supply.api
|
||||
|
||||
interface YieldSupplyFeatureToggles {
|
||||
val isYieldPromoEnabled: Boolean
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.tangem.features.yield.supply.impl
|
||||
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class DefaultYieldSupplyFeatureToggles @Inject constructor(
|
||||
featureTogglesManager: FeatureTogglesManager,
|
||||
) : YieldSupplyFeatureToggles {
|
||||
|
||||
override val isYieldPromoEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15154_YIELD_PROMO_ENABLED,
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
|
|
@ -20,6 +21,7 @@ import com.tangem.core.ui.components.SecondaryButton
|
|||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.test.YieldSupplyTestTags
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.features.marketing.api.MarketingBannerComponent
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent
|
||||
|
|
@ -112,7 +114,8 @@ internal class DefaultYieldSupplyActiveComponent @AssistedInject constructor(
|
|||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 16.dp,
|
||||
),
|
||||
)
|
||||
.testTag(YieldSupplyTestTags.STOP_EARNING_BUTTON),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ import com.tangem.domain.yield.supply.promo.usecase.GetYieldBoostStatusUseCase
|
|||
import com.tangem.domain.yield.supply.usecase.*
|
||||
import com.tangem.features.marketing.api.MarketingBannerRequest
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
|
||||
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
import com.tangem.core.res.R as CoreResR
|
||||
|
|
@ -77,7 +76,6 @@ internal class YieldSupplyActiveModel @Inject constructor(
|
|||
private val appRouter: AppRouter,
|
||||
private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase,
|
||||
private val getYieldBoostStatusUseCase: GetYieldBoostStatusUseCase,
|
||||
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
|
||||
private val boostStoryPreloader: YieldBoostStoryPreloader,
|
||||
) : Model(), YieldSupplyStopEarningComponent.ModelCallback,
|
||||
YieldSupplyApproveComponent.ModelCallback {
|
||||
|
|
@ -260,7 +258,6 @@ internal class YieldSupplyActiveModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun loadBoostBlock() {
|
||||
if (!yieldSupplyFeatureToggles.isYieldPromoEnabled) return
|
||||
modelScope.launch(dispatchers.io) {
|
||||
val token = cryptoCurrency as? CryptoCurrency.Token ?: return@launch
|
||||
val cached = getYieldBoostStatusUseCase(userWalletId).getOrNull()
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
package com.tangem.features.yield.supply.impl.di
|
||||
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
|
||||
import com.tangem.features.yield.supply.impl.DefaultYieldSupplyFeatureToggles
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object YieldSupplyFeatureModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideYieldSupplyFeatureToggles(featureTogglesManager: FeatureTogglesManager): YieldSupplyFeatureToggles {
|
||||
return DefaultYieldSupplyFeatureToggles(featureTogglesManager)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,6 @@ import com.tangem.domain.tokens.model.details.NavigationAction
|
|||
import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
|
||||
import com.tangem.features.yield.supply.api.entry.YieldSupplyEntryRoute
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -31,7 +30,6 @@ internal class YieldSupplyEntryModel @Inject constructor(
|
|||
private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase,
|
||||
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||
private val isYieldBoostPromoEnabledForTokenUseCase: IsYieldBoostPromoEnabledForTokenUseCase,
|
||||
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<YieldSupplyEntryComponent.Params>()
|
||||
|
|
@ -96,8 +94,7 @@ internal class YieldSupplyEntryModel @Inject constructor(
|
|||
return if (isActiveYield) {
|
||||
YieldSupplyEntryRoute.Active(cryptoCurrency = token)
|
||||
} else {
|
||||
val isPromoEnabled = yieldSupplyFeatureToggles.isYieldPromoEnabled &&
|
||||
isYieldBoostPromoEnabledForTokenUseCase(userWalletId, token).getOrElse { false }
|
||||
val isPromoEnabled = isYieldBoostPromoEnabledForTokenUseCase(userWalletId, token).getOrElse { false }
|
||||
YieldSupplyEntryRoute.Promo(
|
||||
cryptoCurrency = token,
|
||||
apy = params.apy,
|
||||
|
|
|
|||
|
|
@ -3,14 +3,15 @@ package com.tangem.features.yield.supply.impl.main
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.common.ui.earn.EarnBlock
|
||||
import com.tangem.common.ui.earn.EarnBlockUM
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
||||
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyComponent
|
||||
import com.tangem.features.yield.supply.impl.main.model.YieldSupplyModel
|
||||
import com.tangem.features.yield.supply.impl.main.ui.YieldSupplyBlockContentLegacy
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -24,12 +25,16 @@ internal class DefaultYieldSupplyComponent @AssistedInject constructor(
|
|||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
if (LocalRedesignEnabled.current) {
|
||||
val earnBlockUM by model.uiState.collectAsStateWithLifecycle()
|
||||
earnBlockUM?.let { EarnBlock(state = it, modifier = modifier) }
|
||||
} else {
|
||||
val yieldSupplyUM by model.uiStateLegacy.collectAsStateWithLifecycle()
|
||||
YieldSupplyBlockContentLegacy(yieldSupplyUM = yieldSupplyUM, modifier = modifier)
|
||||
val earnBlockUM by model.uiState.collectAsStateWithLifecycle()
|
||||
earnBlockUM?.let { blockUM ->
|
||||
val tag = if (blockUM is EarnBlockUM.Content &&
|
||||
blockUM.backgroundUM is EarnBlockUM.BackgroundUM.AccentSoft
|
||||
) {
|
||||
TokenDetailsScreenTestTags.YIELD_SUPPLY_AVAILABLE_BLOCK
|
||||
} else {
|
||||
TokenDetailsScreenTestTags.YIELD_SUPPLY_BLOCK
|
||||
}
|
||||
EarnBlock(state = blockUM, modifier = modifier.testTag(tag))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import com.tangem.domain.yield.supply.promo.usecase.GetBoostedApyUseCase
|
|||
import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.*
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyComponent
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
|
||||
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader
|
||||
|
|
@ -68,7 +67,6 @@ internal class YieldSupplyModel @Inject constructor(
|
|||
private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase,
|
||||
private val isYieldBoostPromoEnabledForTokenUseCase: IsYieldBoostPromoEnabledForTokenUseCase,
|
||||
private val getBoostedApyUseCase: GetBoostedApyUseCase,
|
||||
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
|
||||
private val boostStoryPreloader: YieldBoostStoryPreloader,
|
||||
) : Model(), YieldSupplyClickIntents {
|
||||
|
||||
|
|
@ -160,9 +158,8 @@ internal class YieldSupplyModel @Inject constructor(
|
|||
val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return
|
||||
yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken)
|
||||
.onRight { tokenStatus ->
|
||||
val isPromoEnabled = yieldSupplyFeatureToggles.isYieldPromoEnabled &&
|
||||
isYieldBoostPromoEnabledForTokenUseCase(params.userWalletId, cryptoCurrencyToken)
|
||||
.getOrElse { false }
|
||||
val isPromoEnabled = isYieldBoostPromoEnabledForTokenUseCase(params.userWalletId, cryptoCurrencyToken)
|
||||
.getOrElse { false }
|
||||
val boostedApy = if (isPromoEnabled) getBoostedApyUseCase(tokenStatus.apy) else null
|
||||
uiStateLegacy.update(
|
||||
YieldSupplyTokenStatusSuccessTransformer(
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ internal class YieldSupplyToEarnBlockConverter : Converter<YieldSupplyUM, EarnBl
|
|||
text = resourceReference(CoreResR.string.yield_module_transaction_enter),
|
||||
style = EarnBlockUM.TitleUM.Style.Large,
|
||||
tone = EarnBlockUM.TitleUM.Tone.Primary,
|
||||
iconUM = buildTitleIcon(value),
|
||||
),
|
||||
subtitleUM = EarnBlockUM.SubtitleUM.Text(
|
||||
text = combinedReference(
|
||||
|
|
@ -89,18 +88,19 @@ internal class YieldSupplyToEarnBlockConverter : Converter<YieldSupplyUM, EarnBl
|
|||
style = EarnBlockUM.SubtitleUM.Style.Small,
|
||||
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
|
||||
),
|
||||
trailingUM = EarnBlockUM.TrailingUM.Button(
|
||||
text = resourceReference(CoreResR.string.details_title),
|
||||
style = EarnBlockUM.TrailingUM.Button.Style.Secondary,
|
||||
),
|
||||
trailingUM = buildTrailing(value),
|
||||
onClick = value.onClick,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildTitleIcon(value: YieldSupplyUM.Content): EarnBlockUM.TitleUM.IconUM? = when {
|
||||
value.shouldShowWarningIcon -> EarnBlockUM.TitleUM.IconUM(tone = EarnBlockUM.TitleUM.IconTone.Warning)
|
||||
value.shouldShowInfoIcon -> EarnBlockUM.TitleUM.IconUM(tone = EarnBlockUM.TitleUM.IconTone.Info)
|
||||
else -> null
|
||||
private fun buildTrailing(value: YieldSupplyUM.Content): EarnBlockUM.TrailingUM = when {
|
||||
value.shouldShowWarningIcon -> EarnBlockUM.TrailingUM.StatusIcon(
|
||||
tone = EarnBlockUM.TrailingUM.StatusIcon.Tone.Warning,
|
||||
)
|
||||
value.shouldShowInfoIcon -> EarnBlockUM.TrailingUM.StatusIcon(
|
||||
tone = EarnBlockUM.TrailingUM.StatusIcon.Tone.Info,
|
||||
)
|
||||
else -> EarnBlockUM.TrailingUM.Chevron
|
||||
}
|
||||
|
||||
private fun buildProcessingEnter(): EarnBlockUM.Content {
|
||||
|
|
@ -109,17 +109,18 @@ internal class YieldSupplyToEarnBlockConverter : Converter<YieldSupplyUM, EarnBl
|
|||
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
|
||||
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_yield_40),
|
||||
titleUM = EarnBlockUM.TitleUM(
|
||||
text = resourceReference(CoreResR.string.common_yield_mode),
|
||||
text = combinedReference(
|
||||
resourceReference(CoreResR.string.common_yield_mode),
|
||||
stringReference(StringsSigns.WHITE_SPACE),
|
||||
resourceReference(CoreResR.string.common_enabling),
|
||||
),
|
||||
style = EarnBlockUM.TitleUM.Style.Large,
|
||||
tone = EarnBlockUM.TitleUM.Tone.Primary,
|
||||
),
|
||||
subtitleUM = EarnBlockUM.SubtitleUM.Text(
|
||||
text = resourceReference(CoreResR.string.common_enabling),
|
||||
style = EarnBlockUM.SubtitleUM.Style.Small,
|
||||
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
|
||||
loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Positive),
|
||||
subtitleUM = null,
|
||||
trailingUM = EarnBlockUM.TrailingUM.Loader(
|
||||
tone = EarnBlockUM.TrailingUM.Loader.LoaderTone.Positive,
|
||||
),
|
||||
trailingUM = null,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -127,19 +128,23 @@ internal class YieldSupplyToEarnBlockConverter : Converter<YieldSupplyUM, EarnBl
|
|||
return EarnBlockUM.Content(
|
||||
type = EarnBlockUM.Type.YieldSupply,
|
||||
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
|
||||
iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_yield_disabling_40),
|
||||
iconUM = EarnBlockUM.IconUM.Glowing(
|
||||
iconRes = CoreUiR.drawable.ic_yield_40,
|
||||
tone = EarnBlockUM.IconUM.Tone.Warning,
|
||||
),
|
||||
titleUM = EarnBlockUM.TitleUM(
|
||||
text = resourceReference(CoreResR.string.common_yield_mode),
|
||||
text = combinedReference(
|
||||
resourceReference(CoreResR.string.common_yield_mode),
|
||||
stringReference(StringsSigns.WHITE_SPACE),
|
||||
resourceReference(CoreResR.string.common_disabling),
|
||||
),
|
||||
style = EarnBlockUM.TitleUM.Style.Large,
|
||||
tone = EarnBlockUM.TitleUM.Tone.Primary,
|
||||
),
|
||||
subtitleUM = EarnBlockUM.SubtitleUM.Text(
|
||||
text = resourceReference(CoreResR.string.common_disabling),
|
||||
style = EarnBlockUM.SubtitleUM.Style.Small,
|
||||
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
|
||||
loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Muted),
|
||||
subtitleUM = null,
|
||||
trailingUM = EarnBlockUM.TrailingUM.Loader(
|
||||
tone = EarnBlockUM.TrailingUM.Loader.LoaderTone.Muted,
|
||||
),
|
||||
trailingUM = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,453 +0,0 @@
|
|||
package com.tangem.features.yield.supply.impl.main.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.PrimaryButton
|
||||
import com.tangem.core.ui.components.SecondaryButton
|
||||
import com.tangem.core.ui.components.SpacerW12
|
||||
import com.tangem.core.ui.components.SpacerW8
|
||||
import com.tangem.core.ui.components.TextShimmer
|
||||
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM
|
||||
import com.tangem.utils.StringsSigns
|
||||
|
||||
@Composable
|
||||
internal fun YieldSupplyBlockContentLegacy(yieldSupplyUM: YieldSupplyUM, modifier: Modifier = Modifier) {
|
||||
AnimatedContent(
|
||||
targetState = yieldSupplyUM,
|
||||
contentKey = { it::class },
|
||||
) { supplyUM ->
|
||||
when (supplyUM) {
|
||||
is YieldSupplyUM.Available -> SupplyAvailable(supplyUM, modifier)
|
||||
YieldSupplyUM.Loading -> SupplyLoading(modifier)
|
||||
is YieldSupplyUM.Content -> SupplyContent(supplyUM, modifier)
|
||||
YieldSupplyUM.Processing.Enter -> SupplyProcessing(
|
||||
resourceReference(R.string.yield_module_token_details_earn_notification_processing),
|
||||
modifier,
|
||||
)
|
||||
YieldSupplyUM.Processing.Exit -> SupplyProcessing(
|
||||
resourceReference(R.string.yield_module_stop_earning),
|
||||
modifier,
|
||||
)
|
||||
YieldSupplyUM.Unavailable,
|
||||
YieldSupplyUM.Initial,
|
||||
-> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupplyAvailable(supplyUM: YieldSupplyUM.Available, modifier: Modifier = Modifier) {
|
||||
if (supplyUM.isBoostAvailable) {
|
||||
SupplyAvailableBoosted(supplyUM = supplyUM, modifier = modifier)
|
||||
} else {
|
||||
SupplyInfo(
|
||||
title = resourceReference(
|
||||
R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title,
|
||||
),
|
||||
subtitle = resourceReference(R.string.yield_module_token_details_earn_notification_description),
|
||||
rewardsApy = supplyUM.apyText,
|
||||
iconTint = TangemTheme.colors.icon.accent,
|
||||
modifier = modifier,
|
||||
button = {
|
||||
SecondaryButton(
|
||||
text = stringResourceSafe(R.string.common_learn_more),
|
||||
onClick = supplyUM.onClick,
|
||||
size = TangemButtonSize.WideAction,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupplyAvailableBoosted(supplyUM: YieldSupplyUM.Available, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(TangemTheme.colors.background.primary)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_analytics_up_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.accent,
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors.icon.accent.copy(alpha = 0.1f), CircleShape)
|
||||
.padding(6.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = supplyUM.title.resolveReference(),
|
||||
style = TangemTheme.typography.subtitle1,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
Text(
|
||||
text = supplyUM.apyText.resolveAnnotatedReference(),
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
color = TangemTheme.colors.text.accent,
|
||||
)
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.yield_apy_boost_banner_subtitle),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
SecondaryButton(
|
||||
text = stringResourceSafe(R.string.common_learn_more),
|
||||
onClick = supplyUM.onLearnMoreClick,
|
||||
size = TangemButtonSize.WideAction,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
PrimaryButton(
|
||||
text = stringResourceSafe(R.string.common_activate),
|
||||
onClick = supplyUM.onClick,
|
||||
size = TangemButtonSize.WideAction,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun SupplyContent(supplyUM: YieldSupplyUM.Content, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(TangemTheme.colors.background.primary)
|
||||
.clickable(onClick = supplyUM.onClick)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.img_aave_22),
|
||||
modifier = Modifier.size(36.dp),
|
||||
contentDescription = null,
|
||||
)
|
||||
SpacerW12()
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1.0f, fill = false),
|
||||
text = supplyUM.title.resolveReference(),
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
AnimatedVisibility(supplyUM.rewardsApy != TextReference.EMPTY) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = StringsSigns.DOT,
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
Text(
|
||||
text = supplyUM.rewardsApy.resolveReference(),
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
maxLines = 1,
|
||||
color = TangemTheme.colors.text.accent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = supplyUM.subtitle.resolveReference(),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
}
|
||||
SpacerW8()
|
||||
AnimatedContent(
|
||||
targetState = supplyUM,
|
||||
) { currentState ->
|
||||
when {
|
||||
currentState.shouldShowWarningIcon -> Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_alert_triangle_20),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.attention,
|
||||
)
|
||||
currentState.shouldShowInfoIcon -> Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_alert_circle_red_20),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.accent,
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_right_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupplyProcessing(text: TextReference, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(TangemTheme.colors.background.primary)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.img_aave_22),
|
||||
modifier = Modifier.size(36.dp),
|
||||
contentDescription = null,
|
||||
)
|
||||
SpacerW12()
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(
|
||||
R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title,
|
||||
),
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
Text(
|
||||
text = text.resolveReference(),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
}
|
||||
SpacerW8()
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = TangemTheme.colors.icon.accent,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupplyLoading(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(TangemTheme.colors.background.primary)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_analytics_up_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.inactive,
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors.icon.inactive.copy(alpha = 0.1f), CircleShape)
|
||||
.padding(6.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
TextShimmer(
|
||||
text = stringResourceSafe(R.string.yield_module_unavailable_title),
|
||||
style = TangemTheme.typography.button,
|
||||
)
|
||||
TextShimmer(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResourceSafe(R.string.yield_module_unavailable_subtitle),
|
||||
style = TangemTheme.typography.caption2,
|
||||
)
|
||||
TextShimmer(
|
||||
modifier = Modifier.width(100.dp),
|
||||
text = stringResourceSafe(R.string.yield_module_unavailable_subtitle),
|
||||
style = TangemTheme.typography.caption2,
|
||||
)
|
||||
}
|
||||
}
|
||||
SecondaryButton(
|
||||
text = stringResourceSafe(R.string.common_learn_more),
|
||||
onClick = { },
|
||||
showProgress = true,
|
||||
enabled = false,
|
||||
size = TangemButtonSize.WideAction,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupplyInfo(
|
||||
title: TextReference,
|
||||
subtitle: TextReference,
|
||||
rewardsApy: TextReference?,
|
||||
iconTint: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
button: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(TangemTheme.colors.background.primary)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_analytics_up_24),
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier
|
||||
.background(iconTint.copy(alpha = 0.1f), CircleShape)
|
||||
.padding(6.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title.resolveReference(),
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
if (rewardsApy != null) {
|
||||
Text(
|
||||
text = StringsSigns.DOT,
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
Text(
|
||||
text = rewardsApy.resolveReference(),
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
maxLines = 1,
|
||||
color = TangemTheme.colors.text.accent,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = subtitle.resolveReference(),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
button?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun YieldSupplyBlockContent_Preview(@PreviewParameter(PreviewProvider::class) params: YieldSupplyUM) {
|
||||
TangemThemePreview {
|
||||
YieldSupplyBlockContentLegacy(yieldSupplyUM = params, modifier = Modifier)
|
||||
}
|
||||
}
|
||||
|
||||
private class PreviewProvider : PreviewParameterProvider<YieldSupplyUM> {
|
||||
override val values: Sequence<YieldSupplyUM>
|
||||
get() = sequenceOf(
|
||||
YieldSupplyUM.Available(
|
||||
title = TextReference.Res(
|
||||
R.string.yield_module_token_details_earn_notification_title,
|
||||
wrappedList("5.1"),
|
||||
),
|
||||
apy = "5.1",
|
||||
apyText = stringReference("5.1 % APY"),
|
||||
onClick = {},
|
||||
onLearnMoreClick = {},
|
||||
),
|
||||
YieldSupplyUM.Available(
|
||||
title = TextReference.Res(R.string.yield_apy_boost_banner_title),
|
||||
apy = "5.1",
|
||||
apyText = stringReference("APY 5.1% x3 → 15.3%"),
|
||||
onClick = {},
|
||||
onLearnMoreClick = {},
|
||||
isBoostAvailable = true,
|
||||
),
|
||||
YieldSupplyUM.Content(
|
||||
title = stringReference("Aave l"),
|
||||
subtitle = stringReference("Interest accrues automatically"),
|
||||
rewardsApy = stringReference("APY 5.1%"),
|
||||
onClick = {},
|
||||
apy = "5.1",
|
||||
shouldShowWarningIcon = false,
|
||||
shouldShowInfoIcon = true,
|
||||
),
|
||||
YieldSupplyUM.Content(
|
||||
title = stringReference("Aave lending is active "),
|
||||
subtitle = stringReference("Interest accrues automatically"),
|
||||
rewardsApy = stringReference("APY 5.1%"),
|
||||
onClick = {},
|
||||
apy = "5.1",
|
||||
shouldShowWarningIcon = true,
|
||||
shouldShowInfoIcon = false,
|
||||
),
|
||||
YieldSupplyUM.Loading,
|
||||
YieldSupplyUM.Processing.Enter,
|
||||
YieldSupplyUM.Processing.Exit,
|
||||
YieldSupplyUM.Unavailable,
|
||||
)
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
|
|
@ -37,6 +38,7 @@ import com.tangem.core.ui.components.label.entity.LabelUM
|
|||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.test.YieldSupplyTestTags
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
import com.tangem.features.yield.supply.impl.promo.entity.YieldSupplyPromoUM
|
||||
import com.tangem.features.yield.supply.impl.promo.model.YieldSupplyPromoClickIntents
|
||||
|
|
@ -75,7 +77,8 @@ internal fun YieldSupplyPromoContent(
|
|||
end = 16.dp,
|
||||
bottom = 8.dp,
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
.fillMaxWidth()
|
||||
.testTag(YieldSupplyTestTags.PROMO_CONTINUE_BUTTON),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
|
@ -27,6 +28,7 @@ import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWi
|
|||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.test.YieldSupplyTestTags
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
|
|
@ -78,6 +80,7 @@ internal class YieldSupplyApproveComponent(
|
|||
val modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.testTag(YieldSupplyTestTags.APPROVE_CONFIRM_BUTTON)
|
||||
|
||||
if (state.isHoldToConfirmEnabled) {
|
||||
HoldToConfirmButton(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
|
@ -24,6 +25,7 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIcon
|
|||
import com.tangem.core.ui.decompose.ComposableModularContentComponent
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.test.YieldSupplyTestTags
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
|
|
@ -110,6 +112,7 @@ internal class YieldSupplyStartEarningComponent(
|
|||
val modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.testTag(YieldSupplyTestTags.START_EARNING_BUTTON)
|
||||
|
||||
if (state.isHoldToConfirmEnabled) {
|
||||
HoldToConfirmButton(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
|
@ -28,6 +29,7 @@ import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWi
|
|||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.test.YieldSupplyTestTags
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
|
|
@ -82,6 +84,7 @@ internal class YieldSupplyStopEarningComponent(
|
|||
val modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.testTag(YieldSupplyTestTags.STOP_EARNING_CONFIRM_BUTTON)
|
||||
|
||||
if (state.isHoldToConfirmEnabled) {
|
||||
HoldToConfirmButton(
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyGetProtocolBalanceUseCa
|
|||
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
|
||||
import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
|
|
@ -54,7 +53,6 @@ class YieldSupplyActiveModelBoostBlockTest {
|
|||
private val appRouter: AppRouter = mockk(relaxed = true)
|
||||
private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase = mockk(relaxed = true)
|
||||
private val getYieldBoostStatusUseCase: GetYieldBoostStatusUseCase = mockk(relaxed = true)
|
||||
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles = mockk(relaxed = true)
|
||||
private val boostStoryPreloader: YieldBoostStoryPreloader = mockk(relaxed = true)
|
||||
private val analyticsHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
|
||||
|
|
@ -83,7 +81,6 @@ class YieldSupplyActiveModelBoostBlockTest {
|
|||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns true
|
||||
every { getUserWalletUseCase.invoke(userWalletId) } returns userWallet.right()
|
||||
every { singleAccountStatusListSupplier.invoke(userWalletId) } returns emptyFlow()
|
||||
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right()
|
||||
|
|
@ -108,7 +105,6 @@ class YieldSupplyActiveModelBoostBlockTest {
|
|||
appRouter = appRouter,
|
||||
yieldSupplyGetDustMinAmountUseCase = yieldSupplyGetDustMinAmountUseCase,
|
||||
getYieldBoostStatusUseCase = getYieldBoostStatusUseCase,
|
||||
yieldSupplyFeatureToggles = yieldSupplyFeatureToggles,
|
||||
boostStoryPreloader = boostStoryPreloader,
|
||||
)
|
||||
|
||||
|
|
@ -147,16 +143,6 @@ class YieldSupplyActiveModelBoostBlockTest {
|
|||
coVerify(exactly = 1) { getYieldBoostStatusUseCase(userWalletId, true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN promo toggle disabled WHEN model created THEN does not query boost status`() = runTest {
|
||||
every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns false
|
||||
|
||||
val model = createModel()
|
||||
|
||||
assertThat(model.uiState.value.boostText).isNull()
|
||||
coVerify(exactly = 0) { getYieldBoostStatusUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CONTRACT_ADDRESS = "0xCONTRACT"
|
||||
const val NETWORK_ID = "ethereum"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
package com.tangem.features.yield.supply.impl.active.model.transformers
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.network.NetworkAddress
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee
|
||||
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
import com.tangem.features.yield.supply.impl.active.entity.YieldSupplyActiveContentUM
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class YieldSupplyActiveFeeContentTransformerTest {
|
||||
|
||||
private val analyticsHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
private val token = createToken()
|
||||
private val appCurrency = AppCurrency.Default
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(analyticsHandler)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee below max WHEN transform THEN not high fee and computed fee texts`() {
|
||||
// Arrange — fee 1, maxToken 2, maxFiat 4, fiatRate 1
|
||||
val transformer = createTransformer(feeValue = BigDecimal("1"), tokenMaxFee = BigDecimal("2"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert — currentFee is the token fiat fee (feeValue * fiatRate); feeDescription holds the 4 args in order
|
||||
val expectedFiatFee = fiatText(BigDecimal("1").multiply(BigDecimal("1")))
|
||||
assertThat(result.isHighFee).isFalse()
|
||||
assertThat(result.currentFee).isEqualTo(stringReference(expectedFiatFee))
|
||||
assertThat(result.feeDescription).isEqualTo(
|
||||
resourceReference(
|
||||
id = R.string.yield_module_fee_policy_sheet_fee_note,
|
||||
formatArgs = wrappedList(
|
||||
stringReference(expectedFiatFee),
|
||||
stringReference(cryptoText(BigDecimal("1"))),
|
||||
stringReference(fiatText(BigDecimal("4"))),
|
||||
stringReference(cryptoText(BigDecimal("2"))),
|
||||
),
|
||||
),
|
||||
)
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee above max WHEN transform THEN high fee and analytics carries token and blockchain`() {
|
||||
// Arrange
|
||||
val transformer = createTransformer(feeValue = BigDecimal("3"), tokenMaxFee = BigDecimal("2"))
|
||||
val eventSlot = slot<AnalyticsEvent>()
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert
|
||||
assertThat(result.isHighFee).isTrue()
|
||||
verify(exactly = 1) { analyticsHandler.send(capture(eventSlot)) }
|
||||
val event = eventSlot.captured as YieldSupplyAnalytics.NoticeHighNetworkFee
|
||||
assertThat(event.token).isEqualTo("TTK")
|
||||
assertThat(event.blockchain).isEqualTo("Ethereum")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee equal to max WHEN transform THEN not high fee`() {
|
||||
// Arrange — boundary: comparison is strictly greater-than
|
||||
val transformer = createTransformer(feeValue = BigDecimal("2"), tokenMaxFee = BigDecimal("2"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert
|
||||
assertThat(result.isHighFee).isFalse()
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN missing fiat rate WHEN transform THEN current fee is the placeholder and high fee resolved by crypto`() {
|
||||
// Arrange — null fiat rate: fiat fee text falls back to the placeholder, high-fee logic unaffected
|
||||
val transformer = createTransformer(
|
||||
feeValue = BigDecimal("3"),
|
||||
tokenMaxFee = BigDecimal("2"),
|
||||
fiatRate = null,
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert — placeholder differs from a populated fiat value, proving the null branch was taken
|
||||
assertThat(result.currentFee).isEqualTo(stringReference(fiatText(null)))
|
||||
assertThat(result.isHighFee).isTrue()
|
||||
verify(exactly = 1) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
private fun cryptoText(value: BigDecimal): String = value.format { crypto(token) }
|
||||
|
||||
private fun fiatText(value: BigDecimal?): String = value.format { fiat(appCurrency.code, appCurrency.symbol) }
|
||||
|
||||
private fun createTransformer(
|
||||
feeValue: BigDecimal,
|
||||
tokenMaxFee: BigDecimal,
|
||||
fiatRate: BigDecimal? = BigDecimal("1"),
|
||||
): YieldSupplyActiveFeeContentTransformer = YieldSupplyActiveFeeContentTransformer(
|
||||
cryptoCurrencyStatus = status(fiatRate = fiatRate),
|
||||
appCurrency = appCurrency,
|
||||
feeValue = feeValue,
|
||||
maxNetworkFee = YieldSupplyMaxFee(
|
||||
nativeMaxFee = BigDecimal("0.01"),
|
||||
tokenMaxFee = tokenMaxFee,
|
||||
fiatMaxFee = BigDecimal("4"),
|
||||
),
|
||||
analyticsHandler = analyticsHandler,
|
||||
)
|
||||
|
||||
private fun status(fiatRate: BigDecimal?): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = token,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = BigDecimal.ZERO,
|
||||
fiatAmount = BigDecimal.ZERO,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
value = "0x0000000000000000000000000000000000000000",
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun emptyContent(): YieldSupplyActiveContentUM = YieldSupplyActiveContentUM(
|
||||
totalEarnings = stringReference(""),
|
||||
availableBalance = null,
|
||||
providerTitle = stringReference(""),
|
||||
subtitle = stringReference(""),
|
||||
subtitleLink = stringReference(""),
|
||||
notifications = persistentListOf(),
|
||||
minAmount = null,
|
||||
currentFee = null,
|
||||
feeDescription = null,
|
||||
minFeeDescription = null,
|
||||
)
|
||||
|
||||
private fun createToken(): CryptoCurrency.Token {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
val network = Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network,
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
package com.tangem.features.yield.supply.impl.active.model.transformers
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.ui.components.notifications.NotificationConfig
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.network.NetworkAddress
|
||||
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
|
||||
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
import com.tangem.features.yield.supply.impl.active.entity.YieldSupplyActiveContentUM
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class YieldSupplyActiveMinAmountTransformerTest {
|
||||
|
||||
private val analyticsHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
private val token = createToken()
|
||||
private val appCurrency = AppCurrency.Default
|
||||
private var approveClicked = false
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(analyticsHandler)
|
||||
approveClicked = false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN spending not allowed and nothing un-supplied WHEN transform THEN approval notification and min amount texts`() {
|
||||
// Arrange
|
||||
val status = status(amount = BigDecimal("5"), isAllowedToSpend = false, effectiveProtocolBalance = BigDecimal("5"))
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert — minAmount uses the fiat value (minAmount * fiatRate); minFeeDescription carries [fiat, crypto] in order
|
||||
val expectedMinFiat = fiatText(MIN_AMOUNT.multiply(BigDecimal("1")))
|
||||
val expectedMinCrypto = cryptoText(MIN_AMOUNT)
|
||||
assertThat(result.notifications).hasSize(1)
|
||||
assertThat(result.notifications.first()).isInstanceOf(NotificationUM.Error::class.java)
|
||||
assertThat(result.minAmount).isEqualTo(stringReference(expectedMinFiat))
|
||||
assertThat(result.minFeeDescription).isEqualTo(
|
||||
resourceReference(
|
||||
id = R.string.yield_module_fee_policy_sheet_min_amount_note,
|
||||
formatArgs = wrappedList(expectedMinFiat, expectedMinCrypto),
|
||||
),
|
||||
)
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN spending allowed and un-supplied above dust WHEN transform THEN not-supplied notification with amount and analytics`() {
|
||||
// Arrange — un-supplied = amount(10) - protocolBalance(1) = 9
|
||||
val status = status(
|
||||
amount = BigDecimal("10"),
|
||||
isAllowedToSpend = true,
|
||||
effectiveProtocolBalance = BigDecimal("1"),
|
||||
fiatRate = BigDecimal("1"),
|
||||
)
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
val eventSlot = slot<AnalyticsEvent>()
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert
|
||||
assertThat(result.notifications).hasSize(1)
|
||||
val notification = result.notifications.first() as NotificationUM.Info.YieldSupplyNotAllAmountSupplied
|
||||
assertThat(notification.symbol).isEqualTo(TOKEN_SYMBOL)
|
||||
assertThat(notification.formattedAmount).isEqualTo(notSuppliedText(BigDecimal("9")))
|
||||
verify(exactly = 1) { analyticsHandler.send(capture(eventSlot)) }
|
||||
val event = eventSlot.captured as YieldSupplyAnalytics.NoticeAmountNotDeposited
|
||||
assertThat(event.token).isEqualTo(TOKEN_SYMBOL)
|
||||
assertThat(event.blockchain).isEqualTo("Ethereum")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN spending allowed and fully supplied WHEN transform THEN no notifications`() {
|
||||
// Arrange
|
||||
val status = status(amount = BigDecimal("5"), isAllowedToSpend = true, effectiveProtocolBalance = BigDecimal("5"))
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert
|
||||
assertThat(result.notifications).isEmpty()
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN un-supplied amount below dust threshold WHEN transform THEN no not-supplied notification`() {
|
||||
// Arrange — un-supplied = 1 (fiat), dust threshold = 5 → below threshold
|
||||
val status = status(
|
||||
amount = BigDecimal("10"),
|
||||
isAllowedToSpend = true,
|
||||
effectiveProtocolBalance = BigDecimal("9"),
|
||||
fiatRate = BigDecimal("1"),
|
||||
)
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("5"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert
|
||||
assertThat(result.notifications).isEmpty()
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN un-supplied fiat equals dust threshold WHEN transform THEN not-supplied notification shown`() {
|
||||
// Arrange — boundary: shouldShowNotSuppliedNotification uses >=, so equality must show the notification
|
||||
val status = status(
|
||||
amount = BigDecimal("10"),
|
||||
isAllowedToSpend = true,
|
||||
effectiveProtocolBalance = BigDecimal("5"),
|
||||
fiatRate = BigDecimal("1"),
|
||||
)
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("5"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert — un-supplied fiat = (10-5)*1 = 5 == dust 5
|
||||
assertThat(result.notifications).hasSize(1)
|
||||
assertThat(result.notifications.first())
|
||||
.isInstanceOf(NotificationUM.Info.YieldSupplyNotAllAmountSupplied::class.java)
|
||||
verify(exactly = 1) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN supply inactive WHEN transform THEN no not-supplied notification even if balance differs`() {
|
||||
// Arrange — isActive=false short-circuits notSupplied calculation
|
||||
val status = status(
|
||||
amount = BigDecimal("10"),
|
||||
isAllowedToSpend = true,
|
||||
isActive = false,
|
||||
effectiveProtocolBalance = BigDecimal("1"),
|
||||
fiatRate = BigDecimal("1"),
|
||||
)
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert
|
||||
assertThat(result.notifications).isEmpty()
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN missing fiat rate WHEN transform THEN min amount is the placeholder and no not-supplied notification`() {
|
||||
// Arrange — null fiat rate: fiat min amount cannot be computed, not-supplied calc is skipped
|
||||
val status = status(
|
||||
amount = BigDecimal("10"),
|
||||
isAllowedToSpend = true,
|
||||
isActive = false,
|
||||
effectiveProtocolBalance = BigDecimal("1"),
|
||||
fiatRate = null,
|
||||
)
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert — minAmount falls back to the null-rate placeholder
|
||||
assertThat(result.minAmount).isEqualTo(stringReference(fiatText(null)))
|
||||
assertThat(result.notifications).isEmpty()
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN approval needed and un-supplied above dust WHEN transform THEN both notifications in order`() {
|
||||
// Arrange
|
||||
val status = status(
|
||||
amount = BigDecimal("10"),
|
||||
isAllowedToSpend = false,
|
||||
effectiveProtocolBalance = BigDecimal("1"),
|
||||
fiatRate = BigDecimal("1"),
|
||||
)
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert — approval first, then not-supplied (listOfNotNull order)
|
||||
assertThat(result.notifications).hasSize(2)
|
||||
assertThat(result.notifications[0]).isInstanceOf(NotificationUM.Error::class.java)
|
||||
assertThat(result.notifications[1])
|
||||
.isInstanceOf(NotificationUM.Info.YieldSupplyNotAllAmountSupplied::class.java)
|
||||
verify(exactly = 1) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN approval notification WHEN its button clicked THEN onApprove fires`() {
|
||||
// Arrange
|
||||
val status = status(amount = BigDecimal("5"), isAllowedToSpend = false, effectiveProtocolBalance = BigDecimal("5"))
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
val button = (result.notifications.first() as NotificationUM.Error)
|
||||
.config.buttonsState as NotificationConfig.ButtonsState.PrimaryButtonConfig
|
||||
button.onClick()
|
||||
|
||||
// Assert
|
||||
assertThat(approveClicked).isTrue()
|
||||
}
|
||||
|
||||
private fun cryptoText(value: BigDecimal): String = value.format { crypto(token) }
|
||||
|
||||
private fun fiatText(value: BigDecimal?): String = value.format { fiat(appCurrency.code, appCurrency.symbol) }
|
||||
|
||||
private fun notSuppliedText(value: BigDecimal): String = value.format { crypto(symbol = "", decimals = token.decimals) }
|
||||
|
||||
private fun createTransformer(
|
||||
status: CryptoCurrencyStatus,
|
||||
dustMinAmount: BigDecimal,
|
||||
): YieldSupplyActiveMinAmountTransformer = YieldSupplyActiveMinAmountTransformer(
|
||||
cryptoCurrencyStatus = status,
|
||||
appCurrency = appCurrency,
|
||||
minAmount = MIN_AMOUNT,
|
||||
dustMinAmount = dustMinAmount,
|
||||
analyticsHandler = analyticsHandler,
|
||||
onApprove = { approveClicked = true },
|
||||
)
|
||||
|
||||
private fun status(
|
||||
amount: BigDecimal,
|
||||
isAllowedToSpend: Boolean,
|
||||
isActive: Boolean = true,
|
||||
effectiveProtocolBalance: BigDecimal? = null,
|
||||
fiatRate: BigDecimal? = BigDecimal("1"),
|
||||
): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = token,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = amount,
|
||||
fiatAmount = BigDecimal.ZERO,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = YieldSupplyStatus(
|
||||
isActive = isActive,
|
||||
isInitialized = true,
|
||||
isAllowedToSpend = isAllowedToSpend,
|
||||
effectiveProtocolBalance = effectiveProtocolBalance,
|
||||
),
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
value = "0x0000000000000000000000000000000000000000",
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun emptyContent(): YieldSupplyActiveContentUM = YieldSupplyActiveContentUM(
|
||||
totalEarnings = stringReference(""),
|
||||
availableBalance = null,
|
||||
providerTitle = stringReference(""),
|
||||
subtitle = stringReference(""),
|
||||
subtitleLink = stringReference(""),
|
||||
notifications = persistentListOf(),
|
||||
minAmount = null,
|
||||
currentFee = null,
|
||||
feeDescription = null,
|
||||
minFeeDescription = null,
|
||||
)
|
||||
|
||||
private fun createToken(): CryptoCurrency.Token {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
val network = Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network,
|
||||
name = "TEST_TOKEN",
|
||||
symbol = TOKEN_SYMBOL,
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TOKEN_SYMBOL = "TTK"
|
||||
val MIN_AMOUNT: BigDecimal = BigDecimal("2")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
package com.tangem.features.yield.supply.impl.chart.model
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetChartUseCase
|
||||
import com.tangem.features.yield.supply.impl.chart.DefaultYieldSupplyChartComponent
|
||||
import com.tangem.features.yield.supply.impl.chart.entity.YieldSupplyChartUM
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class YieldSupplyChartModelTest {
|
||||
|
||||
private val getChartUseCase: YieldSupplyGetChartUseCase = mockk()
|
||||
private val callback: DefaultYieldSupplyChartComponent.ModelCallback = mockk(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(getChartUseCase, callback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN chart data with values above one WHEN model created THEN Data state with integer percent format`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
coEvery { getChartUseCase(any()) } returns chartData(y = listOf(2.0, 5.0, 10.0)).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
|
||||
// Assert
|
||||
val state = model.uiState.value
|
||||
assertThat(state).isInstanceOf(YieldSupplyChartUM.Data::class.java)
|
||||
val data = state as YieldSupplyChartUM.Data
|
||||
assertThat(data.chartData.percentFormat).isEqualTo("%.0f")
|
||||
assertThat(data.monthLables).hasSize(MONTH_LABELS_COUNT)
|
||||
verify(exactly = 1) { callback.onStartLoading() }
|
||||
verify(exactly = 1) { callback.onSuccessLoad() }
|
||||
verify(exactly = 0) { callback.onLoadFail() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN chart data with values below one WHEN model created THEN Data state with one-decimal percent format`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
coEvery { getChartUseCase(any()) } returns chartData(y = listOf(0.2, 0.5, 0.9)).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
|
||||
// Assert
|
||||
val data = model.uiState.value as YieldSupplyChartUM.Data
|
||||
assertThat(data.chartData.percentFormat).isEqualTo("%.1f")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN empty chart data WHEN model created THEN Error state and load fail callback`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getChartUseCase(any()) } returns chartData(y = emptyList()).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value).isInstanceOf(YieldSupplyChartUM.Error::class.java)
|
||||
verify(exactly = 1) { callback.onStartLoading() }
|
||||
verify(exactly = 1) { callback.onLoadFail() }
|
||||
verify(exactly = 0) { callback.onSuccessLoad() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN use case fails WHEN model created THEN Error state and load fail callback`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getChartUseCase(any()) } returns IllegalStateException("boom").left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value).isInstanceOf(YieldSupplyChartUM.Error::class.java)
|
||||
verify(exactly = 1) { callback.onLoadFail() }
|
||||
verify(exactly = 0) { callback.onSuccessLoad() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN error state WHEN retry invoked AND data available THEN recovers to Data state`() = runTest {
|
||||
// Arrange — first call fails, retry succeeds
|
||||
coEvery { getChartUseCase(any()) } returnsMany listOf(
|
||||
IllegalStateException("boom").left(),
|
||||
chartData(y = listOf(2.0, 5.0)).right(),
|
||||
)
|
||||
val model = createModel()
|
||||
val error = model.uiState.value as YieldSupplyChartUM.Error
|
||||
|
||||
// Act
|
||||
error.onRetry()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value).isInstanceOf(YieldSupplyChartUM.Data::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no callback WHEN model created with data THEN Data state without crash`() = runTest {
|
||||
// Arrange — Params.callback is optional; model must tolerate its absence
|
||||
coEvery { getChartUseCase(any()) } returns chartData(y = listOf(2.0, 5.0)).right()
|
||||
|
||||
// Act
|
||||
val model = createModel(callback = null)
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value).isInstanceOf(YieldSupplyChartUM.Data::class.java)
|
||||
}
|
||||
|
||||
private fun createModel(
|
||||
callback: DefaultYieldSupplyChartComponent.ModelCallback? = this.callback,
|
||||
): YieldSupplyChartModel = YieldSupplyChartModel(
|
||||
paramsContainer = MutableParamsContainer(
|
||||
DefaultYieldSupplyChartComponent.Params(cryptoCurrency = createToken(), callback = callback),
|
||||
),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
yieldSupplyGetChartUseCase = getChartUseCase,
|
||||
)
|
||||
|
||||
private fun chartData(y: List<Double>): YieldSupplyMarketChartData =
|
||||
YieldSupplyMarketChartData(y = y, x = y.indices.map { it.toDouble() }, avr = 1.0)
|
||||
|
||||
private fun createToken(): CryptoCurrency.Token {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
val network = Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network,
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MONTH_LABELS_COUNT = 5
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
package com.tangem.features.yield.supply.impl.entry.model
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.none
|
||||
import arrow.core.right
|
||||
import arrow.core.some
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Route
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.network.NetworkAddress
|
||||
import com.tangem.domain.account.models.AccountStatusList
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
|
||||
import com.tangem.domain.tokens.model.details.NavigationAction
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus
|
||||
import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent
|
||||
import com.tangem.features.yield.supply.api.entry.YieldSupplyEntryRoute
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class YieldSupplyEntryModelTest {
|
||||
|
||||
private val router: Router = mockk(relaxed = true)
|
||||
private val enterStatusUseCase: YieldSupplyEnterStatusUseCase = mockk()
|
||||
private val accountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
|
||||
private val isPromoEnabledUseCase: IsYieldBoostPromoEnabledForTokenUseCase = mockk()
|
||||
|
||||
private val accountStatusList: AccountStatusList = mockk()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(
|
||||
router, enterStatusUseCase, accountStatusListSupplier,
|
||||
isPromoEnabledUseCase,
|
||||
)
|
||||
mockkObject(CryptoCurrencyStatusOperations)
|
||||
coEvery { accountStatusListSupplier.getSyncOrNull(USER_WALLET_ID) } returns accountStatusList
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkObject(CryptoCurrencyStatusOperations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN currency status not found WHEN created THEN pops without navigating`() = runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(none())
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { router.pop(any()) }
|
||||
verify(exactly = 0) { router.replaceCurrent(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN currency is not a token WHEN created THEN pops without navigating`() = runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(status(isActive = false).some())
|
||||
|
||||
// Act
|
||||
createModel(currency = coin())
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { router.pop(any()) }
|
||||
verify(exactly = 0) { router.replaceCurrent(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN pending enter status and active yield WHEN created THEN navigates to currency details active`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(status(isActive = true).some())
|
||||
coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns pendingEnter().right()
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
val route = captureReplacedRoute()
|
||||
assertThat(route).isInstanceOf(AppRoute.CurrencyDetails::class.java)
|
||||
assertThat((route as AppRoute.CurrencyDetails).navigationAction)
|
||||
.isEqualTo(NavigationAction.YieldSupply(isActive = true))
|
||||
assertThat(route.userWalletId).isEqualTo(USER_WALLET_ID)
|
||||
assertThat(route.currency).isEqualTo(token())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN pending enter status and inactive yield WHEN created THEN currency details with inactive flag`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(status(isActive = false).some())
|
||||
coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns pendingEnter().right()
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
val route = captureReplacedRoute()
|
||||
assertThat((route as AppRoute.CurrencyDetails).navigationAction)
|
||||
.isEqualTo(NavigationAction.YieldSupply(isActive = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no pending status and active yield WHEN created THEN navigates to Active route`() = runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(status(isActive = true).some())
|
||||
coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns null.right()
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
val route = captureReplacedRoute()
|
||||
assertThat(route).isInstanceOf(YieldSupplyEntryRoute.Active::class.java)
|
||||
assertThat((route as YieldSupplyEntryRoute.Active).cryptoCurrency).isEqualTo(token())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN enter status use case fails WHEN created THEN coerced to no pending and routes to Active`() = runTest {
|
||||
// Arrange — a Left is coerced to null by getOrNull, so it must NOT route to CurrencyDetails
|
||||
stubStatusLookup(status(isActive = true).some())
|
||||
coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns Throwable("boom").left()
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
assertThat(captureReplacedRoute()).isInstanceOf(YieldSupplyEntryRoute.Active::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no pending status and inactive yield with promo enabled WHEN created THEN Promo route promo-enabled`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(status(isActive = false).some())
|
||||
coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns null.right()
|
||||
coEvery { isPromoEnabledUseCase(USER_WALLET_ID, any()) } returns true.right()
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
val route = captureReplacedRoute()
|
||||
assertThat(route).isInstanceOf(YieldSupplyEntryRoute.Promo::class.java)
|
||||
assertThat((route as YieldSupplyEntryRoute.Promo).isPromoEnabled).isTrue()
|
||||
assertThat(route.apy).isEqualTo("5.0")
|
||||
assertThat(route.cryptoCurrency).isEqualTo(token())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN promo use case returns false WHEN created THEN Promo route with promo disabled`() = runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(status(isActive = false).some())
|
||||
coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns null.right()
|
||||
coEvery { isPromoEnabledUseCase(USER_WALLET_ID, any()) } returns false.right()
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
assertThat((captureReplacedRoute() as YieldSupplyEntryRoute.Promo).isPromoEnabled).isFalse()
|
||||
}
|
||||
|
||||
private fun captureReplacedRoute(): Route {
|
||||
val slot = slot<Route>()
|
||||
verify { router.replaceCurrent(capture(slot), any()) }
|
||||
return slot.captured
|
||||
}
|
||||
|
||||
private fun stubStatusLookup(result: arrow.core.Option<CryptoCurrencyStatus>) {
|
||||
every {
|
||||
with(CryptoCurrencyStatusOperations) {
|
||||
accountStatusList.getCryptoCurrencyStatus(any<CryptoCurrency>())
|
||||
}
|
||||
} returns result
|
||||
}
|
||||
|
||||
private fun createModel(currency: CryptoCurrency): YieldSupplyEntryModel = YieldSupplyEntryModel(
|
||||
paramsContainer = MutableParamsContainer(
|
||||
YieldSupplyEntryComponent.Params(userWalletId = USER_WALLET_ID, cryptoCurrency = currency, apy = "5.0"),
|
||||
),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
router = router,
|
||||
yieldSupplyEnterStatusUseCase = enterStatusUseCase,
|
||||
singleAccountStatusListSupplier = accountStatusListSupplier,
|
||||
isYieldBoostPromoEnabledForTokenUseCase = isPromoEnabledUseCase,
|
||||
)
|
||||
|
||||
private fun pendingEnter(): YieldSupplyPendingStatus = YieldSupplyPendingStatus.Enter(txIds = listOf("0xTx"))
|
||||
|
||||
private fun status(isActive: Boolean): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = token(),
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = BigDecimal.ZERO,
|
||||
fiatAmount = BigDecimal.ZERO,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = YieldSupplyStatus(
|
||||
isActive = isActive,
|
||||
isInitialized = true,
|
||||
isAllowedToSpend = true,
|
||||
effectiveProtocolBalance = null,
|
||||
),
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
value = "0x0000000000000000000000000000000000000000",
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun token(): CryptoCurrency.Token = CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network(),
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
|
||||
private fun coin(): CryptoCurrency.Coin = CryptoCurrency.Coin(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network(),
|
||||
name = "TEST_COIN",
|
||||
symbol = "ETH",
|
||||
decimals = 18,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
)
|
||||
|
||||
private fun network(): Network {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
return Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val USER_WALLET_ID = UserWalletId("abcdef012345")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,672 @@
|
|||
package com.tangem.features.yield.supply.impl.main.model
|
||||
|
||||
import arrow.core.*
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.common.ui.earn.EarnBlockUM
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.domain.account.models.AccountStatusList
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.error.SelectedAppCurrencyError
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.network.NetworkAddress
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
|
||||
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
|
||||
import com.tangem.domain.stories.models.StoryContentIds
|
||||
import com.tangem.domain.wallets.models.errors.GetUserWalletError
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.domain.yield.supply.models.YieldMarketToken
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus
|
||||
import com.tangem.domain.yield.supply.promo.usecase.GetBoostedApyUseCase
|
||||
import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.*
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyComponent
|
||||
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
|
||||
import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader
|
||||
import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class YieldSupplyModelTest {
|
||||
|
||||
private val analytics: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
private val appRouter: AppRouter = mockk(relaxed = true)
|
||||
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
|
||||
private val getUserWalletUseCase: GetUserWalletUseCase = mockk()
|
||||
private val accountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
|
||||
private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk()
|
||||
private val getTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase = mockk()
|
||||
private val isAvailableUseCase: YieldSupplyIsAvailableUseCase = mockk()
|
||||
private val activateUseCase: YieldSupplyActivateUseCase = mockk()
|
||||
private val deactivateUseCase: YieldSupplyDeactivateUseCase = mockk()
|
||||
private val enterStatusUseCase: YieldSupplyEnterStatusUseCase = mockk()
|
||||
private val enterStatusFlowUseCase: YieldSupplyEnterStatusFlowUseCase = mockk()
|
||||
private val minAmountUseCase: YieldSupplyMinAmountUseCase = mockk()
|
||||
private val getDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase = mockk()
|
||||
private val isBoostPromoEnabledUseCase: IsYieldBoostPromoEnabledForTokenUseCase = mockk()
|
||||
private val getBoostedApyUseCase = GetBoostedApyUseCase()
|
||||
private val boostStoryPreloader: YieldBoostStoryPreloader = mockk(relaxed = true)
|
||||
|
||||
private val userWalletId = UserWalletId("abcdef012345")
|
||||
private val userWallet: UserWallet = mockk(relaxed = true) { every { walletId } returns userWalletId }
|
||||
private val token: CryptoCurrency.Token = token()
|
||||
private val coin: CryptoCurrency.Coin = coin()
|
||||
private val accountStatusList: AccountStatusList = mockk()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockkObject(CryptoCurrencyStatusOperations)
|
||||
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right()
|
||||
coEvery { isAvailableUseCase(any(), any()) } returns true
|
||||
every { getUserWalletUseCase(userWalletId) } returns userWallet.right()
|
||||
every { accountStatusListSupplier(userWalletId) } returns flowOf(accountStatusList)
|
||||
every { enterStatusFlowUseCase(any(), any()) } returns flowOf(null)
|
||||
coEvery { enterStatusUseCase(any(), any()) } returns null.right()
|
||||
coEvery { singleNetworkStatusFetcher(any()) } returns Unit.right()
|
||||
coEvery { getTokenStatusUseCase(any()) } returns marketToken(isActive = true).right()
|
||||
coEvery { isBoostPromoEnabledUseCase(any(), any()) } returns false.right()
|
||||
coEvery { activateUseCase(any(), any(), any()) } returns true.right()
|
||||
coEvery { deactivateUseCase(any(), any()) } returns true.right()
|
||||
coEvery { minAmountUseCase(any(), any()) } returns BigDecimal("5").right()
|
||||
every { getDustMinAmountUseCase(any(), any(), any()) } returns BigDecimal("0.1")
|
||||
stubStatus(status(isActive = false).some())
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkObject(CryptoCurrencyStatusOperations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN yield supply unavailable WHEN model created THEN stays initial and skips wallet load`() = runTest {
|
||||
// Arrange
|
||||
coEvery { isAvailableUseCase(any(), any()) } returns false
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Initial)
|
||||
assertThat(model.uiState.value).isNull()
|
||||
verify(exactly = 0) { getUserWalletUseCase(any()) }
|
||||
coVerify(exactly = 0) { singleNetworkStatusFetcher(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN wallet load fails WHEN model created THEN stays initial and skips status subscription`() = runTest {
|
||||
// Arrange
|
||||
every { getUserWalletUseCase(userWalletId) } returns mockk<GetUserWalletError>(relaxed = true).left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Initial)
|
||||
verify(exactly = 0) { accountStatusListSupplier(any<UserWalletId>()) }
|
||||
coVerify(exactly = 1) { singleNetworkStatusFetcher(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN inactive token with active market WHEN status emitted THEN available state without boost`() = runTest {
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val legacy = model.uiStateLegacy.value
|
||||
assertThat(legacy).isInstanceOf(YieldSupplyUM.Available::class.java)
|
||||
assertThat((legacy as YieldSupplyUM.Available).isBoostAvailable).isFalse()
|
||||
assertThat(legacy.apy).isEqualTo("5")
|
||||
|
||||
val block = model.uiState.value
|
||||
assertThat(block).isInstanceOf(EarnBlockUM.Content::class.java)
|
||||
assertThat((block as EarnBlockUM.Content).backgroundUM).isEqualTo(EarnBlockUM.BackgroundUM.AccentSoft)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN promo enabled for token WHEN status emitted THEN boosted available promo`() = runTest {
|
||||
// Arrange
|
||||
coEvery { isBoostPromoEnabledUseCase(any(), any()) } returns true.right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val legacy = model.uiStateLegacy.value
|
||||
assertThat(legacy).isInstanceOf(YieldSupplyUM.Available::class.java)
|
||||
assertThat((legacy as YieldSupplyUM.Available).isBoostAvailable).isTrue()
|
||||
assertThat(model.uiState.value).isInstanceOf(EarnBlockUM.Promo::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN app currency unavailable WHEN status emitted THEN falls back to default and still loads`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns SelectedAppCurrencyError.NoAppCurrencySelected.left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isInstanceOf(YieldSupplyUM.Available::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN inactive token with inactive market WHEN status emitted THEN unavailable and no block`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getTokenStatusUseCase(any()) } returns marketToken(isActive = false).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Unavailable)
|
||||
assertThat(model.uiState.value).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN inactive token and token status fails WHEN status emitted THEN resets to initial`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getTokenStatusUseCase(any()) } returns Throwable("boom").left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Initial)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token allowed to spend WHEN status emitted THEN content without warning icon`() = runTest {
|
||||
// Arrange — supplied fully so the info-icon branch stays off
|
||||
stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.TEN).some())
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val legacy = model.uiStateLegacy.value
|
||||
assertThat(legacy).isInstanceOf(YieldSupplyUM.Content::class.java)
|
||||
assertThat((legacy as YieldSupplyUM.Content).shouldShowWarningIcon).isFalse()
|
||||
assertThat(legacy.shouldShowInfoIcon).isFalse()
|
||||
verify(exactly = 0) { analytics.send(any<YieldSupplyAnalytics.NoticeApproveNeeded>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token not allowed to spend WHEN status emitted THEN warning icon and analytics sent`() = runTest {
|
||||
// Arrange
|
||||
stubStatus(status(isActive = true, isAllowedToSpend = false, effectiveProtocolBalance = BigDecimal.TEN).some())
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val legacy = model.uiStateLegacy.value as YieldSupplyUM.Content
|
||||
assertThat(legacy.shouldShowWarningIcon).isTrue()
|
||||
val events = mutableListOf<AnalyticsEvent>()
|
||||
verify { analytics.send(capture(events)) }
|
||||
val approveEvent = events.filterIsInstance<YieldSupplyAnalytics.NoticeApproveNeeded>().single()
|
||||
assertThat(approveEvent.token).isEqualTo("TTK")
|
||||
assertThat(approveEvent.blockchain).isEqualTo("Ethereum")
|
||||
|
||||
val block = model.uiState.value as EarnBlockUM.Content
|
||||
assertThat(block.trailingUM).isEqualTo(
|
||||
EarnBlockUM.TrailingUM.StatusIcon(tone = EarnBlockUM.TrailingUM.StatusIcon.Tone.Warning),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token and token status fails WHEN status emitted THEN content with empty apy`() = runTest {
|
||||
// Arrange
|
||||
stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.TEN).some())
|
||||
coEvery { getTokenStatusUseCase(any()) } returns Throwable("boom").left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val legacy = model.uiStateLegacy.value as YieldSupplyUM.Content
|
||||
assertThat(legacy.apy).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token with not supplied amount WHEN status emitted THEN info icon shown`() = runTest {
|
||||
// Arrange — amount(10) > protocolBalance(1) so there is a not-supplied remainder above the dust limit
|
||||
stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.ONE).some())
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val legacy = model.uiStateLegacy.value as YieldSupplyUM.Content
|
||||
assertThat(legacy.shouldShowInfoIcon).isTrue()
|
||||
assertThat(legacy.shouldShowWarningIcon).isFalse()
|
||||
val block = model.uiState.value as EarnBlockUM.Content
|
||||
assertThat(block.trailingUM).isEqualTo(
|
||||
EarnBlockUM.TrailingUM.StatusIcon(tone = EarnBlockUM.TrailingUM.StatusIcon.Tone.Info),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN not supplied amount below dust WHEN status emitted THEN info icon hidden`() = runTest {
|
||||
// Arrange — dust threshold far above the not-supplied fiat value
|
||||
stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.ONE).some())
|
||||
every { getDustMinAmountUseCase(any(), any(), any()) } returns BigDecimal("1000")
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat((model.uiStateLegacy.value as YieldSupplyUM.Content).shouldShowInfoIcon).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN not supplied amount but min amount unavailable WHEN status emitted THEN info icon hidden`() = runTest {
|
||||
// Arrange — not-supplied remainder exists, but the min-amount lookup fails
|
||||
stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.ONE).some())
|
||||
coEvery { minAmountUseCase(any(), any()) } returns Throwable("no min").left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat((model.uiStateLegacy.value as YieldSupplyUM.Content).shouldShowInfoIcon).isFalse()
|
||||
verify(exactly = 0) { getDustMinAmountUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN pending enter status WHEN status emitted THEN processing enter`() = runTest {
|
||||
// Arrange
|
||||
coEvery { enterStatusUseCase(any(), any()) } returns YieldSupplyPendingStatus.Enter(txIds = listOf("0x1")).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Processing.Enter)
|
||||
assertThat(model.uiState.value).isInstanceOf(EarnBlockUM.Content::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN pending exit status WHEN status emitted THEN processing exit`() = runTest {
|
||||
// Arrange
|
||||
coEvery { enterStatusUseCase(any(), any()) } returns YieldSupplyPendingStatus.Exit(txIds = listOf("0x1")).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Processing.Exit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN processing state WHEN cached status emitted THEN keeps processing`() = runTest {
|
||||
// Arrange — first emission sets Processing.Enter, second (from cache) must be ignored
|
||||
val firstList: AccountStatusList = mockk()
|
||||
val secondList: AccountStatusList = mockk()
|
||||
val supplierFlow = MutableStateFlow(firstList)
|
||||
every { accountStatusListSupplier(userWalletId) } returns supplierFlow
|
||||
stubStatus(status(isActive = false, amount = BigDecimal.TEN).some(), firstList)
|
||||
stubStatus(
|
||||
option = status(isActive = false, amount = BigDecimal.ONE, networkSource = StatusSource.CACHE).some(),
|
||||
list = secondList,
|
||||
)
|
||||
coEvery { enterStatusUseCase(any(), any()) } returns
|
||||
YieldSupplyPendingStatus.Enter(txIds = listOf("0x1")).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
supplierFlow.value = secondList
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Processing.Enter)
|
||||
coVerify(exactly = 1) { enterStatusUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN identical statuses emitted twice WHEN model created THEN downstream runs once`() = runTest {
|
||||
// Arrange — distinctUntilChanged must collapse equal emissions
|
||||
val firstList: AccountStatusList = mockk()
|
||||
val secondList: AccountStatusList = mockk()
|
||||
val sameStatus = status(isActive = false)
|
||||
every { accountStatusListSupplier(userWalletId) } returns flowOf(firstList, secondList)
|
||||
stubStatus(sameStatus.some(), firstList)
|
||||
stubStatus(sameStatus.some(), secondList)
|
||||
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { enterStatusUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN two distinct emissions WHEN model created THEN protocol status sent only on the first`() = runTest {
|
||||
// Arrange — first emission active, second inactive; the once-only compareAndSet must fire sendInfo on the first
|
||||
// only. If the guard were removed, the second (inactive) emission would call deactivate.
|
||||
val firstList: AccountStatusList = mockk()
|
||||
val secondList: AccountStatusList = mockk()
|
||||
every { accountStatusListSupplier(userWalletId) } returns flowOf(firstList, secondList)
|
||||
stubStatus(
|
||||
status(isActive = true, amount = BigDecimal.TEN, effectiveProtocolBalance = BigDecimal.TEN).some(),
|
||||
firstList,
|
||||
)
|
||||
stubStatus(
|
||||
status(isActive = false, amount = BigDecimal.ONE).some(),
|
||||
secondList,
|
||||
)
|
||||
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — activate fired once (first emission); the guard suppressed the second, so deactivate never ran
|
||||
coVerify(exactly = 1) { activateUseCase(userWalletId, token, SOURCE_ADDRESS) }
|
||||
coVerify(exactly = 0) { deactivateUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cached status while not processing WHEN status emitted THEN state still advances`() = runTest {
|
||||
// Arrange — the cache guard must short-circuit ONLY while Processing
|
||||
stubStatus(status(isActive = false, networkSource = StatusSource.CACHE).some())
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isInstanceOf(YieldSupplyUM.Available::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN coin currency WHEN status emitted THEN token-only logic is skipped`() = runTest {
|
||||
// Arrange — every token-specific step guards on CryptoCurrency.Token
|
||||
stubStatus(status(currency = coin, isActive = false).some())
|
||||
|
||||
// Act
|
||||
val model = createModel(currency = coin)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Initial)
|
||||
coVerify(exactly = 0) { getTokenStatusUseCase(any()) }
|
||||
coVerify(exactly = 0) { activateUseCase(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { deactivateUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active status on first emission WHEN model created THEN activates protocol`() = runTest {
|
||||
// Arrange
|
||||
stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.TEN).some())
|
||||
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify { activateUseCase(userWalletId, token, SOURCE_ADDRESS) }
|
||||
coVerify(exactly = 0) { deactivateUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN inactive status on first emission WHEN model created THEN deactivates protocol`() = runTest {
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify { deactivateUseCase(token, SOURCE_ADDRESS) }
|
||||
coVerify(exactly = 0) { activateUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN missing network address WHEN status emitted THEN protocol status not sent`() = runTest {
|
||||
// Arrange — a Loading value carries no network address, so the side-effect must short-circuit
|
||||
stubStatus(CryptoCurrencyStatus(currency = token, value = CryptoCurrencyStatus.Loading).some())
|
||||
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 0) { activateUseCase(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { deactivateUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN latest status loaded WHEN onStartEarningClick THEN pushes yield entry route`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
val routeSlot = slot<AppRoute>()
|
||||
|
||||
// Act
|
||||
model.onStartEarningClick()
|
||||
|
||||
// Assert
|
||||
verify { appRouter.push(capture(routeSlot), any()) }
|
||||
val route = routeSlot.captured as AppRoute.YieldSupplyEntry
|
||||
assertThat(route.userWalletId).isEqualTo(userWalletId)
|
||||
assertThat(route.cryptoCurrency).isEqualTo(token)
|
||||
assertThat(route.apy).isEqualTo("5")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN processing state WHEN onStartEarningClick THEN pushes route with empty apy`() = runTest {
|
||||
// Arrange — Processing state has no apy field, so the route apy collapses to empty
|
||||
coEvery { enterStatusUseCase(any(), any()) } returns YieldSupplyPendingStatus.Enter(txIds = listOf("0x1")).right()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
val routeSlot = slot<AppRoute>()
|
||||
|
||||
// Act
|
||||
model.onStartEarningClick()
|
||||
|
||||
// Assert
|
||||
verify { appRouter.push(capture(routeSlot), any()) }
|
||||
assertThat((routeSlot.captured as AppRoute.YieldSupplyEntry).apy).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no latest status WHEN onActiveClick THEN does not navigate`() = runTest {
|
||||
// Arrange — currency status never resolves, so latestCryptoCurrencyStatus stays null
|
||||
stubStatus(none())
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onActiveClick()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 0) { appRouter.push(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN latest status loaded WHEN onLearnMoreClick THEN pushes stories route`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
val routeSlot = slot<AppRoute>()
|
||||
|
||||
// Act
|
||||
model.onLearnMoreClick()
|
||||
|
||||
// Assert
|
||||
verify { appRouter.push(capture(routeSlot), any()) }
|
||||
val route = routeSlot.captured as AppRoute.Stories
|
||||
assertThat(route.storyId).isEqualTo(StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id)
|
||||
assertThat(route.screenSource).isEqualTo("TokenDetails")
|
||||
assertThat(route.nextScreen).isInstanceOf(AppRoute.YieldSupplyEntry::class.java)
|
||||
}
|
||||
|
||||
private fun stubStatus(option: Option<CryptoCurrencyStatus>, list: AccountStatusList = accountStatusList) {
|
||||
every {
|
||||
with(CryptoCurrencyStatusOperations) { list.getCryptoCurrencyStatus(any<CryptoCurrency>()) }
|
||||
} returns option
|
||||
}
|
||||
|
||||
private fun TestScope.createModel(currency: CryptoCurrency = token): YieldSupplyModel = YieldSupplyModel(
|
||||
paramsContainer = MutableParamsContainer(
|
||||
YieldSupplyComponent.Params(userWalletId = userWalletId, cryptoCurrency = currency),
|
||||
),
|
||||
dispatchers = createDispatchers(),
|
||||
analyticsEventsHandler = analytics,
|
||||
appRouter = appRouter,
|
||||
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
||||
getUserWalletUseCase = getUserWalletUseCase,
|
||||
singleAccountStatusListSupplier = accountStatusListSupplier,
|
||||
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
|
||||
yieldSupplyGetTokenStatusUseCase = getTokenStatusUseCase,
|
||||
yieldSupplyIsAvailableUseCase = isAvailableUseCase,
|
||||
yieldSupplyActivateUseCase = activateUseCase,
|
||||
yieldSupplyDeactivateUseCase = deactivateUseCase,
|
||||
yieldSupplyEnterStatusUseCase = enterStatusUseCase,
|
||||
yieldSupplyEnterStatusFlowUseCase = enterStatusFlowUseCase,
|
||||
yieldSupplyMinAmountUseCase = minAmountUseCase,
|
||||
yieldSupplyGetDustMinAmountUseCase = getDustMinAmountUseCase,
|
||||
isYieldBoostPromoEnabledForTokenUseCase = isBoostPromoEnabledUseCase,
|
||||
getBoostedApyUseCase = getBoostedApyUseCase,
|
||||
boostStoryPreloader = boostStoryPreloader,
|
||||
)
|
||||
|
||||
private fun TestScope.createDispatchers(): TestingCoroutineDispatcherProvider {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
return TestingCoroutineDispatcherProvider(
|
||||
main = dispatcher,
|
||||
mainImmediate = dispatcher,
|
||||
io = dispatcher,
|
||||
default = dispatcher,
|
||||
single = dispatcher,
|
||||
)
|
||||
}
|
||||
|
||||
private fun status(
|
||||
currency: CryptoCurrency = token,
|
||||
isActive: Boolean = false,
|
||||
isAllowedToSpend: Boolean = true,
|
||||
amount: BigDecimal = BigDecimal.TEN,
|
||||
effectiveProtocolBalance: BigDecimal? = BigDecimal.ONE,
|
||||
fiatRate: BigDecimal? = BigDecimal.ONE,
|
||||
networkSource: StatusSource = StatusSource.ACTUAL,
|
||||
address: String = SOURCE_ADDRESS,
|
||||
): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = currency,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = amount,
|
||||
fiatAmount = amount,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = YieldSupplyStatus(
|
||||
isActive = isActive,
|
||||
isInitialized = true,
|
||||
isAllowedToSpend = isAllowedToSpend,
|
||||
effectiveProtocolBalance = effectiveProtocolBalance,
|
||||
),
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(value = address, type = NetworkAddress.Address.Type.Primary),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(networkSource = networkSource),
|
||||
),
|
||||
)
|
||||
|
||||
private fun marketToken(isActive: Boolean): YieldMarketToken = YieldMarketToken(
|
||||
tokenAddress = "0xToken",
|
||||
chainId = 1,
|
||||
apy = BigDecimal("5"),
|
||||
isActive = isActive,
|
||||
maxFeeNative = BigDecimal.ZERO,
|
||||
maxFeeUSD = BigDecimal.ZERO,
|
||||
backendId = "ethereum",
|
||||
)
|
||||
|
||||
private fun token(): CryptoCurrency.Token = CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network(),
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
|
||||
private fun coin(): CryptoCurrency.Coin = CryptoCurrency.Coin(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network(),
|
||||
name = "TEST_COIN",
|
||||
symbol = "ETH",
|
||||
decimals = 18,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
)
|
||||
|
||||
private fun network(): Network {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
return Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SOURCE_ADDRESS = "0x1111111111111111111111111111111111111111"
|
||||
}
|
||||
}
|
||||
|
|
@ -54,9 +54,7 @@ internal class YieldSupplyToEarnBlockConverterTest {
|
|||
assertThat(earnBlock.titleUM.tone).isEqualTo(EarnBlockUM.TitleUM.Tone.Primary)
|
||||
assertThat((earnBlock.subtitleUM as EarnBlockUM.SubtitleUM.Text).tone)
|
||||
.isEqualTo(EarnBlockUM.SubtitleUM.Tone.Accent)
|
||||
assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java)
|
||||
val button = earnBlock.trailingUM as EarnBlockUM.TrailingUM.Button
|
||||
assertThat(button.isEnabled).isTrue()
|
||||
assertThat(earnBlock.trailingUM).isEqualTo(EarnBlockUM.TrailingUM.Chevron)
|
||||
assertThat(earnBlock.onClick).isNotNull()
|
||||
earnBlock.onClick?.invoke()
|
||||
assertThat(clicked).isTrue()
|
||||
|
|
@ -71,7 +69,9 @@ internal class YieldSupplyToEarnBlockConverterTest {
|
|||
assertThat(earnBlock.type).isEqualTo(EarnBlockUM.Type.YieldSupply)
|
||||
assertThat(earnBlock.backgroundUM).isEqualTo(EarnBlockUM.BackgroundUM.Surface)
|
||||
assertThat(earnBlock.iconUM).isInstanceOf(EarnBlockUM.IconUM.Glowing::class.java)
|
||||
assertThat(earnBlock.trailingUM).isNull()
|
||||
assertThat(earnBlock.trailingUM).isEqualTo(
|
||||
EarnBlockUM.TrailingUM.Loader(tone = EarnBlockUM.TrailingUM.Loader.LoaderTone.Positive),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -82,12 +82,19 @@ internal class YieldSupplyToEarnBlockConverterTest {
|
|||
val earnBlock = result as EarnBlockUM.Content
|
||||
assertThat(earnBlock.type).isEqualTo(EarnBlockUM.Type.YieldSupply)
|
||||
assertThat(earnBlock.backgroundUM).isEqualTo(EarnBlockUM.BackgroundUM.Surface)
|
||||
assertThat(earnBlock.iconUM).isInstanceOf(EarnBlockUM.IconUM.Plain::class.java)
|
||||
assertThat(earnBlock.trailingUM).isNull()
|
||||
assertThat(earnBlock.iconUM).isEqualTo(
|
||||
EarnBlockUM.IconUM.Glowing(
|
||||
iconRes = com.tangem.core.ui.R.drawable.ic_yield_40,
|
||||
tone = EarnBlockUM.IconUM.Tone.Warning,
|
||||
),
|
||||
)
|
||||
assertThat(earnBlock.trailingUM).isEqualTo(
|
||||
EarnBlockUM.TrailingUM.Loader(tone = EarnBlockUM.TrailingUM.Loader.LoaderTone.Muted),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Content with showWarningIcon WHEN convert THEN title Warning Icon`() {
|
||||
fun `GIVEN Content with showWarningIcon WHEN convert THEN trailing Warning StatusIcon`() {
|
||||
val content = YieldSupplyUM.Content(
|
||||
apy = "5.1",
|
||||
title = stringReference("Yield Mode"),
|
||||
|
|
@ -102,13 +109,13 @@ internal class YieldSupplyToEarnBlockConverterTest {
|
|||
|
||||
assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java)
|
||||
val earnBlock = result as EarnBlockUM.Content
|
||||
assertThat(earnBlock.titleUM.iconUM).isNotNull()
|
||||
assertThat(earnBlock.titleUM.iconUM?.tone).isEqualTo(EarnBlockUM.TitleUM.IconTone.Warning)
|
||||
assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java)
|
||||
assertThat(earnBlock.trailingUM).isEqualTo(
|
||||
EarnBlockUM.TrailingUM.StatusIcon(tone = EarnBlockUM.TrailingUM.StatusIcon.Tone.Warning),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Content with showInfoIcon WHEN convert THEN title Info Icon`() {
|
||||
fun `GIVEN Content with showInfoIcon WHEN convert THEN trailing Info StatusIcon`() {
|
||||
val content = YieldSupplyUM.Content(
|
||||
apy = "5.1",
|
||||
title = stringReference("Yield Mode"),
|
||||
|
|
@ -123,9 +130,9 @@ internal class YieldSupplyToEarnBlockConverterTest {
|
|||
|
||||
assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java)
|
||||
val earnBlock = result as EarnBlockUM.Content
|
||||
assertThat(earnBlock.titleUM.iconUM).isNotNull()
|
||||
assertThat(earnBlock.titleUM.iconUM?.tone).isEqualTo(EarnBlockUM.TitleUM.IconTone.Info)
|
||||
assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java)
|
||||
assertThat(earnBlock.trailingUM).isEqualTo(
|
||||
EarnBlockUM.TrailingUM.StatusIcon(tone = EarnBlockUM.TrailingUM.StatusIcon.Tone.Info),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -143,8 +150,9 @@ internal class YieldSupplyToEarnBlockConverterTest {
|
|||
val result = converter.convert(content)
|
||||
|
||||
val earnBlock = result as EarnBlockUM.Content
|
||||
assertThat(earnBlock.titleUM.iconUM).isNotNull()
|
||||
assertThat(earnBlock.titleUM.iconUM?.tone).isEqualTo(EarnBlockUM.TitleUM.IconTone.Warning)
|
||||
assertThat(earnBlock.trailingUM).isEqualTo(
|
||||
EarnBlockUM.TrailingUM.StatusIcon(tone = EarnBlockUM.TrailingUM.StatusIcon.Tone.Warning),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
package com.tangem.features.yield.supply.impl.main.model.transformers
|
||||
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.extensions.annotatedReference
|
||||
import com.tangem.core.ui.extensions.combinedReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.yield.supply.models.YieldMarketToken
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class YieldSupplyTokenStatusSuccessTransformerTest {
|
||||
|
||||
private var startEarningClicked = false
|
||||
private var learnMoreClicked = false
|
||||
|
||||
@Test
|
||||
fun `GIVEN inactive token WHEN transform THEN Unavailable`() {
|
||||
// Arrange
|
||||
val transformer = createTransformer(tokenStatus = marketToken(isActive = false))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(YieldSupplyUM.Initial)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(YieldSupplyUM.Unavailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token without boost WHEN transform THEN Available with plain apy text`() {
|
||||
// Arrange
|
||||
val transformer = createTransformer(tokenStatus = marketToken(isActive = true, apy = BigDecimal("5.5")))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(YieldSupplyUM.Initial)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isInstanceOf(YieldSupplyUM.Available::class.java)
|
||||
val available = result as YieldSupplyUM.Available
|
||||
assertThat(available.isBoostAvailable).isFalse()
|
||||
assertThat(available.apy).isEqualTo("5.5")
|
||||
assertThat(available.title).isEqualTo(
|
||||
resourceReference(R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title),
|
||||
)
|
||||
assertThat(available.apyText).isEqualTo(
|
||||
combinedReference(
|
||||
resourceReference(R.string.yield_module_token_details_earn_notification_apy),
|
||||
stringReference(" 5.5%"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token with boost WHEN transform THEN Available with boosted apy text and title`() {
|
||||
// Arrange
|
||||
val transformer = createTransformer(
|
||||
tokenStatus = marketToken(isActive = true, apy = BigDecimal("5.5")),
|
||||
boostedApy = BigDecimal("16.5"),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(YieldSupplyUM.Initial)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isInstanceOf(YieldSupplyUM.Available::class.java)
|
||||
val available = result as YieldSupplyUM.Available
|
||||
assertThat(available.isBoostAvailable).isTrue()
|
||||
assertThat(available.title).isEqualTo(resourceReference(R.string.yield_apy_boost_banner_title))
|
||||
assertThat(available.apyText).isEqualTo(
|
||||
annotatedReference(
|
||||
buildAnnotatedString {
|
||||
append("APY ")
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||
append("5.5%")
|
||||
}
|
||||
append(" x3 → 16.5%")
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token WHEN clicks delegated THEN original callbacks fire`() {
|
||||
// Arrange
|
||||
val transformer = createTransformer(tokenStatus = marketToken(isActive = true))
|
||||
|
||||
// Act
|
||||
val available = transformer.transform(YieldSupplyUM.Initial) as YieldSupplyUM.Available
|
||||
available.onClick()
|
||||
available.onLearnMoreClick()
|
||||
|
||||
// Assert
|
||||
assertThat(startEarningClicked).isTrue()
|
||||
assertThat(learnMoreClicked).isTrue()
|
||||
}
|
||||
|
||||
private fun createTransformer(
|
||||
tokenStatus: YieldMarketToken,
|
||||
boostedApy: BigDecimal? = null,
|
||||
): YieldSupplyTokenStatusSuccessTransformer = YieldSupplyTokenStatusSuccessTransformer(
|
||||
tokenStatus = tokenStatus,
|
||||
onStartEarningClick = { startEarningClicked = true },
|
||||
onLearnMoreClick = { learnMoreClicked = true },
|
||||
boostedApy = boostedApy,
|
||||
)
|
||||
|
||||
private fun marketToken(isActive: Boolean, apy: BigDecimal = BigDecimal("5.5")): YieldMarketToken =
|
||||
YieldMarketToken(
|
||||
tokenAddress = "0xToken",
|
||||
chainId = 1,
|
||||
apy = apy,
|
||||
isActive = isActive,
|
||||
maxFeeNative = BigDecimal.ZERO,
|
||||
maxFeeUSD = BigDecimal.ZERO,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
package com.tangem.features.yield.supply.impl.subcomponents
|
||||
|
||||
import arrow.core.right
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.AmountType
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.navigation.url.UrlOpener
|
||||
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
|
||||
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.network.NetworkAddress
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
|
||||
import com.tangem.domain.transaction.usecase.GetFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
|
||||
import com.tangem.domain.yield.supply.YieldSupplyRepository
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyPendingTracker
|
||||
import com.tangem.features.yield.supply.impl.common.YieldSupplyAlertFactory
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSupplyNotificationsUpdateTrigger
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Shared fixtures, mocks and builders for the Yield Supply transactional model tests
|
||||
* (Approve / StopEarning / StartEarning). Subclasses declare their own unique mocks and build
|
||||
* the concrete model via the base mocks; tests read [uiState] synchronously thanks to the
|
||||
* Unconfined [TestingCoroutineDispatcherProvider].
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal abstract class YieldSupplyActionModelTestBase {
|
||||
|
||||
protected val analytics: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
protected val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
|
||||
protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = mockk()
|
||||
protected val sendTransactionUseCase: SendTransactionUseCase = mockk()
|
||||
protected val getFeeUseCase: GetFeeUseCase = mockk()
|
||||
protected val urlOpener: UrlOpener = mockk(relaxed = true)
|
||||
protected val notificationsUpdateTrigger: YieldSupplyNotificationsUpdateTrigger = mockk(relaxed = true)
|
||||
protected val alertFactory: YieldSupplyAlertFactory = mockk(relaxed = true)
|
||||
protected val pendingTracker: YieldSupplyPendingTracker = mockk(relaxed = true)
|
||||
protected val yieldSupplyRepository: YieldSupplyRepository = mockk(relaxed = true)
|
||||
protected val appsFlyerStore: AppsFlyerStore = mockk(relaxed = true)
|
||||
|
||||
protected val userWalletId = UserWalletId("abcdef012345")
|
||||
protected val userWallet: UserWallet = mockk(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
|
||||
protected val token: CryptoCurrency.Token = token()
|
||||
protected val coin: CryptoCurrency.Coin = coin()
|
||||
protected val cryptoCurrencyStatus: CryptoCurrencyStatus = statusOf(token)
|
||||
protected val cryptoCurrencyStatusFlow = MutableStateFlow(cryptoCurrencyStatus)
|
||||
|
||||
@BeforeEach
|
||||
fun baseSetUp() {
|
||||
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right()
|
||||
every { notificationsUpdateTrigger.hasErrorFlow } returns MutableStateFlow(false)
|
||||
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase(any(), any()) } returns cryptoCurrencyStatus.right()
|
||||
}
|
||||
|
||||
/** A [StandardTestDispatcher] for every role so `advanceUntilIdle()` drives the model's coroutines. */
|
||||
protected fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
|
||||
val testDispatcher = StandardTestDispatcher(testScheduler)
|
||||
return TestingCoroutineDispatcherProvider(
|
||||
main = testDispatcher,
|
||||
mainImmediate = testDispatcher,
|
||||
io = testDispatcher,
|
||||
default = testDispatcher,
|
||||
single = testDispatcher,
|
||||
)
|
||||
}
|
||||
|
||||
/** Network fee is paid in the native coin (token amounts are rejected by `increaseGasLimitBy`). */
|
||||
protected fun coinAmount(value: BigDecimal): Amount =
|
||||
Amount(currencySymbol = "ETH", value = value, decimals = 18, type = AmountType.Coin)
|
||||
|
||||
protected fun ethFee(value: BigDecimal = BigDecimal("0.001")): Fee.Ethereum.EIP1559 = Fee.Ethereum.EIP1559(
|
||||
maxFeePerGas = BigInteger.valueOf(1_000_000_000L),
|
||||
priorityFee = BigInteger.ONE,
|
||||
gasLimit = BigInteger.valueOf(21_000),
|
||||
amount = coinAmount(value),
|
||||
)
|
||||
|
||||
protected fun transactionFee(value: BigDecimal = BigDecimal("0.001")): TransactionFee.Single =
|
||||
TransactionFee.Single(normal = ethFee(value))
|
||||
|
||||
protected fun uncompiledTx(fee: Fee = ethFee()): TransactionData.Uncompiled = TransactionData.Uncompiled(
|
||||
fee = fee,
|
||||
amount = coinAmount(BigDecimal.ONE),
|
||||
contractAddress = null,
|
||||
sourceAddress = SOURCE_ADDRESS,
|
||||
destinationAddress = DESTINATION_ADDRESS,
|
||||
extras = null,
|
||||
)
|
||||
|
||||
protected fun statusOf(currency: CryptoCurrency): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = currency,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = BigDecimal.TEN,
|
||||
fiatAmount = BigDecimal.TEN,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = YieldSupplyStatus(
|
||||
isActive = true,
|
||||
isInitialized = true,
|
||||
isAllowedToSpend = true,
|
||||
effectiveProtocolBalance = BigDecimal.ONE,
|
||||
),
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
value = SOURCE_ADDRESS,
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
protected fun token(): CryptoCurrency.Token = CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network(),
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
|
||||
protected fun coin(): CryptoCurrency.Coin = CryptoCurrency.Coin(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network(),
|
||||
name = "TEST_COIN",
|
||||
symbol = "ETH",
|
||||
decimals = 18,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
)
|
||||
|
||||
protected fun network(): Network {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
return Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
protected companion object {
|
||||
const val SOURCE_ADDRESS = "0x1111111111111111111111111111111111111111"
|
||||
const val DESTINATION_ADDRESS = "0x2222222222222222222222222222222222222222"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
package com.tangem.features.yield.supply.impl.subcomponents.approve.model
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.TangemBlogUrlBuilder
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.Basic
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.SendTransactionError
|
||||
import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetContractAddressUseCase
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.YieldSupplyActionModelTestBase
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.approve.YieldSupplyApproveComponent
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class YieldSupplyApproveModelTest : YieldSupplyActionModelTestBase() {
|
||||
|
||||
private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase = mockk()
|
||||
private val getContractAddressUseCase: YieldSupplyGetContractAddressUseCase = mockk()
|
||||
private val callback: YieldSupplyApproveComponent.ModelCallback = mockk(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
coEvery { getContractAddressUseCase(any(), any()) } returns "0xSpender".right()
|
||||
coEvery {
|
||||
createApprovalTransactionUseCase(any(), any(), any(), any(), any())
|
||||
} returns uncompiledTx().right()
|
||||
coEvery { getFeeUseCase(any(), any(), any()) } returns transactionFee().right()
|
||||
coEvery { sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) } returns "0xhash".right()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN successful fee load WHEN model created THEN fee content and button enabled`() = runTest {
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isInstanceOf(YieldSupplyFeeUM.Content::class.java)
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue()
|
||||
coVerify { notificationsUpdateTrigger.triggerUpdate(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN get fee fails WHEN model created THEN fee error state`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-token currency WHEN model created THEN fee not loaded`() = runTest {
|
||||
// Act
|
||||
val model = createModel(statusFlow = MutableStateFlow(statusOf(coin)))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading)
|
||||
coVerify(exactly = 0) { getFeeUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contract address missing WHEN model created THEN fee not loaded`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getContractAddressUseCase(any(), any()) } returns (null as String?).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading)
|
||||
coVerify(exactly = 0) { getFeeUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN content loaded WHEN onClick THEN sends transaction tracks pending and notifies sent`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { callback.onTransactionProgress(true) }
|
||||
coVerify { pendingTracker.addPending(userWalletId, any(), any()) }
|
||||
verify { callback.onTransactionSent() }
|
||||
|
||||
// Token fee asset (default fee currency is the token itself)
|
||||
val events = mutableListOf<AnalyticsEvent>()
|
||||
verify { analytics.send(capture(events)) }
|
||||
val sent = events.filterIsInstance<Basic.TransactionSent>().single()
|
||||
assertThat(sent.params["Fee Token"]).isEqualTo("TTK")
|
||||
assertThat(sent.params["Fee Asset Type"]).isEqualTo(AnalyticsParam.FeeAssetType.Token.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN coin fee currency WHEN onClick succeeds THEN transaction sent analytics carries coin fee asset`() = runTest {
|
||||
// Arrange — network fee paid in the native coin, not the token
|
||||
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase(any(), any()) } returns statusOf(coin).right()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val events = mutableListOf<AnalyticsEvent>()
|
||||
verify { analytics.send(capture(events)) }
|
||||
val sent = events.filterIsInstance<Basic.TransactionSent>().single()
|
||||
assertThat(sent.params["Fee Token"]).isEqualTo("ETH")
|
||||
assertThat(sent.params["Fee Asset Type"]).isEqualTo(AnalyticsParam.FeeAssetType.Coin.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee not loaded WHEN onClick THEN does not send transaction`() = runTest {
|
||||
// Arrange — fee load fails so the fee state is Error; onClick reports progress then early-returns
|
||||
coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { callback.onTransactionProgress(true) }
|
||||
coVerify(exactly = 0) { sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN notifications report an error WHEN flag emitted THEN primary button disabled`() = runTest {
|
||||
// Arrange
|
||||
val hasErrorFlow = MutableStateFlow(false)
|
||||
every { notificationsUpdateTrigger.hasErrorFlow } returns hasErrorFlow
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue()
|
||||
|
||||
// Act
|
||||
hasErrorFlow.value = true
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN content loaded WHEN onClick and send fails THEN shows error and stops progress`() = runTest {
|
||||
// Arrange
|
||||
coEvery {
|
||||
sendTransactionUseCase(txData = any(), userWallet = any(), network = any())
|
||||
} returns SendTransactionError.UnknownError().left()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isTransactionSending).isFalse()
|
||||
verify { alertFactory.getSendTransactionErrorState(any(), any(), any()) }
|
||||
verify { callback.onTransactionProgress(false) }
|
||||
verify(exactly = 0) { callback.onTransactionSent() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN onReadMoreClick THEN opens url`() = runTest {
|
||||
// Arrange — TangemBlogUrlBuilder.build is a real suspend object; stub it to isolate the model's intent
|
||||
mockkObject(TangemBlogUrlBuilder)
|
||||
try {
|
||||
coEvery { TangemBlogUrlBuilder.build(any()) } returns BLOG_URL
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onReadMoreClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { urlOpener.openUrl(BLOG_URL) }
|
||||
} finally {
|
||||
unmockkObject(TangemBlogUrlBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createModel(
|
||||
statusFlow: StateFlow<CryptoCurrencyStatus> = cryptoCurrencyStatusFlow,
|
||||
): YieldSupplyApproveModel = YieldSupplyApproveModel(
|
||||
dispatchers = createTestingCoroutineDispatcherProvider(),
|
||||
paramsContainer = MutableParamsContainer(
|
||||
YieldSupplyApproveComponent.Params(
|
||||
userWallet = userWallet,
|
||||
cryptoCurrencyStatusFlow = statusFlow,
|
||||
callback = callback,
|
||||
),
|
||||
),
|
||||
analyticsEventHandler = analytics,
|
||||
urlOpener = urlOpener,
|
||||
yieldSupplyNotificationsUpdateTrigger = notificationsUpdateTrigger,
|
||||
createApprovalTransactionUseCase = createApprovalTransactionUseCase,
|
||||
getFeeUseCase = getFeeUseCase,
|
||||
sendTransactionUseCase = sendTransactionUseCase,
|
||||
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
||||
getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase,
|
||||
yieldSupplyGetContractAddressUseCase = getContractAddressUseCase,
|
||||
yieldSupplyPendingTracker = pendingTracker,
|
||||
yieldSupplyAlertFactory = alertFactory,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val BLOG_URL = "https://tangem.com/blog"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
package com.tangem.features.yield.supply.impl.subcomponents.startearning.model
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.none
|
||||
import arrow.core.right
|
||||
import arrow.core.some
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.account.models.AccountStatusList
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.SendTransactionError
|
||||
import com.tangem.domain.wallets.models.errors.GetUserWalletError
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.domain.yield.supply.YieldSupplyError
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyFee
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyActivateUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetCurrentFeeUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetMaxFeeUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyStartEarningUseCase
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.YieldSupplyActionModelTestBase
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.startearning.YieldSupplyStartEarningComponent
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class YieldSupplyStartEarningModelTest : YieldSupplyActionModelTestBase() {
|
||||
|
||||
private val getUserWalletUseCase: GetUserWalletUseCase = mockk()
|
||||
private val accountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
|
||||
private val startEarningUseCase: YieldSupplyStartEarningUseCase = mockk()
|
||||
private val estimateEnterFeeUseCase: YieldSupplyEstimateEnterFeeUseCase = mockk()
|
||||
private val activateUseCase: YieldSupplyActivateUseCase = mockk()
|
||||
private val minAmountUseCase: YieldSupplyMinAmountUseCase = mockk()
|
||||
private val getMaxFeeUseCase: YieldSupplyGetMaxFeeUseCase = mockk()
|
||||
private val getCurrentFeeUseCase: YieldSupplyGetCurrentFeeUseCase = mockk()
|
||||
|
||||
private val accountStatusList: AccountStatusList = mockk()
|
||||
private val callback: YieldSupplyStartEarningComponent.ModelCallback = mockk(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockkObject(CryptoCurrencyStatusOperations)
|
||||
every { getUserWalletUseCase(userWalletId) } returns userWallet.right()
|
||||
every { accountStatusListSupplier(userWalletId) } returns flowOf(accountStatusList)
|
||||
stubCurrencyStatusLookup(cryptoCurrencyStatus.some())
|
||||
coEvery { minAmountUseCase(any(), any()) } returns BigDecimal("5").right()
|
||||
coEvery { getMaxFeeUseCase(any(), any()) } returns maxFee().right()
|
||||
coEvery { getCurrentFeeUseCase(any(), any()) } returns YieldSupplyFee(BigDecimal("0.001")).right()
|
||||
coEvery { startEarningUseCase(any(), any(), any()) } returns listOf(uncompiledTx()).right()
|
||||
coEvery { estimateEnterFeeUseCase(any(), any(), any()) } returns listOf(uncompiledTx()).right()
|
||||
coEvery {
|
||||
sendTransactionUseCase(txsData = any(), userWallet = any(), network = any(), sendMode = any())
|
||||
} returns listOf("0xhash").right()
|
||||
coEvery { activateUseCase(any(), any(), any()) } returns true.right()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkObject(CryptoCurrencyStatusOperations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN successful fee load WHEN model created THEN fee content and button enabled`() = runTest {
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isInstanceOf(YieldSupplyFeeUM.Content::class.java)
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue()
|
||||
coVerify { notificationsUpdateTrigger.triggerUpdate(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN estimate fee fails WHEN model created THEN fee error state`() = runTest {
|
||||
// Arrange
|
||||
coEvery { estimateEnterFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN max fee unavailable WHEN model created THEN fee error state`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getMaxFeeUseCase(any(), any()) } returns Throwable("no max fee").left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Error)
|
||||
coVerify(exactly = 0) { estimateEnterFeeUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN user wallet unavailable WHEN model created THEN shows generic error`() = runTest {
|
||||
// Arrange
|
||||
every { getUserWalletUseCase(userWalletId) } returns mockk<GetUserWalletError>(relaxed = true).left()
|
||||
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { alertFactory.getGenericErrorState(any(), any()) }
|
||||
coVerify(exactly = 0) { getMaxFeeUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN currency status not found WHEN model created THEN shows generic error`() = runTest {
|
||||
// Arrange
|
||||
stubCurrencyStatusLookup(none())
|
||||
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { alertFactory.getGenericErrorState(any(), any()) }
|
||||
coVerify(exactly = 0) { getMaxFeeUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN content loaded WHEN onClick THEN sends activates tracks pending and notifies sent`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, any(), any()) }
|
||||
coVerify { activateUseCase(userWalletId, any(), any()) }
|
||||
coVerify { pendingTracker.addPending(userWalletId, any(), any()) }
|
||||
verify { callback.onTransactionSent() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN content loaded WHEN onClick and send fails THEN shows error and not sent`() = runTest {
|
||||
// Arrange
|
||||
coEvery {
|
||||
sendTransactionUseCase(txsData = any(), userWallet = any(), network = any(), sendMode = any())
|
||||
} returns SendTransactionError.UnknownError().left()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isTransactionSending).isFalse()
|
||||
verify { alertFactory.getSendTransactionErrorState(any(), any(), any()) }
|
||||
verify(exactly = 0) { callback.onTransactionSent() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee not loaded WHEN onClick THEN does not send transactions`() = runTest {
|
||||
// Arrange — estimate fee fails so the fee state is Error; onClick must early-return before sending
|
||||
coEvery { estimateEnterFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 0) {
|
||||
sendTransactionUseCase(txsData = any(), userWallet = any(), network = any(), sendMode = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN notifications report an error WHEN flag emitted THEN primary button disabled`() = runTest {
|
||||
// Arrange
|
||||
val hasErrorFlow = MutableStateFlow(false)
|
||||
every { notificationsUpdateTrigger.hasErrorFlow } returns hasErrorFlow
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue()
|
||||
|
||||
// Act
|
||||
hasErrorFlow.value = true
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
private fun stubCurrencyStatusLookup(result: arrow.core.Option<com.tangem.domain.models.currency.CryptoCurrencyStatus>) {
|
||||
every {
|
||||
with(CryptoCurrencyStatusOperations) {
|
||||
accountStatusList.getCryptoCurrencyStatus(any<CryptoCurrency>())
|
||||
}
|
||||
} returns result
|
||||
}
|
||||
|
||||
private fun maxFee(): YieldSupplyMaxFee = YieldSupplyMaxFee(
|
||||
nativeMaxFee = BigDecimal("0.01"),
|
||||
tokenMaxFee = BigDecimal("2"),
|
||||
fiatMaxFee = BigDecimal("4"),
|
||||
)
|
||||
|
||||
private fun TestScope.createModel(): YieldSupplyStartEarningModel = YieldSupplyStartEarningModel(
|
||||
dispatchers = createTestingCoroutineDispatcherProvider(),
|
||||
paramsContainer = MutableParamsContainer(
|
||||
YieldSupplyStartEarningComponent.Params(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrency = token,
|
||||
yieldSupplyActionUM = actionUM(),
|
||||
callback = callback,
|
||||
),
|
||||
),
|
||||
analytics = analytics,
|
||||
getUserWalletUseCase = getUserWalletUseCase,
|
||||
singleAccountStatusListSupplier = accountStatusListSupplier,
|
||||
getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase,
|
||||
sendTransactionUseCase = sendTransactionUseCase,
|
||||
yieldSupplyStartEarningUseCase = startEarningUseCase,
|
||||
yieldSupplyEstimateEnterFeeUseCase = estimateEnterFeeUseCase,
|
||||
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
||||
yieldSupplyNotificationsUpdateTrigger = notificationsUpdateTrigger,
|
||||
yieldSupplyAlertFactory = alertFactory,
|
||||
yieldSupplyActivateUseCase = activateUseCase,
|
||||
yieldSupplyMinAmountUseCase = minAmountUseCase,
|
||||
yieldSupplyGetMaxFeeUseCase = getMaxFeeUseCase,
|
||||
yieldSupplyGetCurrentFeeUseCase = getCurrentFeeUseCase,
|
||||
yieldSupplyRepository = yieldSupplyRepository,
|
||||
yieldSupplyPendingTracker = pendingTracker,
|
||||
appsFlyerStore = appsFlyerStore,
|
||||
)
|
||||
|
||||
private fun actionUM(): YieldSupplyActionUM = YieldSupplyActionUM(
|
||||
title = stringReference(""),
|
||||
subtitle = stringReference(""),
|
||||
footer = stringReference(""),
|
||||
footerLink = stringReference(""),
|
||||
currencyIconState = mockk<CurrencyIconState>(relaxed = true),
|
||||
yieldSupplyFeeUM = YieldSupplyFeeUM.Loading,
|
||||
isPrimaryButtonEnabled = false,
|
||||
isTransactionSending = false,
|
||||
isHoldToConfirmEnabled = false,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
package com.tangem.features.yield.supply.impl.subcomponents.startearning.model.transformers
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.network.NetworkAddress
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class YieldSupplyStartEarningFeeContentTransformerTest {
|
||||
|
||||
private val token = createToken()
|
||||
private val appCurrency = AppCurrency.Default
|
||||
|
||||
@Test
|
||||
fun `GIVEN currency status loading WHEN transform THEN fee Loading and button flag preserved`() {
|
||||
// Arrange — prevState button flag is false; the Loading branch must not flip it
|
||||
val transformer = createTransformer(currencyStatus = loadingStatus())
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(prevState())
|
||||
|
||||
// Assert
|
||||
assertThat(result.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading)
|
||||
assertThat(result.isPrimaryButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded status with rates WHEN transform THEN fee Content with every fiat field computed`() {
|
||||
// Arrange — tokenFiatRate 1, feeFiatRate 2; feeValue 0.5, estimatedToken 0.4, minAmount 3, maxFee 2 token / 4 fiat
|
||||
val transformer = createTransformer(currencyStatus = customStatus(BigDecimal("1")), feeFiatRate = BigDecimal("2"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(prevState())
|
||||
|
||||
// Assert — whole Content compared field-by-field (no fields touched on isPrimaryButtonEnabled)
|
||||
assertThat(result.yieldSupplyFeeUM).isEqualTo(
|
||||
expectedContent(tokenFiatRate = BigDecimal("1"), feeFiatRate = BigDecimal("2")),
|
||||
)
|
||||
assertThat(result.isPrimaryButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded status but missing rates WHEN transform THEN fiat fields collapse to placeholders`() {
|
||||
// Arrange — negative: both token and fee fiat rates unavailable
|
||||
val transformer = createTransformer(currencyStatus = customStatus(null), feeFiatRate = null)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(prevState())
|
||||
|
||||
// Assert — fiat-derived fields become the placeholder; crypto fields and the max fiat fee stay populated
|
||||
assertThat(result.yieldSupplyFeeUM).isEqualTo(
|
||||
expectedContent(tokenFiatRate = null, feeFiatRate = null),
|
||||
)
|
||||
}
|
||||
|
||||
private fun expectedContent(tokenFiatRate: BigDecimal?, feeFiatRate: BigDecimal?): YieldSupplyFeeUM.Content {
|
||||
val feeFiatText = fiatText(feeFiatRate?.let(FEE_VALUE::multiply))
|
||||
val estimatedFiatText = fiatText(tokenFiatRate?.let(ESTIMATED_TOKEN::multiply))
|
||||
val estimatedCryptoText = cryptoText(ESTIMATED_TOKEN)
|
||||
val maxFiatText = fiatText(MAX_FIAT_FEE)
|
||||
val maxCryptoText = cryptoText(MAX_TOKEN_FEE)
|
||||
val minFiatText = fiatText(tokenFiatRate?.let(MIN_AMOUNT::multiply))
|
||||
val minCryptoText = cryptoText(MIN_AMOUNT)
|
||||
return YieldSupplyFeeUM.Content(
|
||||
transactionDataList = persistentListOf(),
|
||||
feeFiatValue = stringReference(feeFiatText),
|
||||
estimatedFiatValue = stringReference(estimatedFiatText),
|
||||
maxNetworkFeeFiatValue = stringReference(maxFiatText),
|
||||
minTopUpFiatValue = stringReference(minFiatText),
|
||||
feeNoteValue = resourceReference(
|
||||
id = R.string.yield_module_fee_policy_sheet_fee_note,
|
||||
formatArgs = wrappedList(estimatedFiatText, estimatedCryptoText, maxFiatText, maxCryptoText),
|
||||
),
|
||||
minFeeNoteValue = resourceReference(
|
||||
id = R.string.yield_module_fee_policy_sheet_min_amount_note,
|
||||
formatArgs = wrappedList(minFiatText, minCryptoText),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun cryptoText(value: BigDecimal): String = value.format { crypto(token) }
|
||||
|
||||
private fun fiatText(value: BigDecimal?): String = value.format { fiat(appCurrency.code, appCurrency.symbol) }
|
||||
|
||||
private fun createTransformer(
|
||||
currencyStatus: CryptoCurrencyStatus,
|
||||
feeFiatRate: BigDecimal? = BigDecimal("1"),
|
||||
): YieldSupplyStartEarningFeeContentTransformer = YieldSupplyStartEarningFeeContentTransformer(
|
||||
cryptoCurrencyStatus = currencyStatus,
|
||||
feeCryptoCurrencyStatus = customStatus(feeFiatRate),
|
||||
appCurrency = appCurrency,
|
||||
updatedTransactionList = emptyList(),
|
||||
feeValue = FEE_VALUE,
|
||||
estimatedFeeValueInTokenCurrency = ESTIMATED_TOKEN,
|
||||
maxNetworkFee = YieldSupplyMaxFee(
|
||||
nativeMaxFee = BigDecimal("0.01"),
|
||||
tokenMaxFee = MAX_TOKEN_FEE,
|
||||
fiatMaxFee = MAX_FIAT_FEE,
|
||||
),
|
||||
minAmount = MIN_AMOUNT,
|
||||
)
|
||||
|
||||
private fun customStatus(fiatRate: BigDecimal?): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = token,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = BigDecimal.ZERO,
|
||||
fiatAmount = BigDecimal.ZERO,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
value = "0x0000000000000000000000000000000000000000",
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun loadingStatus(): CryptoCurrencyStatus =
|
||||
CryptoCurrencyStatus(currency = token, value = CryptoCurrencyStatus.Loading)
|
||||
|
||||
private fun prevState(): YieldSupplyActionUM = YieldSupplyActionUM(
|
||||
title = stringReference(""),
|
||||
subtitle = stringReference(""),
|
||||
footer = stringReference(""),
|
||||
footerLink = stringReference(""),
|
||||
currencyIconState = mockk<CurrencyIconState>(relaxed = true),
|
||||
yieldSupplyFeeUM = YieldSupplyFeeUM.Error,
|
||||
isPrimaryButtonEnabled = false,
|
||||
isTransactionSending = false,
|
||||
isHoldToConfirmEnabled = false,
|
||||
)
|
||||
|
||||
private fun createToken(): CryptoCurrency.Token {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
val network = Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network,
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val FEE_VALUE: BigDecimal = BigDecimal("0.5")
|
||||
val ESTIMATED_TOKEN: BigDecimal = BigDecimal("0.4")
|
||||
val MIN_AMOUNT: BigDecimal = BigDecimal("3")
|
||||
val MAX_TOKEN_FEE: BigDecimal = BigDecimal("2")
|
||||
val MAX_FIAT_FEE: BigDecimal = BigDecimal("4")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
package com.tangem.features.yield.supply.impl.subcomponents.stopearning.model
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.TangemBlogUrlBuilder
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.Basic
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.SendTransactionError
|
||||
import com.tangem.domain.yield.supply.YieldSupplyError
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyDeactivateUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyStopEarningUseCase
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.YieldSupplyActionModelTestBase
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.stopearning.YieldSupplyStopEarningComponent
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class YieldSupplyStopEarningModelTest : YieldSupplyActionModelTestBase() {
|
||||
|
||||
private val stopEarningUseCase: YieldSupplyStopEarningUseCase = mockk()
|
||||
private val deactivateUseCase: YieldSupplyDeactivateUseCase = mockk()
|
||||
private val callback: YieldSupplyStopEarningComponent.ModelCallback = mockk(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
coEvery { stopEarningUseCase(any(), any(), any()) } returns uncompiledTx().right()
|
||||
coEvery { getFeeUseCase(any(), any(), any()) } returns transactionFee().right()
|
||||
coEvery { sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) } returns "0xhash".right()
|
||||
coEvery { deactivateUseCase(any(), any()) } returns true.right()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN successful fee load WHEN model created THEN fee content and button enabled`() = runTest {
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isInstanceOf(YieldSupplyFeeUM.Content::class.java)
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue()
|
||||
coVerify { notificationsUpdateTrigger.triggerUpdate(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN get fee fails WHEN model created THEN fee error state`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-token currency WHEN model created THEN fee not loaded`() = runTest {
|
||||
// Act
|
||||
val model = createModel(statusFlow = MutableStateFlow(statusOf(coin)))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading)
|
||||
coVerify(exactly = 0) { getFeeUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stop earning use case fails WHEN model created THEN fee not loaded`() = runTest {
|
||||
// Arrange
|
||||
coEvery { stopEarningUseCase(any(), any(), any()) } returns YieldSupplyError.DataError(Throwable()).left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading)
|
||||
coVerify(exactly = 0) { getFeeUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN content loaded WHEN onClick THEN sends deactivates tracks pending and notifies sent`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { callback.onTransactionProgress(true) }
|
||||
coVerify { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, any(), any()) }
|
||||
coVerify { deactivateUseCase(any(), any()) }
|
||||
coVerify { pendingTracker.addPending(userWalletId, any(), any()) }
|
||||
verify { callback.onStopEarningTransactionSent() }
|
||||
|
||||
// Token fee asset (default fee currency is the token itself)
|
||||
val events = mutableListOf<AnalyticsEvent>()
|
||||
verify { analytics.send(capture(events)) }
|
||||
val sent = events.filterIsInstance<Basic.TransactionSent>().single()
|
||||
assertThat(sent.params["Fee Token"]).isEqualTo("TTK")
|
||||
assertThat(sent.params["Fee Asset Type"]).isEqualTo(AnalyticsParam.FeeAssetType.Token.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN coin fee currency WHEN onClick succeeds THEN transaction sent analytics carries coin fee asset`() = runTest {
|
||||
// Arrange — network fee paid in the native coin, not the token
|
||||
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase(any(), any()) } returns statusOf(coin).right()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val events = mutableListOf<AnalyticsEvent>()
|
||||
verify { analytics.send(capture(events)) }
|
||||
val sent = events.filterIsInstance<Basic.TransactionSent>().single()
|
||||
assertThat(sent.params["Fee Token"]).isEqualTo("ETH")
|
||||
assertThat(sent.params["Fee Asset Type"]).isEqualTo(AnalyticsParam.FeeAssetType.Coin.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee not loaded WHEN onClick THEN does not send transaction`() = runTest {
|
||||
// Arrange — fee load fails so the fee state is Error; onClick reports progress then early-returns
|
||||
coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { callback.onTransactionProgress(true) }
|
||||
coVerify(exactly = 0) { sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN notifications report an error WHEN flag emitted THEN primary button disabled`() = runTest {
|
||||
// Arrange
|
||||
val hasErrorFlow = MutableStateFlow(false)
|
||||
every { notificationsUpdateTrigger.hasErrorFlow } returns hasErrorFlow
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue()
|
||||
|
||||
// Act
|
||||
hasErrorFlow.value = true
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN content loaded WHEN onClick and send fails THEN shows error and stops progress`() = runTest {
|
||||
// Arrange
|
||||
coEvery {
|
||||
sendTransactionUseCase(txData = any(), userWallet = any(), network = any())
|
||||
} returns SendTransactionError.UnknownError().left()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isTransactionSending).isFalse()
|
||||
verify { alertFactory.getSendTransactionErrorState(any(), any(), any()) }
|
||||
verify { callback.onTransactionProgress(false) }
|
||||
verify(exactly = 0) { callback.onStopEarningTransactionSent() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN onReadMoreClick THEN opens url`() = runTest {
|
||||
// Arrange
|
||||
mockkObject(TangemBlogUrlBuilder)
|
||||
try {
|
||||
coEvery { TangemBlogUrlBuilder.build(any()) } returns BLOG_URL
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onReadMoreClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { urlOpener.openUrl(BLOG_URL) }
|
||||
} finally {
|
||||
unmockkObject(TangemBlogUrlBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createModel(
|
||||
statusFlow: StateFlow<CryptoCurrencyStatus> = cryptoCurrencyStatusFlow,
|
||||
): YieldSupplyStopEarningModel = YieldSupplyStopEarningModel(
|
||||
dispatchers = createTestingCoroutineDispatcherProvider(),
|
||||
paramsContainer = MutableParamsContainer(
|
||||
YieldSupplyStopEarningComponent.Params(
|
||||
userWallet = userWallet,
|
||||
cryptoCurrencyStatusFlow = statusFlow,
|
||||
callback = callback,
|
||||
),
|
||||
),
|
||||
analytics = analytics,
|
||||
getFeeUseCase = getFeeUseCase,
|
||||
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
||||
getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase,
|
||||
sendTransactionUseCase = sendTransactionUseCase,
|
||||
yieldSupplyStopEarningUseCase = stopEarningUseCase,
|
||||
urlOpener = urlOpener,
|
||||
yieldSupplyNotificationsUpdateTrigger = notificationsUpdateTrigger,
|
||||
yieldSupplyAlertFactory = alertFactory,
|
||||
yieldSupplyDeactivateUseCase = deactivateUseCase,
|
||||
yieldSupplyRepository = yieldSupplyRepository,
|
||||
yieldSupplyPendingTracker = pendingTracker,
|
||||
appsFlyerStore = appsFlyerStore,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val BLOG_URL = "https://tangem.com/blog"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
package com.tangem.features.yield.supply.impl.subcomponents.stopearning.model.transformer
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.network.NetworkAddress
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class YieldSupplyStopEarningFeeContentTransformerTest {
|
||||
|
||||
private val token = createToken()
|
||||
private val appCurrency = AppCurrency.Default
|
||||
|
||||
@Test
|
||||
fun `GIVEN currency status loading WHEN transform THEN fee Loading and button flag preserved`() {
|
||||
// Arrange — prevState button flag is false; the Loading branch must not flip it
|
||||
val transformer = createTransformer(currencyStatus = loadingStatus(), feeFiatRate = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(prevState())
|
||||
|
||||
// Assert
|
||||
assertThat(result.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading)
|
||||
assertThat(result.isPrimaryButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded status with fee rate WHEN transform THEN only fiat fee set and the rest EMPTY`() {
|
||||
// Arrange — feeValue 0.5, feeFiatRate 2 → fiat fee = 1.0; all other fee fields are intentionally EMPTY
|
||||
val transformer = createTransformer(currencyStatus = customStatus(BigDecimal("1")), feeFiatRate = BigDecimal("2"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(prevState())
|
||||
|
||||
// Assert
|
||||
assertThat(result.isPrimaryButtonEnabled).isTrue()
|
||||
assertThat(result.yieldSupplyFeeUM).isEqualTo(
|
||||
YieldSupplyFeeUM.Content(
|
||||
transactionDataList = persistentListOf(),
|
||||
feeFiatValue = stringReference(fiatText(BigDecimal("0.5").multiply(BigDecimal("2")))),
|
||||
estimatedFiatValue = TextReference.EMPTY,
|
||||
maxNetworkFeeFiatValue = TextReference.EMPTY,
|
||||
minTopUpFiatValue = TextReference.EMPTY,
|
||||
feeNoteValue = TextReference.EMPTY,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded status but missing fee rate WHEN transform THEN fiat fee is the placeholder`() {
|
||||
// Arrange — negative: fee fiat rate unavailable, fiat fee text becomes the placeholder
|
||||
val transformer = createTransformer(currencyStatus = customStatus(BigDecimal("1")), feeFiatRate = null)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(prevState())
|
||||
|
||||
// Assert
|
||||
assertThat(result.isPrimaryButtonEnabled).isTrue()
|
||||
assertThat(result.yieldSupplyFeeUM).isEqualTo(
|
||||
YieldSupplyFeeUM.Content(
|
||||
transactionDataList = persistentListOf(),
|
||||
feeFiatValue = stringReference(fiatText(null)),
|
||||
estimatedFiatValue = TextReference.EMPTY,
|
||||
maxNetworkFeeFiatValue = TextReference.EMPTY,
|
||||
minTopUpFiatValue = TextReference.EMPTY,
|
||||
feeNoteValue = TextReference.EMPTY,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun fiatText(value: BigDecimal?): String = value.format { fiat(appCurrency.code, appCurrency.symbol) }
|
||||
|
||||
private fun createTransformer(
|
||||
currencyStatus: CryptoCurrencyStatus,
|
||||
feeFiatRate: BigDecimal?,
|
||||
): YieldSupplyStopEarningFeeContentTransformer = YieldSupplyStopEarningFeeContentTransformer(
|
||||
cryptoCurrencyStatus = currencyStatus,
|
||||
feeCryptoCurrencyStatus = customStatus(feeFiatRate),
|
||||
appCurrency = appCurrency,
|
||||
transactions = emptyList(),
|
||||
feeValue = BigDecimal("0.5"),
|
||||
)
|
||||
|
||||
private fun customStatus(fiatRate: BigDecimal?): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = token,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = BigDecimal.ZERO,
|
||||
fiatAmount = BigDecimal.ZERO,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
value = "0x0000000000000000000000000000000000000000",
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun loadingStatus(): CryptoCurrencyStatus =
|
||||
CryptoCurrencyStatus(currency = token, value = CryptoCurrencyStatus.Loading)
|
||||
|
||||
private fun prevState(): YieldSupplyActionUM = YieldSupplyActionUM(
|
||||
title = stringReference(""),
|
||||
subtitle = stringReference(""),
|
||||
footer = stringReference(""),
|
||||
footerLink = stringReference(""),
|
||||
currencyIconState = mockk<CurrencyIconState>(relaxed = true),
|
||||
yieldSupplyFeeUM = YieldSupplyFeeUM.Error,
|
||||
isPrimaryButtonEnabled = false,
|
||||
isTransactionSending = false,
|
||||
isHoldToConfirmEnabled = false,
|
||||
)
|
||||
|
||||
private fun createToken(): CryptoCurrency.Token {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
val network = Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network,
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue