From 1beceb4744547c9032059e5ebb24f550452f7892 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 3 Jul 2026 11:34:04 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../ExpressTxToDetailsUMConverterTest.kt | 555 +++++++++ ...pressTxToTransactionItemUMConverterTest.kt | 58 +- .../OnChainTxToDetailsUMConverterTest.kt | 471 ++++++++ .../converter/TxDetailsConverterTestBase.kt | 271 +++++ ...oryInfoToTransactionItemUMConverterTest.kt | 4 +- ...ryInfoToTxHistoryDetailsUMConverterTest.kt | 1044 +---------------- ...oryItemToTransactionItemUMConverterTest.kt | 2 +- .../converter/TxHistoryTitleConverterTest.kt | 130 ++ .../state/TxHistoryStateControllerTest.kt | 1 - .../utils/TxHistoryInfoMergerTest.kt | 4 +- .../utils/TxHistoryListManagerTest.kt | 119 +- 11 files changed, 1581 insertions(+), 1078 deletions(-) create mode 100644 features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverterTest.kt create mode 100644 features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/OnChainTxToDetailsUMConverterTest.kt create mode 100644 features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxDetailsConverterTestBase.kt create mode 100644 features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryTitleConverterTest.kt diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverterTest.kt new file mode 100644 index 0000000000..3d3f705a76 --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverterTest.kt @@ -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 +} \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt index f8ff49c6ff..3e67bcb269 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt @@ -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( diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/OnChainTxToDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/OnChainTxToDetailsUMConverterTest.kt new file mode 100644 index 0000000000..ba0412e862 --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/OnChainTxToDetailsUMConverterTest.kt @@ -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 +} \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxDetailsConverterTestBase.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxDetailsConverterTestBase.kt new file mode 100644 index 0000000000..7321973cf0 --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxDetailsConverterTestBase.kt @@ -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() + protected val openedUrls = mutableListOf() + + @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 = persistentListOf(), + validators: List = emptyList(), + ownAddresses: Set = 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 = 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>, + isAccountsModeEnabled: Boolean = true, + walletInfoById: Map = 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" + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt index ccf65e9aa9..0237261551 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt @@ -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 diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt index 00b1cfd951..b97747a106 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt @@ -1,235 +1,75 @@ package com.tangem.features.txhistory.converter -import android.text.format.DateFormat -import androidx.compose.ui.graphics.Color import com.google.common.truth.Truth.assertThat -import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.core.ui.components.transactions.state.TransactionItemUM -import com.tangem.core.ui.components.transactions.state.TxIcon -import com.tangem.core.ui.ds.image.DeviceIconUM -import com.tangem.core.ui.extensions.TextReference 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_down_20 -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.core.ui.res.generated.icons.ic_copy_24 import com.tangem.core.ui.res.generated.icons.ic_globe_24 import com.tangem.core.ui.res.generated.icons.ic_share_android_24 -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.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.domain.txhistory.model.TxHistoryInfo import com.tangem.features.txhistory.entity.TxHistoryDetailsUM import com.tangem.features.txhistory.impl.R import com.tangem.features.txhistory.model.TxHistoryLookupContext -import com.tangem.features.txhistory.model.WalletInfo -import com.tangem.test.core.ProvideTestModels -import com.tangem.test.mock.MockAccounts -import io.mockk.every -import io.mockk.mockkStatic -import io.mockk.unmockkStatic -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach 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 TxHistoryInfoToTxHistoryDetailsUMConverterTest { +/** + * The dispatcher owns three things: routing each [TxHistoryInfo] shape to its sub-converter, building the shared header + * menu once from the callbacks, and deriving the on-chain own-address set from the lookup. Per-shape conversion detail + * is covered by [OnChainTxToDetailsUMConverterTest] / [ExpressTxToDetailsUMConverterTest]. + */ +internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest : TxDetailsConverterTestBase() { - private val mockCurrencyFactory = MockCryptoCurrencyFactory() - private val currency = mockCurrencyFactory.ethereum - - // The express payout leg: a real Bitcoin coin so the resolved symbol (BTC) matches the "bitcoin" network id. - private val bitcoin = mockCurrencyFactory.bitcoin - private val ownAccount: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 1, name = "Family") - private val secondAccount: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 2, name = "Savings") - private val copiedAddresses = mutableListOf() - private val openedUrls = mutableListOf() - private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter( - currency = currency, - onCopyAddress = copiedAddresses::add, - onGoToProvider = openedUrls::add, - ) - - @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) - } - - // region On-chain (TxInfo) + // region Routing @Test - fun `GIVEN on-chain Swap WHEN convert THEN SingleAsset fallback`() { - // A two-asset swap always surfaces as ExpressTx.Swap; an on-chain TxInfo of type Swap has no legs, - // so it falls back to the single amount it does carry rather than an empty two-asset card. - // Arrange - val tx = onChain(type = TransactionType.Swap) - + fun `GIVEN on-chain tx WHEN convert THEN SingleAsset`() { // Act - val result = converter.convert(tx) + val result = dispatcher().convert(onChain(type = TransactionType.Transfer)) // Assert assertThat(result).isInstanceOf(TxHistoryDetailsUM.SingleAsset::class.java) } - @ParameterizedTest - @ProvideTestModels - fun `GIVEN non-Swap TransactionType WHEN convert THEN SingleAsset`(type: TransactionType) { - // Act - val result = converter.convert(onChain(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.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 = onChain( - type = TransactionType.Transfer, - interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), - ) - + fun `GIVEN express swap WHEN convert THEN TwoAssets`() { // Act - val header = converter.convert(tx).header + val result = dispatcher().convert(expressSwap(status = ExpressExchangeStatus.Exchanging)) // 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)) + assertThat(result).isInstanceOf(TxHistoryDetailsUM.TwoAssets::class.java) } @Test - fun `GIVEN outgoing external Transfer WHEN convert THEN sent title`() { - // Arrange - val tx = onChain( - type = TransactionType.Transfer, - isOutgoing = true, - interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), - ) - + fun `GIVEN express onramp WHEN convert THEN TwoAssets`() { // Act - val header = converter.convert(tx).header + val result = dispatcher().convert(expressOnramp(status = ExpressOnrampStatus.Finished)) // Assert - assertThat(header.title).isEqualTo(resourceReference(R.string.common_sent)) + assertThat(result).isInstanceOf(TxHistoryDetailsUM.TwoAssets::class.java) } - @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 = TxHistoryInfoToTxHistoryDetailsUMConverter( - currency = currency, - onCopyAddress = copiedAddresses::add, - onGoToProvider = openedUrls::add, - lookup = lookupOf(currency.network.id.rawId to mapOf(USER_ADDRESS to ownAccount)), - ) - val tx = onChain( - type = TransactionType.Transfer, - isOutgoing = false, - interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), - ) + // endregion - // Act - val header = ownConverter.convert(tx).header - - // Assert - assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred)) - } + // region Header menu building @Test - fun `GIVEN outgoing Transfer to own address WHEN convert THEN transferred title`() { - // Arrange - val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter( - currency = currency, - onCopyAddress = copiedAddresses::add, - onGoToProvider = openedUrls::add, - lookup = lookupOf(currency.network.id.rawId to mapOf(USER_ADDRESS to ownAccount)), - ) - val tx = onChain( - 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 = onChain(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 menu callbacks WHEN convert THEN header menu has copy-id, share and explore rows wired`() { + fun `GIVEN all menu callbacks WHEN convert THEN header menu has copy-id, share and explore rows wired`() { // Arrange var copiedTxId = false var shared = false var explored = false - val menuConverter = TxHistoryInfoToTxHistoryDetailsUMConverter( - currency = currency, - onCopyAddress = copiedAddresses::add, - onGoToProvider = openedUrls::add, + val converter = dispatcher( onCopyTxId = { copiedTxId = true }, onShare = { shared = true }, onExplore = { explored = true }, ) // Act - val menu = menuConverter.convert(onChain(type = TransactionType.Transfer)).header.menu + val menu = converter.convert(onChain(type = TransactionType.Transfer)).header.menu // Assert assertThat(menu).hasSize(3) @@ -251,17 +91,10 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN no share and explore callbacks WHEN convert THEN header menu drops the share and explore rows`() { // Arrange — onShare/onExplore are null (e.g. an express op with no on-chain leg to share or open yet). - val menuConverter = TxHistoryInfoToTxHistoryDetailsUMConverter( - currency = currency, - onCopyAddress = copiedAddresses::add, - onGoToProvider = openedUrls::add, - onCopyTxId = {}, - onShare = null, - onExplore = null, - ) + val converter = dispatcher(onCopyTxId = {}, onShare = null, onExplore = null) // Act - val menu = menuConverter.convert(onChain(type = TransactionType.Transfer)).header.menu + val menu = converter.convert(onChain(type = TransactionType.Transfer)).header.menu // Assert assertThat(menu).hasSize(1) @@ -271,839 +104,82 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN no menu callbacks WHEN convert THEN header menu is empty`() { // Arrange — every menu action is absent (e.g. a blank tx id with no on-chain leg to share or open). - val menuConverter = TxHistoryInfoToTxHistoryDetailsUMConverter( - currency = currency, - onCopyAddress = copiedAddresses::add, - onGoToProvider = openedUrls::add, - onCopyTxId = null, - onShare = null, - onExplore = null, - ) + val converter = dispatcher(onCopyTxId = null, onShare = null, onExplore = null) // Act - val menu = menuConverter.convert(onChain(type = TransactionType.Transfer)).header.menu + val menu = converter.convert(onChain(type = TransactionType.Transfer)).header.menu // Assert assertThat(menu).isEmpty() } @Test - fun `GIVEN incoming Transfer WHEN convert THEN amount block has plus sign and not failed`() { - // Arrange - val tx = onChain(type = TransactionType.Transfer, isOutgoing = false) + fun `GIVEN menu WHEN convert express swap THEN same menu is shared on the express header`() { + // Arrange — the menu is built once and handed to both sub-converters. + val converter = dispatcher(onCopyTxId = {}) // Act - val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock + val menu = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging)).header.menu // 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 = onChain(type = TransactionType.Transfer, isOutgoing = true) - - // Act - val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock - - // Assert - assertThat(amountBlock.amount.resolveString()).startsWith("- ") - } - - @Test - fun `GIVEN zero amount WHEN convert THEN amount block has no sign`() { - // Arrange - val tx = onChain(type = TransactionType.Transfer, isOutgoing = true, amount = BigDecimal.ZERO) - - // Act - val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock - - // Assert - val amount = amountBlock.amount.resolveString() - 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 = onChain( - type = TransactionType.Transfer, - isOutgoing = true, - status = TxInfo.TransactionStatus.Failed, - ) - - // Act - val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock - - // Assert - assertThat(amountBlock.isFailed).isTrue() - val amount = amountBlock.amount.resolveString() - assertThat(amount).doesNotContain("+") - assertThat(amount).doesNotContain("-") - } - - @Test - fun `GIVEN no interaction address WHEN convert THEN counterparty is null`() { - // Arrange - val tx = onChain(type = TransactionType.Transfer, interactionAddressType = null) - - // Act - val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).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 = onChain( - type = TransactionType.Transfer, - isOutgoing = false, - interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), - ) - - // Act - val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).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 = onChain( - type = TransactionType.Transfer, - isOutgoing = true, - interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), - ) - - // Act - val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).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 = onChain( - type = TransactionType.Transfer, - interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), - ) - val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).counterparty - - // Act - counterparty?.onCopyClick?.invoke() - - // Assert - assertThat(copiedAddresses).containsExactly(USER_ADDRESS) - } - - @Test - fun `GIVEN tx with fee WHEN convert THEN single network-fee row`() { - // Arrange - val tx = onChain( - type = TransactionType.Transfer, - fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18), - ) - - // Act - val rows = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).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 = onChain(type = TransactionType.Transfer, fee = null) - - // Act - val rows = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).rows - - // Assert - assertThat(rows).isEmpty() + assertThat(menu).hasSize(1) + assertThat(menu[0].title).isEqualTo(resourceReference(R.string.common_transaction_id)) } // endregion - // region Express (swap / onramp) + // region Lookup -> own addresses threading @Test - fun `GIVEN express swap WHEN convert THEN TwoAssets with exchange icon`() { - // Act - val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging)) - - // Assert - assertThat(result).isInstanceOf(TxHistoryDetailsUM.TwoAssets::class.java) - assertThat(result.header.icon).isEqualTo(TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20)) - } - - @Test - fun `GIVEN exchanging express swap WHEN convert THEN info status banner with loader`() { - // Act - val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging)) - val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner - - // Assert - assertThat(banner).isEqualTo( - TxHistoryDetailsUM.StatusBannerUM( - severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info, - title = resourceReference(R.string.express_exchange_status_exchanging_active), - isLoading = true, - ), + fun `GIVEN incoming Transfer from an address owned on the currency network WHEN convert THEN transferred title`() { + // Arrange — the dispatcher derives the own-address set from lookup[currency.network], driving the on-chain title. + val converter = dispatcher( + lookup = lookupOf(currency.network.id.rawId to mapOf(USER_ADDRESS to ownAccount)), ) - } - - @Test - fun `GIVEN verifying express swap WHEN convert THEN warning status banner with verification subtitle`() { - // Act - val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Verifying)) - val banner = (swap as TxHistoryDetailsUM.TwoAssets).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 swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished)) - val banner = (swap as TxHistoryDetailsUM.TwoAssets).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 - val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Unknown)) - - // Assert — nothing to surface, the plaque is hidden. - assertThat((swap as TxHistoryDetailsUM.TwoAssets).statusBanner).isNull() - } - - @Test - fun `GIVEN failed express swap WHEN convert THEN error status banner with refund subtitle`() { - // Act - val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Failed)) - val banner = (swap as TxHistoryDetailsUM.TwoAssets).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 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)) as TxHistoryDetailsUM.TwoAssets - - // 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)) as TxHistoryDetailsUM.TwoAssets - - // 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)) as TxHistoryDetailsUM.TwoAssets - - // 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 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), + val tx = onChain( + type = TransactionType.Transfer, + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), ) // Act - val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg)) as TxHistoryDetailsUM.TwoAssets - - // 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, - ), - ) as TxHistoryDetailsUM.TwoAssets - - // 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.label).isEqualTo(resourceReference(R.string.express_provider)) - 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")), - ) as TxHistoryDetailsUM.TwoAssets + val header = converter.convert(tx).header // Assert - val providerRow = result.rows.first() - assertThat(providerRow.value.resolveString()).isEqualTo("Mercuryo") - assertThat(providerRow.trailingIconRes).isNull() - assertThat(providerRow.onClick).isNull() + assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred)) } @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), + fun `GIVEN incoming Transfer from an address owned only on another network WHEN convert THEN received title`() { + // Arrange — the address is owned, but on bitcoin, not the viewed ethereum currency, so it is not "own" here. + val converter = dispatcher( + lookup = lookupOf(bitcoin.network.id.rawId to mapOf(USER_ADDRESS to ownAccount)), + ) + val tx = onChain( + type = TransactionType.Transfer, + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), ) // Act - val result = converter.convert( - expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg, provider = provider(name = "Changelly")), - ) as TxHistoryDetailsUM.TwoAssets + val header = converter.convert(tx).header // 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")), - ) as TxHistoryDetailsUM.TwoAssets - - // 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) as TxHistoryDetailsUM.TwoAssets - - // 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), - ) as TxHistoryDetailsUM.TwoAssets - - // 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") - } - - @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)) as TxHistoryDetailsUM.TwoAssets - - // 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)) as TxHistoryDetailsUM.TwoAssets - - // 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() - } - - @Test - fun `GIVEN finished express onramp WHEN convert THEN TwoAssets with success banner`() { - // Act - val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Finished)) as TxHistoryDetailsUM.TwoAssets - - // Assert - assertThat(result.header.icon).isEqualTo(TxIcon.Vector(Icons.ic_card_20)) - assertThat(result.statusBanner).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 result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Verifying)) as TxHistoryDetailsUM.TwoAssets - - // Assert - assertThat(result.statusBanner).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 - val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Unknown)) as TxHistoryDetailsUM.TwoAssets - - // Assert — nothing to surface, the plaque is hidden. - assertThat(result.statusBanner).isNull() - } - - @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), - ) as TxHistoryDetailsUM.TwoAssets - - // 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), - ) as TxHistoryDetailsUM.TwoAssets - - // 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), - ) as TxHistoryDetailsUM.TwoAssets - - // 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), - ) as TxHistoryDetailsUM.TwoAssets - - // 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), - ) as TxHistoryDetailsUM.TwoAssets - - // Assert - assertThat(result.providerButton).isNull() + assertThat(header.title).isEqualTo(resourceReference(R.string.common_received)) } // endregion - // region Express 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 = ownConverter(lookup).convert(swap) as TxHistoryDetailsUM.TwoAssets - - // 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 = ownConverter(lookup).convert(swap) as TxHistoryDetailsUM.TwoAssets - - // Assert - val owner = 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 = ownConverter(lookupOf()).convert(swap) as TxHistoryDetailsUM.TwoAssets - - // Assert - val owner = 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)) - as TxHistoryDetailsUM.TwoAssets - - // 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 = ownConverter(lookup).convert(onramp) as TxHistoryDetailsUM.TwoAssets - - // 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 = ownConverter(lookup).convert(swap) as TxHistoryDetailsUM.TwoAssets - - // 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 = ownConverter(lookup).convert(swap) as TxHistoryDetailsUM.TwoAssets - - // Assert — ambiguous, so it falls back to the external address rather than guessing an owner. - val owner = result.from?.owner - assertThat(owner).isInstanceOf(TxHistoryDetailsUM.AssetOwnerUM.Address::class.java) - assertThat((owner as TxHistoryDetailsUM.AssetOwnerUM.Address).rawAddress).isEqualTo(FROM_ADDRESS) - } - - // endregion - - private fun onChain( - type: TransactionType, - isOutgoing: Boolean = false, - status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed, - amount: BigDecimal = BigDecimal.ONE, - interactionAddressType: TxInfo.InteractionAddressType? = null, - fee: SdkAmount? = null, - ): OnChainTx.BSDK = OnChainTx.BSDK( - TxInfo( - txHash = TX_HASH, - timestampInMillis = TIMESTAMP, - isOutgoing = isOutgoing, - destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)), - sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS), - interactionAddressType = interactionAddressType, - status = status, - type = type, - amount = amount, - fee = fee, - ), - ) - - private fun provider(name: String): ExpressProvider = ExpressProvider( - providerId = "provider-1", - name = name, - type = ExpressProviderType.CEX, - imageLarge = "", - termsOfUse = null, - privacyPolicy = null, - slippage = null, - ) - - private 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, - ) - - private 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, - ) - - private 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. */ - private fun lookupOf( - vararg networks: Pair>, - isAccountsModeEnabled: Boolean = true, - walletInfoById: Map = 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, - ) - - private fun ownConverter(lookup: TxHistoryLookupContext) = TxHistoryInfoToTxHistoryDetailsUMConverter( + private fun dispatcher( + onCopyTxId: (() -> Unit)? = null, + onShare: (() -> Unit)? = null, + onExplore: (() -> Unit)? = null, + lookup: TxHistoryLookupContext = lookupOf(), + ) = TxHistoryInfoToTxHistoryDetailsUMConverter( currency = currency, onCopyAddress = copiedAddresses::add, onGoToProvider = openedUrls::add, + onCopyTxId = onCopyTxId, + onShare = onShare, + onExplore = onExplore, lookup = lookup, ) - - private fun TextReference.resolveString(): String = (this as TextReference.Str).value - - private 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 EXTERNAL_URL = "https://provider.example/tx/swap-1" - const val FROM_ADDRESS = "0xfromOwnAddress1234" - const val PAYOUT_ADDRESS = "bc1qPayoutOwnAddress" - const val EXTERNAL_ADDRESS = "bc1qExternalNonUserAddress" - } } \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt index 2ba322a55d..90d47531a8 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt @@ -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 diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryTitleConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryTitleConverterTest.kt new file mode 100644 index 0000000000..3466a5aa4b --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryTitleConverterTest.kt @@ -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): TextReference = TextReference.Res( + id = id, + formatArgs = WrappedList(args), + ) +} \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/state/TxHistoryStateControllerTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/state/TxHistoryStateControllerTest.kt index 328bdf2d48..ea13035c1f 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/state/TxHistoryStateControllerTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/state/TxHistoryStateControllerTest.kt @@ -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 diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt index fd87fa1aaa..039ed35ed6 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt @@ -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) diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManagerTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManagerTest.kt index e3fb2bd693..f9f999a5b2 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManagerTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManagerTest.kt @@ -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(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(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 {