Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-28 14:08:01 +03:00
commit 06a915a0f4
2186 changed files with 101417 additions and 36593 deletions

View file

@ -1,5 +0,0 @@
package com.tangem.features.yield.supply.api
interface YieldSupplyFeatureToggles {
val isYieldPromoEnabled: Boolean
}

View file

@ -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,
)
}

View file

@ -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),
)
}

View file

@ -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()

View file

@ -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)
}
}

View file

@ -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,

View file

@ -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))
}
}

View file

@ -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(

View file

@ -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,
)
}
}

View file

@ -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

View file

@ -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),
)
}
}

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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"

View file

@ -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",
)
}
}

View file

@ -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")
}
}

View file

@ -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
}
}

View file

@ -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")
}
}

View file

@ -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"
}
}

View file

@ -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

View file

@ -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,
)
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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,
)
}

View file

@ -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")
}
}

View file

@ -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"
}
}

View file

@ -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",
)
}
}