Updated on 2026-08-14
This commit is contained in:
parent
19df6fefae
commit
1beceb4744
11 changed files with 1581 additions and 1078 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue