Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-03 11:34:04 +03:00
parent 19df6fefae
commit 1beceb4744
11 changed files with 1581 additions and 1078 deletions

View file

@ -0,0 +1,555 @@
package com.tangem.features.txhistory.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.transactions.state.TxIcon
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_20
import com.tangem.core.ui.res.generated.icons.ic_card_20
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.models.network.SdkAmount
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.impl.R
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class ExpressTxToDetailsUMConverterTest : TxDetailsConverterTestBase() {
private val converter = expressConverter()
// region Header / dispatch
@Test
fun `GIVEN express swap WHEN convert THEN TwoAssets with exchange icon`() {
// Act
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging))
// Assert
assertThat(result.header.icon).isEqualTo(TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20))
}
@Test
fun `GIVEN express onramp WHEN convert THEN TwoAssets with card icon`() {
// Act
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Finished))
// Assert
assertThat(result.header.icon).isEqualTo(TxIcon.Vector(Icons.ic_card_20))
}
// endregion
// region Status banners
@Test
fun `GIVEN exchanging express swap WHEN convert THEN info status banner with loader`() {
// Act
val banner = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging)).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info,
title = resourceReference(R.string.express_exchange_status_exchanging_active),
isLoading = true,
),
)
}
@Test
fun `GIVEN verifying express swap WHEN convert THEN warning status banner with verification subtitle`() {
// Act
val banner = converter.convert(expressSwap(status = ExpressExchangeStatus.Verifying)).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Warning,
title = resourceReference(R.string.express_exchange_status_verifying),
subtitle = resourceReference(R.string.express_exchange_notification_verification_text),
isLoading = false,
),
)
}
@Test
fun `GIVEN finished express swap WHEN convert THEN success status banner`() {
// Act
val banner = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished)).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success,
title = resourceReference(R.string.express_exchange_status_exchanged),
isLoading = false,
),
)
}
@Test
fun `GIVEN unknown express swap WHEN convert THEN no status banner`() {
// Act — nothing to surface, the plaque is hidden.
val banner = converter.convert(expressSwap(status = ExpressExchangeStatus.Unknown)).statusBanner
// Assert
assertThat(banner).isNull()
}
@Test
fun `GIVEN failed express swap WHEN convert THEN error status banner with refund subtitle`() {
// Act
val banner = converter.convert(expressSwap(status = ExpressExchangeStatus.Failed)).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error,
title = resourceReference(R.string.express_exchange_status_failed),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
isLoading = false,
),
)
}
@Test
fun `GIVEN finished express onramp WHEN convert THEN success banner`() {
// Act
val banner = converter.convert(expressOnramp(status = ExpressOnrampStatus.Finished)).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success,
title = resourceReference(R.string.express_exchange_status_bought),
isLoading = false,
),
)
}
@Test
fun `GIVEN verifying express onramp WHEN convert THEN warning status banner with verification subtitle`() {
// Act
val banner = converter.convert(expressOnramp(status = ExpressOnrampStatus.Verifying)).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Warning,
title = resourceReference(R.string.express_exchange_status_verifying),
subtitle = resourceReference(R.string.express_exchange_notification_verification_text),
isLoading = false,
),
)
}
@Test
fun `GIVEN unknown express onramp WHEN convert THEN no status banner`() {
// Act — nothing to surface, the plaque is hidden.
val banner = converter.convert(expressOnramp(status = ExpressOnrampStatus.Unknown)).statusBanner
// Assert
assertThat(banner).isNull()
}
// endregion
// region Asset legs
@Test
fun `GIVEN in-progress express swap WHEN convert THEN from is minus and to is approx, neither faded`() {
// Act
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging))
// Assert
assertThat(result.from?.amount?.resolveString()).startsWith("- ")
assertThat(result.from?.isFaded).isFalse()
// Receive amount is still an estimate while in flight: `~`, not `+`, and not struck through.
assertThat(result.to?.amount?.resolveString()).startsWith("~ ")
assertThat(result.to?.isFaded).isFalse()
// Counterparty (to) symbol comes from the resolved CryptoCurrency; the unresolved from leg falls back to network id.
assertThat(result.to?.currencyIcon).isNotNull()
assertThat(result.from?.currencyIcon).isNull()
}
@Test
fun `GIVEN finished express swap WHEN convert THEN to is plus and neither leg faded`() {
// Act
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished))
// Assert
assertThat(result.from?.amount?.resolveString()).startsWith("- ")
assertThat(result.to?.amount?.resolveString()).startsWith("+ ")
assertThat(result.from?.isFaded).isFalse()
assertThat(result.to?.isFaded).isFalse()
}
@Test
fun `GIVEN failed express swap WHEN convert THEN both legs faded and signs dropped`() {
// Act
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Failed))
// Assert
assertThat(result.from?.isFaded).isTrue()
assertThat(result.to?.isFaded).isTrue()
assertThat(result.from?.amount?.resolveString()).doesNotContain("-")
assertThat(result.to?.amount?.resolveString()).doesNotContain("+")
}
@Test
fun `GIVEN finished express onramp WHEN convert THEN paid fiat is unsigned and topped-up crypto is plus`() {
// Act
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Finished))
// Assert
// "You paid" fiat carries no icon and no sign — the exact amount paid.
assertThat(result.from?.currencyIcon).isNull()
assertThat(result.from?.amount?.resolveString()).contains("SEK")
assertThat(result.from?.amount?.resolveString()).doesNotContain("-")
assertThat(result.from?.amount?.resolveString()).doesNotContain("+")
assertThat(result.from?.amount?.resolveString()).doesNotContain("~")
// Topped-up crypto leg is settled: `+`, with an icon.
assertThat(result.to?.currencyIcon).isNotNull()
assertThat(result.to?.amount?.resolveString()).startsWith("+ ")
assertThat(result.to?.isFaded).isFalse()
}
@Test
fun `GIVEN in-progress express onramp WHEN convert THEN paid fiat is unsigned and top-up crypto is approx`() {
// Act
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Sending))
// Assert
// "You paid" stays unsigned regardless of status.
assertThat(result.from?.amount?.resolveString()).doesNotContain("-")
assertThat(result.from?.amount?.resolveString()).doesNotContain("+")
assertThat(result.from?.amount?.resolveString()).doesNotContain("~")
assertThat(result.from?.isFaded).isFalse()
// Crypto to-be-received is an estimate while in flight: `~`, not struck through.
assertThat(result.to?.amount?.resolveString()).startsWith("~ ")
assertThat(result.to?.isFaded).isFalse()
}
// endregion
// region Info rows (provider / rate / network fee)
@Test
fun `GIVEN express swap with matched on-chain leg WHEN convert THEN network-fee row from leg`() {
// Arrange
val leg = onChain(
type = TransactionType.Swap,
fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18),
)
// Act
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg))
// Assert — no provider in the fixture, so rate then the on-chain leg's network fee.
assertThat(result.rows.map { it.label }).containsExactly(
resourceReference(R.string.common_rate),
resourceReference(R.string.common_network_fee_title),
).inOrder()
}
@Test
fun `GIVEN express swap with provider and url WHEN convert THEN provider row links to the url`() {
// Act
val result = converter.convert(
expressSwap(
status = ExpressExchangeStatus.Finished,
provider = provider(name = "Mercuryo"),
externalTxUrl = EXTERNAL_URL,
),
)
// Assert — provider then rate (no on-chain leg, so no fee row).
assertThat(result.rows.map { it.label }).containsExactly(
resourceReference(R.string.express_provider),
resourceReference(R.string.common_rate),
).inOrder()
val providerRow = result.rows.first()
assertThat(providerRow.value.resolveString()).isEqualTo("Mercuryo")
assertThat(providerRow.trailingIconRes).isEqualTo(R.drawable.ic_arrow_top_right_24)
providerRow.onClick?.invoke()
assertThat(openedUrls).containsExactly(EXTERNAL_URL)
}
@Test
fun `GIVEN express swap with provider but no url WHEN convert THEN provider row has no link`() {
// Act — the provider supplies no link (e.g. DEX), so the row is plain text.
val result = converter.convert(
expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Mercuryo")),
)
// Assert
val providerRow = result.rows.first()
assertThat(providerRow.value.resolveString()).isEqualTo("Mercuryo")
assertThat(providerRow.trailingIconRes).isNull()
assertThat(providerRow.onClick).isNull()
}
@Test
fun `GIVEN express swap with provider and on-chain leg WHEN convert THEN provider row precedes network-fee row`() {
// Arrange
val leg = onChain(
type = TransactionType.Swap,
fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18),
)
// Act
val result = converter.convert(
expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg, provider = provider(name = "Changelly")),
)
// Assert
assertThat(result.rows.map { it.label }).containsExactly(
resourceReference(R.string.express_provider),
resourceReference(R.string.common_rate),
resourceReference(R.string.common_network_fee_title),
).inOrder()
}
@Test
fun `GIVEN express swap with both amounts WHEN convert THEN rate row 1 from approx to follows provider`() {
// Act — no on-chain leg, so the rows are provider then rate.
val result = converter.convert(
expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Changelly")),
)
// Assert
assertThat(result.rows.map { it.label }).containsExactly(
resourceReference(R.string.express_provider),
resourceReference(R.string.common_rate),
).inOrder()
val rate = result.rows[1].value.resolveString()
// 0.001 BTC / 1.5 ETH ≈ 0.00066667; base falls back to the unresolved from-leg network id, quote to BTC.
assertThat(rate).startsWith("1")
assertThat(rate).contains("")
assertThat(rate).contains("ethereum")
assertThat(rate).contains("BTC")
}
@Test
fun `GIVEN express swap with non-positive amount WHEN convert THEN no rate row`() {
// Arrange — a zero pay-in makes the rate undefined; the row is dropped (division-by-zero guard).
val base = expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Changelly"))
val swap = base.copy(tx = base.tx.copy(fromAsset = base.tx.fromAsset.copy(amount = BigDecimal.ZERO)))
// Act
val result = converter.convert(swap)
// Assert — only the provider row remains.
assertThat(result.rows.map { it.label }).containsExactly(resourceReference(R.string.express_provider))
}
@Test
fun `GIVEN express onramp with both amounts WHEN convert THEN rate row 1 crypto approx fiat`() {
// Act
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Finished))
// Assert — onramp has no provider in the fixture, so the only row is the rate.
assertThat(result.rows.map { it.label }).containsExactly(resourceReference(R.string.common_rate))
val rate = result.rows.first().value.resolveString()
// 100 SEK / 0.006 BTC ≈ 16,666.67 SEK; base is the resolved crypto symbol (BTC).
assertThat(rate).startsWith("1")
assertThat(rate).contains("")
assertThat(rate).contains("BTC")
assertThat(rate).contains("SEK")
}
// endregion
// region Provider button
@Test
fun `GIVEN failed express swap with url WHEN convert THEN go-to-provider button opening the url`() {
// Act
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Failed, externalTxUrl = EXTERNAL_URL))
// Assert
val button = result.providerButton
assertThat(button?.text).isEqualTo(resourceReference(R.string.common_go_to_provider))
button?.onClick?.invoke()
assertThat(openedUrls).containsExactly(EXTERNAL_URL)
}
@Test
fun `GIVEN verifying express swap with url WHEN convert THEN go-to-verification button`() {
// Act
val result = converter.convert(
expressSwap(status = ExpressExchangeStatus.Verifying, externalTxUrl = EXTERNAL_URL),
)
// Assert
assertThat(result.providerButton?.text).isEqualTo(resourceReference(R.string.common_go_to_verification))
}
@Test
fun `GIVEN verifying express onramp with url WHEN convert THEN go-to-verification button opening the url`() {
// Act
val result = converter.convert(
expressOnramp(status = ExpressOnrampStatus.Verifying, externalTxUrl = EXTERNAL_URL),
)
// Assert
val button = result.providerButton
assertThat(button?.text).isEqualTo(resourceReference(R.string.common_go_to_verification))
button?.onClick?.invoke()
assertThat(openedUrls).containsExactly(EXTERNAL_URL)
}
@Test
fun `GIVEN failed express swap without url WHEN convert THEN no provider button`() {
// Act — the provider supplies no link (e.g. DEX), so there is nowhere to send the user.
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Failed, externalTxUrl = null))
// Assert
assertThat(result.providerButton).isNull()
}
@Test
fun `GIVEN finished express swap with url WHEN convert THEN no provider button`() {
// Act — a settled success needs no provider action even when a link exists.
val result = converter.convert(
expressSwap(status = ExpressExchangeStatus.Finished, externalTxUrl = EXTERNAL_URL),
)
// Assert
assertThat(result.providerButton).isNull()
}
// endregion
// region Leg owner
@Test
fun `GIVEN swap between own accounts WHEN convert THEN legs labelled From-To with account owners`() {
// Arrange — from leg on ethereum, payout leg on bitcoin, both addresses owned, accounts mode on.
val swap = expressSwap(
status = ExpressExchangeStatus.Finished,
fromAddress = FROM_ADDRESS,
payoutAddress = PAYOUT_ADDRESS,
fromCurrency = currency,
)
val lookup = lookupOf(
currency.network.id.rawId to mapOf(FROM_ADDRESS to ownAccount),
bitcoin.network.id.rawId to mapOf(PAYOUT_ADDRESS to ownAccount),
)
// Act
val result = expressConverter(lookup = lookup).convert(swap)
// Assert
assertThat(result.from?.label).isEqualTo(resourceReference(R.string.common_from))
assertThat(result.to?.label).isEqualTo(resourceReference(R.string.common_to))
assertThat(result.from?.owner).isInstanceOf(TxHistoryDetailsUM.AssetOwnerUM.Account::class.java)
assertThat(result.to?.owner).isInstanceOf(TxHistoryDetailsUM.AssetOwnerUM.Account::class.java)
}
@Test
fun `GIVEN swap to own address with accounts mode off WHEN convert THEN owner is wallet`() {
// Arrange
val swap = expressSwap(status = ExpressExchangeStatus.Finished, payoutAddress = PAYOUT_ADDRESS)
val lookup = lookupOf(
bitcoin.network.id.rawId to mapOf(PAYOUT_ADDRESS to ownAccount),
isAccountsModeEnabled = false,
)
// Act
val result = expressConverter(lookup = lookup).convert(swap)
// Assert
val owner = requireNotNull(result.to?.owner)
assertThat(owner).isInstanceOf(TxHistoryDetailsUM.AssetOwnerUM.Wallet::class.java)
assertThat((owner as TxHistoryDetailsUM.AssetOwnerUM.Wallet).name).isEqualTo(stringReference("My Wallet"))
}
@Test
fun `GIVEN send-and-swap to external address WHEN convert THEN to leg owner is external address`() {
// Arrange — payout address is none of the user's, so it stays an external address.
val swap = expressSwap(status = ExpressExchangeStatus.Finished, payoutAddress = EXTERNAL_ADDRESS)
// Act
val result = expressConverter(lookup = lookupOf()).convert(swap)
// Assert
val owner = requireNotNull(result.to?.owner)
assertThat(owner).isInstanceOf(TxHistoryDetailsUM.AssetOwnerUM.Address::class.java)
assertThat((owner as TxHistoryDetailsUM.AssetOwnerUM.Address).rawAddress).isEqualTo(EXTERNAL_ADDRESS)
assertThat(result.to?.label).isEqualTo(resourceReference(R.string.common_to))
}
@Test
fun `GIVEN swap without addresses WHEN convert THEN no owner and default labels`() {
// Act — no fromAddress / payoutAddress plumbed (e.g. very old app version).
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished))
// Assert
assertThat(result.from?.owner).isNull()
assertThat(result.to?.owner).isNull()
assertThat(result.from?.label).isEqualTo(resourceReference(R.string.swapping_from_title_v2))
assertThat(result.to?.label).isEqualTo(resourceReference(R.string.swapping_to_title))
}
@Test
fun `GIVEN onramp to own account WHEN convert THEN from is You paid and to has account owner`() {
// Arrange
val onramp = expressOnramp(status = ExpressOnrampStatus.Finished, payoutAddress = PAYOUT_ADDRESS)
val lookup = lookupOf(bitcoin.network.id.rawId to mapOf(PAYOUT_ADDRESS to ownAccount))
// Act
val result = expressConverter(lookup = lookup).convert(onramp)
// Assert
assertThat(result.from?.owner).isNull()
assertThat(result.from?.label).isEqualTo(resourceReference(R.string.tx_history_you_paid))
assertThat(result.to?.owner).isInstanceOf(TxHistoryDetailsUM.AssetOwnerUM.Account::class.java)
assertThat(result.to?.label).isEqualTo(resourceReference(R.string.common_to))
}
@Test
fun `GIVEN leg with unresolved currency but own address WHEN convert THEN owner resolved cross-network`() {
// Arrange — from leg has no cryptoCurrency (null network), yet its address is owned on exactly one network.
val swap = expressSwap(
status = ExpressExchangeStatus.Finished,
fromAddress = FROM_ADDRESS,
fromCurrency = null,
)
val lookup = lookupOf(currency.network.id.rawId to mapOf(FROM_ADDRESS to ownAccount))
// Act
val result = expressConverter(lookup = lookup).convert(swap)
// Assert
assertThat(result.from?.owner).isInstanceOf(TxHistoryDetailsUM.AssetOwnerUM.Account::class.java)
assertThat(result.from?.label).isEqualTo(resourceReference(R.string.common_from))
}
@Test
fun `GIVEN leg with unresolved currency and address on two distinct accounts WHEN convert THEN stays external`() {
// Arrange — null network forces a cross-network lookup; the same address maps to two different accounts.
val swap = expressSwap(
status = ExpressExchangeStatus.Finished,
fromAddress = FROM_ADDRESS,
fromCurrency = null,
)
val lookup = lookupOf(
currency.network.id.rawId to mapOf(FROM_ADDRESS to ownAccount),
bitcoin.network.id.rawId to mapOf(FROM_ADDRESS to secondAccount),
)
// Act
val result = expressConverter(lookup = lookup).convert(swap)
// Assert — ambiguous, so it falls back to the external address rather than guessing an owner.
val owner = requireNotNull(result.from?.owner)
assertThat(owner).isInstanceOf(TxHistoryDetailsUM.AssetOwnerUM.Address::class.java)
assertThat((owner as TxHistoryDetailsUM.AssetOwnerUM.Address).rawAddress).isEqualTo(FROM_ADDRESS)
}
// endregion
}

View file

@ -159,8 +159,12 @@ internal class ExpressTxToTransactionItemUMConverterTest {
@Test
fun `GIVEN swap statuses WHEN convert THEN status-aware title`() {
val swapping = converter.convert(createSwap(status = ExpressExchangeStatus.Waiting)) as TransactionItemUM.Content
val swapped = converter.convert(createSwap(status = ExpressExchangeStatus.Finished)) as TransactionItemUM.Content
val swapping = converter.convert(
createSwap(status = ExpressExchangeStatus.Waiting),
) as TransactionItemUM.Content
val swapped = converter.convert(
createSwap(status = ExpressExchangeStatus.Finished),
) as TransactionItemUM.Content
assertThat(swapping.title).isEqualTo(resourceReference(R.string.common_swapping))
assertThat(swapped.title).isEqualTo(resourceReference(R.string.common_swapped))
@ -169,7 +173,9 @@ internal class ExpressTxToTransactionItemUMConverterTest {
@Test
fun `GIVEN onramp statuses WHEN convert THEN status-aware title`() {
val topUp = converter.convert(createOnramp(status = ExpressOnrampStatus.Sending)) as TransactionItemUM.Content
val toppedUp = converter.convert(createOnramp(status = ExpressOnrampStatus.Finished)) as TransactionItemUM.Content
val toppedUp = converter.convert(
createOnramp(status = ExpressOnrampStatus.Finished),
) as TransactionItemUM.Content
assertThat(topUp.title).isEqualTo(resourceReference(R.string.tx_history_onramp_top_up))
assertThat(toppedUp.title).isEqualTo(resourceReference(R.string.tx_history_onramp_topped_up))
@ -237,31 +243,29 @@ internal class ExpressTxToTransactionItemUMConverterTest {
txInfo = null,
)
private fun createOnramp(
status: ExpressOnrampStatus,
toAmount: BigDecimal? = BigDecimal("0.006339"),
) = ExpressTx.Onramp(
tx = OnrampTransaction(
txId = "tx-2",
status = status,
createdAtMillis = 100,
provider = null,
payoutHash = null,
payoutAddress = null,
fromFiat = Amount(
currencySymbol = "SEK",
value = BigDecimal("100"),
decimals = 2,
type = AmountType.FiatType(code = "SEK"),
private fun createOnramp(status: ExpressOnrampStatus, toAmount: BigDecimal? = BigDecimal("0.006339")) =
ExpressTx.Onramp(
tx = OnrampTransaction(
txId = "tx-2",
status = status,
createdAtMillis = 100,
provider = null,
payoutHash = null,
payoutAddress = null,
fromFiat = Amount(
currencySymbol = "SEK",
value = BigDecimal("100"),
decimals = 2,
type = AmountType.FiatType(code = "SEK"),
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0"),
amount = toAmount,
decimals = 8,
),
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0"),
amount = toAmount,
decimals = 8,
),
),
txInfo = null,
)
txInfo = null,
)
private fun createCoin(symbol: String, decimals: Int): CryptoCurrency.Coin = CryptoCurrency.Coin(
id = CryptoCurrency.ID(

View file

@ -0,0 +1,471 @@
package com.tangem.features.txhistory.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TxIcon
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_down_20
import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_20
import com.tangem.domain.models.network.SdkAmount
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.impl.R
import com.tangem.test.core.ProvideTestModels
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class OnChainTxToDetailsUMConverterTest : TxDetailsConverterTestBase() {
private val converter = onChainConverter()
// region Header
@ParameterizedTest
@ProvideTestModels
fun `GIVEN any TransactionType WHEN convert THEN SingleAsset produced`(type: TransactionType) {
// Act
val result = converter.convert(txInfo(type = type))
// Assert
assertThat(result).isInstanceOf(TxHistoryDetailsUM.SingleAsset::class.java)
}
private fun provideTestModels() = listOf(
TransactionType.Transfer,
TransactionType.Approve,
TransactionType.Operation(name = "Mint NFT"),
TransactionType.UnknownOperation,
TransactionType.GaslessFee,
TransactionType.Swap,
TransactionType.Staking.Stake,
TransactionType.Staking.ClaimRewards,
TransactionType.Staking.Vote(validatorAddress = VALIDATOR_ADDRESS),
TransactionType.YieldSupply.Topup,
TransactionType.YieldSupply.Enter(address = USER_ADDRESS),
)
@Test
fun `GIVEN incoming confirmed external Transfer WHEN convert THEN header has down icon, confirmed status, received title`() {
// Arrange
val tx = txInfo(
type = TransactionType.Transfer,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
// Act
val header = converter.convert(tx).header
// Assert
assertThat(header.icon).isEqualTo(TxIcon.Vector(Icons.ic_arrow_down_20))
assertThat(header.status).isEqualTo(TransactionItemUM.Content.Status.Confirmed)
assertThat(header.title).isEqualTo(resourceReference(R.string.common_received))
}
@Test
fun `GIVEN outgoing external Transfer WHEN convert THEN sent title`() {
// Arrange
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = true,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
// Act
val header = converter.convert(tx).header
// Assert
assertThat(header.title).isEqualTo(resourceReference(R.string.common_sent))
}
@Test
fun `GIVEN incoming Transfer from own address WHEN convert THEN transferred title`() {
// Arrange — the counterparty is one of the user's own deposit addresses.
val ownConverter = onChainConverter(ownAddresses = setOf(USER_ADDRESS))
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = false,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
// Act
val header = ownConverter.convert(tx).header
// Assert
assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred))
}
@Test
fun `GIVEN outgoing Transfer to own address WHEN convert THEN transferred title`() {
// Arrange
val ownConverter = onChainConverter(ownAddresses = setOf(USER_ADDRESS))
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = true,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
// Act
val header = ownConverter.convert(tx).header
// Assert
assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred))
}
@Test
fun `GIVEN Swap WHEN convert THEN header has exchange icon`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap)
// Act
val header = converter.convert(tx).header
// Assert
assertThat(header.icon).isEqualTo(TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20))
}
@Test
fun `GIVEN a menu WHEN convert THEN header carries it verbatim`() {
// Arrange — the converter is handed a ready menu; it must place it on the header untouched.
val item = TxHistoryDetailsUM.MenuItemUM(
icon = Icons.ic_arrow_down_20,
title = resourceReference(R.string.common_share),
onClick = {},
)
// Act
val header = onChainConverter(menu = persistentListOf(item))
.convert(txInfo(type = TransactionType.Transfer)).header
// Assert
assertThat(header.menu).containsExactly(item)
}
// endregion
// region Amount block
@Test
fun `GIVEN incoming Transfer WHEN convert THEN amount block has plus sign and not failed`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer, isOutgoing = false)
// Act
val amountBlock = converter.convert(tx).amountBlock
// Assert
assertThat(amountBlock.amount.resolveString()).startsWith("+ ")
assertThat(amountBlock.isFailed).isFalse()
}
@Test
fun `GIVEN outgoing Transfer WHEN convert THEN amount block has minus sign`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer, isOutgoing = true)
// Act
val amountBlock = converter.convert(tx).amountBlock
// Assert
assertThat(amountBlock.amount.resolveString()).startsWith("- ")
}
@Test
fun `GIVEN zero amount WHEN convert THEN amount block has no sign`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer, isOutgoing = true, amount = BigDecimal.ZERO)
// Act
val amount = converter.convert(tx).amountBlock.amount.resolveString()
// Assert
assertThat(amount).doesNotContain("+")
assertThat(amount).doesNotContain("-")
}
@Test
fun `GIVEN failed outgoing Transfer WHEN convert THEN amount block is failed and drops the sign`() {
// Arrange
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = true,
status = TxInfo.TransactionStatus.Failed,
)
// Act
val amountBlock = converter.convert(tx).amountBlock
// Assert
assertThat(amountBlock.isFailed).isTrue()
val amount = amountBlock.amount.resolveString()
assertThat(amount).doesNotContain("+")
assertThat(amount).doesNotContain("-")
}
// endregion
// region Counterparty
@Test
fun `GIVEN no interaction address WHEN convert THEN counterparty is null`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer, interactionAddressType = null)
// Act
val counterparty = converter.convert(tx).counterparty
// Assert
assertThat(counterparty).isNull()
}
@Test
fun `GIVEN incoming Transfer with User address WHEN convert THEN address-avatar counterparty with From label`() {
// Arrange
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = false,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
// Act
val counterparty = converter.convert(tx).counterparty
// Assert
assertThat(counterparty?.avatar).isEqualTo(TxHistoryDetailsUM.CounterpartyAvatar.Address(USER_ADDRESS))
assertThat(counterparty?.label).isEqualTo(resourceReference(R.string.common_from))
}
@Test
fun `GIVEN outgoing Transfer with User address WHEN convert THEN counterparty has Recipient label`() {
// Arrange
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = true,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
// Act
val counterparty = converter.convert(tx).counterparty
// Assert
assertThat(counterparty?.label).isEqualTo(resourceReference(R.string.send_recipient))
}
@Test
fun `GIVEN address counterparty WHEN onCopyClick invoked THEN raw address is copied`() {
// Arrange
val tx = txInfo(
type = TransactionType.Transfer,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val counterparty = converter.convert(tx).counterparty
// Act
counterparty?.onCopyClick?.invoke()
// Assert
assertThat(copiedAddresses).containsExactly(USER_ADDRESS)
}
// endregion
// region Network-fee row
@Test
fun `GIVEN tx with fee WHEN convert THEN single network-fee row`() {
// Arrange
val tx = txInfo(
type = TransactionType.Transfer,
fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18),
)
// Act
val rows = converter.convert(tx).rows
// Assert
assertThat(rows).hasSize(1)
assertThat(rows.first().label).isEqualTo(resourceReference(R.string.common_network_fee_title))
assertThat(rows.first().value.resolveString()).contains("ETH")
}
@Test
fun `GIVEN tx without fee WHEN convert THEN no rows`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer, fee = null)
// Act
val rows = converter.convert(tx).rows
// Assert
assertThat(rows).isEmpty()
}
// endregion
// region Validator row
@Test
fun `GIVEN Vote tx with resolved validator having website WHEN convert THEN validator row links to the website`() {
// Arrange — the Vote type carries the validator address; the yield resolves it to name + website.
val tx = txInfo(type = TransactionType.Staking.Vote(validatorAddress = VALIDATOR_ADDRESS))
// Act
val rows = onChainConverter(validators = listOf(validator())).convert(tx).rows
// Assert
val validatorRow = rows.single()
assertThat(validatorRow.label).isEqualTo(resourceReference(R.string.staking_validator))
assertThat(validatorRow.value.resolveString()).isEqualTo("Lido Finance")
assertThat(validatorRow.trailingIconRes).isEqualTo(R.drawable.ic_arrow_top_right_24)
validatorRow.onClick?.invoke()
assertThat(openedUrls).containsExactly(VALIDATOR_URL)
}
@Test
fun `GIVEN Stake tx with validator interaction address WHEN convert THEN validator row resolved`() {
// Arrange — non-Vote staking types surface the validator through the interaction address (e.g. Solana Stake).
val tx = txInfo(
type = TransactionType.Staking.Stake,
interactionAddressType = TxInfo.InteractionAddressType.Validator(VALIDATOR_ADDRESS),
)
// Act
val rows = onChainConverter(validators = listOf(validator())).convert(tx).rows
// Assert
assertThat(rows.single().value.resolveString()).isEqualTo("Lido Finance")
}
@Test
fun `GIVEN Stake tx with validator destination address WHEN convert THEN validator row resolved`() {
// Arrange — the validator can also arrive as the destination address type.
val tx = txInfo(
type = TransactionType.Staking.Stake,
destinationType = TxInfo.DestinationType.Single(
addressType = TxInfo.AddressType.Validator(VALIDATOR_ADDRESS),
),
)
// Act
val rows = onChainConverter(validators = listOf(validator())).convert(tx).rows
// Assert
assertThat(rows.single().value.resolveString()).isEqualTo("Lido Finance")
}
@Test
fun `GIVEN resolved validator without website WHEN convert THEN validator row has no link`() {
// Arrange
val tx = txInfo(type = TransactionType.Staking.Vote(validatorAddress = VALIDATOR_ADDRESS))
// Act
val rows = onChainConverter(validators = listOf(validator(website = null))).convert(tx).rows
// Assert
val validatorRow = rows.single()
assertThat(validatorRow.value.resolveString()).isEqualTo("Lido Finance")
assertThat(validatorRow.trailingIconRes).isNull()
assertThat(validatorRow.onClick).isNull()
}
@Test
fun `GIVEN validator row and network fee WHEN convert THEN validator row precedes the fee row`() {
// Arrange
val tx = txInfo(
type = TransactionType.Staking.Vote(validatorAddress = VALIDATOR_ADDRESS),
fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18),
)
// Act
val rows = onChainConverter(validators = listOf(validator())).convert(tx).rows
// Assert
assertThat(rows.map { it.label }).containsExactly(
resourceReference(R.string.staking_validator),
resourceReference(R.string.common_network_fee_title),
).inOrder()
}
@Test
fun `GIVEN staking tx with address absent from yield WHEN convert THEN no validator row`() {
// Arrange — the tx carries a validator address, but the current yield does not list it.
val tx = txInfo(type = TransactionType.Staking.Vote(validatorAddress = "0xunknown"))
// Act
val rows = onChainConverter(validators = listOf(validator())).convert(tx).rows
// Assert
assertThat(rows).isEmpty()
}
@Test
fun `GIVEN staking tx with no validator address WHEN convert THEN no validator row`() {
// Arrange — ClaimRewards carries no validator address at all.
val tx = txInfo(type = TransactionType.Staking.ClaimRewards)
// Act
val rows = onChainConverter(validators = listOf(validator())).convert(tx).rows
// Assert
assertThat(rows).isEmpty()
}
@Test
fun `GIVEN non-staking tx with validators available WHEN convert THEN no validator row`() {
// Arrange — a plain transfer is never a staking op, so the validator row is not offered.
val tx = txInfo(
type = TransactionType.Transfer,
interactionAddressType = TxInfo.InteractionAddressType.Validator(VALIDATOR_ADDRESS),
)
// Act
val rows = onChainConverter(validators = listOf(validator())).convert(tx).rows
// Assert
assertThat(rows).isEmpty()
}
// endregion
// region Protocol row (yield-supply)
@Test
fun `GIVEN yield-supply tx WHEN convert THEN protocol row shows the hard-wired Aave protocol`() {
// Arrange — yield-supply is a single hard-wired integration (Aave), so the value is constant, not resolved.
val tx = txInfo(type = TransactionType.YieldSupply.Enter(address = USER_ADDRESS))
// Act
val row = converter.convert(tx).rows.single()
// Assert
assertThat(row.label).isEqualTo(resourceReference(R.string.staking_validator))
assertThat(row.value).isEqualTo(resourceReference(R.string.yield_module_provider))
assertThat(row.trailingIconRes).isNull()
assertThat(row.onClick).isNull()
}
@Test
fun `GIVEN yield-supply tx with network fee WHEN convert THEN protocol row precedes the fee row`() {
// Arrange
val tx = txInfo(
type = TransactionType.YieldSupply.Topup,
fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18),
)
// Act
val rows = converter.convert(tx).rows
// Assert
assertThat(rows.map { it.label }).containsExactly(
resourceReference(R.string.staking_validator),
resourceReference(R.string.common_network_fee_title),
).inOrder()
}
// endregion
}

View file

@ -0,0 +1,271 @@
package com.tangem.features.txhistory.converter
import android.text.format.DateFormat
import androidx.compose.ui.graphics.Color
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.SdkAmount
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.model.TxHistoryLookupContext
import com.tangem.features.txhistory.model.WalletInfo
import com.tangem.test.mock.MockAccounts
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import java.math.BigDecimal
/**
* Shared fixtures for the transaction-details converter tests: mock currencies/accounts, the on-chain / express-deal
* builders, the [TxHistoryLookupContext] builder, and the sub-converter factories. Each converter under test
* ([OnChainTxToDetailsUMConverter], [ExpressTxToDetailsUMConverter], and the [TxHistoryInfoToTxHistoryDetailsUMConverter]
* dispatcher) has its own `*Test` extending this base.
*/
internal open class TxDetailsConverterTestBase {
protected val mockCurrencyFactory = MockCryptoCurrencyFactory()
protected val currency = mockCurrencyFactory.ethereum
// The express payout leg: a real Bitcoin coin so the resolved symbol (BTC) matches the "bitcoin" network id.
protected val bitcoin = mockCurrencyFactory.bitcoin
protected val ownAccount: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 1, name = "Family")
protected val secondAccount: Account.CryptoPortfolio =
MockAccounts.createAccount(derivationIndex = 2, name = "Savings")
protected val copiedAddresses = mutableListOf<String>()
protected val openedUrls = mutableListOf<String>()
@BeforeEach
fun setUp() {
copiedAddresses.clear()
openedUrls.clear()
// The header subtitle formats the date via DateTimeFormatters -> DateFormat.getBestDateTimePattern,
// which is an Android stub on the JVM. Mirror the DateTimeFormattersTest mock so convert() runs.
mockkStatic(DateFormat::class)
every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() }
}
@AfterEach
fun tearDown() {
unmockkStatic(DateFormat::class)
}
protected fun onChainConverter(
menu: ImmutableList<TxHistoryDetailsUM.MenuItemUM> = persistentListOf(),
validators: List<Yield.Validator> = emptyList(),
ownAddresses: Set<String> = emptySet(),
) = OnChainTxToDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
menu = menu,
validatorsByAddress = validators.associateBy(Yield.Validator::address),
onOpenValidator = openedUrls::add,
ownAddresses = ownAddresses,
)
protected fun expressConverter(
lookup: TxHistoryLookupContext = lookupOf(),
menu: ImmutableList<TxHistoryDetailsUM.MenuItemUM> = persistentListOf(),
) = ExpressTxToDetailsUMConverter(
onGoToProvider = openedUrls::add,
lookup = lookup,
menu = menu,
)
protected fun txInfo(
type: TransactionType,
isOutgoing: Boolean = false,
status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed,
amount: BigDecimal = BigDecimal.ONE,
interactionAddressType: TxInfo.InteractionAddressType? = null,
destinationType: TxInfo.DestinationType =
TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)),
fee: SdkAmount? = null,
): TxInfo = TxInfo(
txHash = TX_HASH,
timestampInMillis = TIMESTAMP,
isOutgoing = isOutgoing,
destinationType = destinationType,
sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS),
interactionAddressType = interactionAddressType,
status = status,
type = type,
amount = amount,
fee = fee,
)
protected fun onChain(
type: TransactionType,
isOutgoing: Boolean = false,
status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed,
amount: BigDecimal = BigDecimal.ONE,
interactionAddressType: TxInfo.InteractionAddressType? = null,
destinationType: TxInfo.DestinationType =
TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)),
fee: SdkAmount? = null,
): OnChainTx.BSDK = OnChainTx.BSDK(
txInfo(
type = type,
isOutgoing = isOutgoing,
status = status,
amount = amount,
interactionAddressType = interactionAddressType,
destinationType = destinationType,
fee = fee,
),
)
protected fun validator(
address: String = VALIDATOR_ADDRESS,
name: String = "Lido Finance",
website: String? = VALIDATOR_URL,
): Yield.Validator = Yield.Validator(
address = address,
status = Yield.Validator.ValidatorStatus.ACTIVE,
name = name,
website = website,
preferred = true,
isStrategicPartner = false,
)
protected fun provider(name: String): ExpressProvider = ExpressProvider(
providerId = "provider-1",
name = name,
type = ExpressProviderType.CEX,
imageLarge = "",
termsOfUse = null,
privacyPolicy = null,
slippage = null,
)
protected fun expressSwap(
status: ExpressExchangeStatus,
isOutgoing: Boolean = true,
txInfo: OnChainTx? = null,
provider: ExpressProvider? = null,
externalTxUrl: String? = null,
fromAddress: String? = null,
payoutAddress: String? = null,
fromCurrency: CryptoCurrency? = null,
): ExpressTx.Swap = ExpressTx.Swap(
tx = ExchangeTransaction(
txId = "swap-1",
status = status,
createdAtMillis = TIMESTAMP,
provider = provider,
payinHash = null,
payoutHash = null,
fromAddress = fromAddress,
payoutAddress = payoutAddress,
fromAsset = expressAsset(
networkId = "ethereum",
amount = BigDecimal("1.5"),
decimals = 18,
cryptoCurrency = fromCurrency,
),
toAsset = expressAsset(
networkId = "bitcoin",
amount = BigDecimal("0.001"),
decimals = 8,
cryptoCurrency = bitcoin,
),
externalTxUrl = externalTxUrl,
),
isOutgoing = isOutgoing,
txInfo = txInfo,
)
protected fun expressOnramp(
status: ExpressOnrampStatus,
txInfo: OnChainTx? = null,
externalTxUrl: String? = null,
payoutAddress: String? = null,
): ExpressTx.Onramp = ExpressTx.Onramp(
tx = OnrampTransaction(
txId = "onramp-1",
status = status,
createdAtMillis = TIMESTAMP,
provider = null,
payoutHash = null,
payoutAddress = payoutAddress,
externalTxUrl = externalTxUrl,
fromFiat = Amount(
currencySymbol = "SEK",
value = BigDecimal("100"),
decimals = 2,
type = AmountType.FiatType(code = "SEK"),
),
toAsset = expressAsset(
networkId = "bitcoin",
amount = BigDecimal("0.006"),
decimals = 8,
cryptoCurrency = bitcoin,
),
),
txInfo = txInfo,
)
protected fun expressAsset(
networkId: String,
amount: BigDecimal,
decimals: Int,
cryptoCurrency: CryptoCurrency? = null,
): ExpressTransactionAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = networkId, contractAddress = "0"),
amount = amount,
decimals = decimals,
cryptoCurrency = cryptoCurrency,
)
/** Builds a details lookup with the given per-network own-address maps. */
protected fun lookupOf(
vararg networks: Pair<Network.RawID, Map<String, Account.CryptoPortfolio>>,
isAccountsModeEnabled: Boolean = true,
walletInfoById: Map<UserWalletId, WalletInfo> = mapOf(
MockAccounts.userWalletId to WalletInfo(
name = "My Wallet",
deviceIconUM = DeviceIconUM.Card(mainColor = Color(0xFF1E1E1E), secondColor = null),
),
),
): TxHistoryLookupContext = TxHistoryLookupContext(
ownAccountByNetwork = networks.toMap(),
isAccountsModeEnabled = isAccountsModeEnabled,
walletInfoById = walletInfoById,
)
protected fun TextReference.resolveString(): String = (this as TextReference.Str).value
protected companion object {
const val TX_HASH = "0xtxhash"
const val TIMESTAMP = 1_700_000_000_000L
const val USER_ADDRESS = "0x1234567890abcdef1234"
const val VALIDATOR_ADDRESS = "0xvalidator"
const val VALIDATOR_URL = "https://lido.fi"
const val EXTERNAL_URL = "https://provider.example/tx/swap-1"
const val FROM_ADDRESS = "0xfromOwnAddress1234"
const val PAYOUT_ADDRESS = "bc1qPayoutOwnAddress"
const val EXTERNAL_ADDRESS = "bc1qExternalNonUserAddress"
}
}

View file

@ -63,7 +63,7 @@ internal class TxHistoryInfoToTransactionItemUMConverterTest {
}
@Test
fun `GIVEN on-chain pill row WHEN row clicked THEN stays on the explorer`() {
fun `GIVEN on-chain pill row WHEN row clicked THEN routes the incoming OnChainTx through onTransactionClick`() {
// Arrange
val item = OnChainTx.BSDK(txInfo(type = TransactionType.Approve))
@ -72,7 +72,7 @@ internal class TxHistoryInfoToTransactionItemUMConverterTest {
result.onClick()
// Assert
verify { txHistoryUiActions.openTxInExplorer(TX_HASH) }
verify { txHistoryUiActions.onTransactionClick(item) }
}
@Test

View file

@ -85,7 +85,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(TextReference.Str("Mint NFT"))
assertThat(result.icon).isEqualTo(TxIcon.Vector(Icons.ic_arrow_down_20))
assertThat(result.icon).isEqualTo(TxIcon.Vector(Icons.ic_document_20))
}
@Test

View file

@ -0,0 +1,130 @@
package com.tangem.features.txhistory.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.domain.models.network.TxInfo.TransactionStatus
import com.tangem.features.txhistory.impl.R
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TxHistoryTitleConverterTest {
private val converter = TxHistoryTitleConverter()
@ParameterizedTest
@ProvideTestModels
fun convert(model: TitleModel) {
// Act
val actual = converter.convert(model.tx, isOwnTransfer = model.isOwnTransfer)
// Assert
assertThat(actual).isEqualTo(model.expected)
}
@Suppress("LongMethod")
private fun provideTestModels() = listOf(
// Pills resolve to the status-aware label text (no amount)
TitleModel(
tx = txInfo(TransactionType.Approve),
expected = resRef(R.string.common_approved),
),
TitleModel(
tx = txInfo(TransactionType.Staking.Stake),
expected = resRef(R.string.common_staked),
),
TitleModel(
tx = txInfo(TransactionType.Staking.Unstake),
expected = resRef(R.string.staking_unstaked),
),
TitleModel(
tx = txInfo(TransactionType.Staking.Restake),
expected = resRef(R.string.transaction_history_rewards_restaked),
),
TitleModel(
tx = txInfo(TransactionType.Staking.Vote(validatorAddress = "0xv"), status = TransactionStatus.Failed),
expected = resRef(R.string.common_action_failed, listOf(resRef(R.string.staking_vote))),
),
TitleModel(
tx = txInfo(TransactionType.YieldSupply.Enter(address = "0xa")),
expected = resRef(R.string.yield_module_transaction_enter),
),
TitleModel(
tx = txInfo(TransactionType.YieldSupply.Exit(address = "0xa")),
expected = resRef(R.string.yield_module_transaction_exit),
),
// Content titles
TitleModel(
tx = txInfo(TransactionType.Swap, status = TransactionStatus.Confirmed),
expected = resRef(R.string.common_swapped),
),
TitleModel(
tx = txInfo(TransactionType.Swap, status = TransactionStatus.Unconfirmed),
expected = resRef(R.string.common_swapping),
),
// Transfer: own vs external, direction-aware
TitleModel(
tx = txInfo(TransactionType.Transfer, isOutgoing = true),
isOwnTransfer = true,
expected = resRef(R.string.common_transferred),
),
TitleModel(
tx = txInfo(TransactionType.Transfer, isOutgoing = true),
expected = resRef(R.string.common_sent),
),
TitleModel(
tx = txInfo(TransactionType.Transfer, isOutgoing = false),
expected = resRef(R.string.common_received),
),
TitleModel(
tx = txInfo(TransactionType.Operation(name = "Mint")),
expected = TextReference.Str("Mint"),
),
TitleModel(
tx = txInfo(TransactionType.YieldSupply.Topup),
expected = resRef(R.string.yield_module_transaction_topup),
),
TitleModel(
tx = txInfo(TransactionType.UnknownOperation),
expected = resRef(R.string.transaction_history_operation),
),
TitleModel(
tx = txInfo(TransactionType.GaslessFee),
expected = resRef(R.string.gasless_transaction_fee),
),
)
internal data class TitleModel(
val tx: TxInfo,
val expected: TextReference,
val isOwnTransfer: Boolean = false,
)
private fun txInfo(
type: TransactionType,
status: TransactionStatus = TransactionStatus.Confirmed,
isOutgoing: Boolean = false,
): TxInfo = TxInfo(
txHash = "0xtxhash",
timestampInMillis = 1_700_000_000_000L,
isOutgoing = isOutgoing,
destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User("0xdest")),
sourceType = TxInfo.SourceType.Single(address = "0xsrc"),
interactionAddressType = null,
status = status,
type = type,
amount = BigDecimal.ONE,
)
private fun resRef(id: Int): TextReference = TextReference.Res(id = id)
private fun resRef(id: Int, args: List<Any>): TextReference = TextReference.Res(
id = id,
formatArgs = WrappedList(args),
)
}

View file

@ -1,7 +1,6 @@
package com.tangem.features.txhistory.state
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.features.txhistory.entity.TxHistoryItemsUM

View file

@ -76,7 +76,9 @@ internal class TxHistoryInfoMergerTest {
fun `GIVEN rows of different timestamps WHEN merge THEN sorted by timestamp descending`() {
// Arrange
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
val express = listOf(createSwap(matchHash = "missing", createdAtMillis = 200, status = ExpressExchangeStatus.Waiting))
val express = listOf(
createSwap(matchHash = "missing", createdAtMillis = 200, status = ExpressExchangeStatus.Waiting),
)
// Act
val result = mergeTxHistoryInfos(onChain, express)

View file

@ -1,7 +1,6 @@
package com.tangem.features.txhistory.utils
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWalletId
@ -47,43 +46,41 @@ internal class TxHistoryListManagerTest {
private val currency = mockk<CryptoCurrency>(relaxed = true)
@Test
fun `GIVEN pages that are empty for the token WHEN loading THEN auto-loads through them until the end`() =
runTest {
// page 0: 2 items, then two empty-for-token pages, then 3 items on the last page → 5 items total.
val fetcher = ScriptedFetcher { call ->
when (call) {
0 -> page(itemCount = 2, isLast = false)
1 -> page(itemCount = 0, isLast = false)
2 -> page(itemCount = 0, isLast = false)
else -> page(itemCount = 3, isLast = true)
}
}
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
withLoadedManager(manager) {
// first fetch + 3 auto-loaded next pages = 4
assertThat(fetcher.fetchCount).isEqualTo(4)
assertThat(repo.loadedItemsCount()).isEqualTo(5)
assertThat(repo.status()).isInstanceOf(PaginationStatus.EndOfPagination::class.java)
fun `GIVEN pages that are empty for the token WHEN loading THEN auto-loads through them until the end`() = runTest {
// page 0: 2 items, then two empty-for-token pages, then 3 items on the last page → 5 items total.
val fetcher = ScriptedFetcher { call ->
when (call) {
0 -> page(itemCount = 2, isLast = false)
1 -> page(itemCount = 0, isLast = false)
2 -> page(itemCount = 0, isLast = false)
else -> page(itemCount = 3, isLast = true)
}
}
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
withLoadedManager(manager) {
// first fetch + 3 auto-loaded next pages = 4
assertThat(fetcher.fetchCount).isEqualTo(4)
assertThat(repo.loadedItemsCount()).isEqualTo(5)
assertThat(repo.status()).isInstanceOf(PaginationStatus.EndOfPagination::class.java)
}
}
@Test
fun `GIVEN many small non-final pages WHEN loading THEN stops once the list is long enough to scroll`() =
runTest {
// every page returns 7 items and is never the last page.
val fetcher = ScriptedFetcher { page(itemCount = 7, isLast = false) }
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
fun `GIVEN many small non-final pages WHEN loading THEN stops once the list is long enough to scroll`() = runTest {
// every page returns 7 items and is never the last page.
val fetcher = ScriptedFetcher { page(itemCount = 7, isLast = false) }
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
withLoadedManager(manager) {
// 7 -> 14 -> 21: stops after crossing AUTO_LOAD_MORE_TARGET_COUNT (20), does not keep loading.
assertThat(fetcher.fetchCount).isEqualTo(3)
assertThat(repo.loadedItemsCount()).isEqualTo(21)
assertThat(repo.status()).isInstanceOf(PaginationStatus.Paginating::class.java)
}
withLoadedManager(manager) {
// 7 -> 14 -> 21: stops after crossing AUTO_LOAD_MORE_TARGET_COUNT (20), does not keep loading.
assertThat(fetcher.fetchCount).isEqualTo(3)
assertThat(repo.loadedItemsCount()).isEqualTo(21)
assertThat(repo.status()).isInstanceOf(PaginationStatus.Paginating::class.java)
}
}
@Test
fun `GIVEN a full first page WHEN loading THEN does not auto-load more`() = runTest {
@ -99,36 +96,35 @@ internal class TxHistoryListManagerTest {
}
@Test
fun `GIVEN a gap of empty pages mid-history WHEN scrolled to the end THEN auto-loads through the gap`() =
runTest {
// A full first page (no auto-load), then two empty-for-token pages (a gap of other-token
// activity), then one final item. Mirrors a busy account where a token has a long activity gap.
val fetcher = ScriptedFetcher { call ->
when (call) {
0 -> page(itemCount = 25, isLast = false)
1 -> page(itemCount = 0, isLast = false)
2 -> page(itemCount = 0, isLast = false)
else -> page(itemCount = 1, isLast = true)
}
}
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
withLoadedManager(manager) {
// full first page → no auto-load yet, the list is scrollable.
assertThat(fetcher.fetchCount).isEqualTo(1)
assertThat(repo.loadedItemsCount()).isEqualTo(25)
// user scrolls to the bottom → one loadMore; the empty gap must be auto-bridged to the end,
// otherwise the list dead-ends and the final transaction is never reached.
manager.loadMore(userWalletId, currency)
advanceUntilIdle()
assertThat(fetcher.fetchCount).isEqualTo(4)
assertThat(repo.loadedItemsCount()).isEqualTo(26)
assertThat(repo.status()).isInstanceOf(PaginationStatus.EndOfPagination::class.java)
fun `GIVEN a gap of empty pages mid-history WHEN scrolled to the end THEN auto-loads through the gap`() = runTest {
// A full first page (no auto-load), then two empty-for-token pages (a gap of other-token
// activity), then one final item. Mirrors a busy account where a token has a long activity gap.
val fetcher = ScriptedFetcher { call ->
when (call) {
0 -> page(itemCount = 25, isLast = false)
1 -> page(itemCount = 0, isLast = false)
2 -> page(itemCount = 0, isLast = false)
else -> page(itemCount = 1, isLast = true)
}
}
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
withLoadedManager(manager) {
// full first page → no auto-load yet, the list is scrollable.
assertThat(fetcher.fetchCount).isEqualTo(1)
assertThat(repo.loadedItemsCount()).isEqualTo(25)
// user scrolls to the bottom → one loadMore; the empty gap must be auto-bridged to the end,
// otherwise the list dead-ends and the final transaction is never reached.
manager.loadMore(userWalletId, currency)
advanceUntilIdle()
assertThat(fetcher.fetchCount).isEqualTo(4)
assertThat(repo.loadedItemsCount()).isEqualTo(26)
assertThat(repo.status()).isInstanceOf(PaginationStatus.EndOfPagination::class.java)
}
}
private suspend fun TestScope.withLoadedManager(
manager: TxHistoryListManager,
@ -162,8 +158,7 @@ internal class TxHistoryListManagerTest {
legacyTxHistoryItemConverter = mockk<TxHistoryItemToTransactionStateConverter>(relaxed = true),
)
private fun page(itemCount: Int, isLast: Boolean): Page2Spec =
Page2Spec(itemCount = itemCount, isLast = isLast)
private fun page(itemCount: Int, isLast: Boolean): Page2Spec = Page2Spec(itemCount = itemCount, isLast = isLast)
private fun testDispatchers(dispatcher: CoroutineDispatcher): CoroutineDispatcherProvider =
object : CoroutineDispatcherProvider {