diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt index 73a0b7d670..20e26b2e38 100644 --- a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt @@ -63,6 +63,17 @@ sealed interface OnChainTx : TxHistoryInfo { */ fun TxInfo.identityKey(): String = "$txHash|$type" +/** + * Hash of the matched on-chain leg, used to open the row in a block explorer; `null` when there is no + * blockchain tx to link to — an [ExpressTx] whose on-chain leg has not matched yet. The express `txId` + * must never stand in here: it is not an on-chain hash and would build a broken explorer URL. + */ +inline val TxHistoryInfo.explorerHash: String? + get() = when (this) { + is OnChainTx.BSDK -> txInfo.txHash + is ExpressTx -> matchHash + } + /** * A history row backed by an express operation. It is a thin wrapper over the standalone express * model ([ExchangeTransaction] / [OnrampTransaction]), adding only the history-view concerns: diff --git a/features/txhistory/api/build.gradle.kts b/features/txhistory/api/build.gradle.kts index b26a92aadc..3b5eceb638 100644 --- a/features/txhistory/api/build.gradle.kts +++ b/features/txhistory/api/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { /** Domain models */ api(projects.domain.models) + api(projects.domain.txhistory) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsSlotConfig.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsSlotConfig.kt new file mode 100644 index 0000000000..50b3057860 --- /dev/null +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsSlotConfig.kt @@ -0,0 +1,12 @@ +package com.tangem.features.txhistory.component + +import com.tangem.domain.txhistory.model.TxHistoryInfo +import kotlinx.coroutines.flow.Flow + +/** + * Activation config for the transaction-details child slot owned by the host (e.g. token-details). + * + + * with `serializer = null` — the details sheet is intentionally not restored after process death. + */ +data class TxHistoryDetailsSlotConfig(val txHistoryInfo: Flow) \ No newline at end of file diff --git a/features/txhistory/impl/build.gradle.kts b/features/txhistory/impl/build.gradle.kts index 34f003cbab..245b8d24c9 100644 --- a/features/txhistory/impl/build.gradle.kts +++ b/features/txhistory/impl/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { /* Tests */ testImplementation(projects.common.test) + testImplementation(projects.test.core) testImplementation(projects.domain.onramp.models) testImplementation(deps.test.junit5) testImplementation(deps.test.mockk) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressExchangeStatusToUiStatusConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressExchangeStatusToUiStatusConverter.kt new file mode 100644 index 0000000000..740abe27c4 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressExchangeStatusToUiStatusConverter.kt @@ -0,0 +1,37 @@ +package com.tangem.features.txhistory.converter + +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.utils.converter.Converter + +/** + * Collapses the typed swap status into a UI [Status] bucket: the single success state + * ([Finished][ExpressExchangeStatus.Finished]), the failure/return states ([Failed][ExpressExchangeStatus.Failed]/ + * [TxFailed][ExpressExchangeStatus.TxFailed]/[Refunded][ExpressExchangeStatus.Refunded]/ + * [Expired][ExpressExchangeStatus.Expired]/[Unknown][ExpressExchangeStatus.Unknown]) → [Failed][Status.Failed], + * everything in flight (incl. [Verifying][ExpressExchangeStatus.Verifying] and [Paused][ExpressExchangeStatus.Paused]) + * → [Unconfirmed][Status.Unconfirmed]. + */ +internal class ExpressExchangeStatusToUiStatusConverter : Converter { + + override fun convert(value: ExpressExchangeStatus): Status = when (value) { + ExpressExchangeStatus.Finished -> Status.Confirmed + ExpressExchangeStatus.Failed, + ExpressExchangeStatus.TxFailed, + ExpressExchangeStatus.Refunded, + ExpressExchangeStatus.Expired, + ExpressExchangeStatus.Unknown, + -> Status.Failed + ExpressExchangeStatus.Preview, + ExpressExchangeStatus.Created, + ExpressExchangeStatus.ExchangeTxSent, + ExpressExchangeStatus.Waiting, + ExpressExchangeStatus.WaitingTxHash, + ExpressExchangeStatus.Confirming, + ExpressExchangeStatus.Exchanging, + ExpressExchangeStatus.Sending, + ExpressExchangeStatus.Verifying, + ExpressExchangeStatus.Paused, + -> Status.Unconfirmed + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverter.kt new file mode 100644 index 0000000000..4e8cea0f4f --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverter.kt @@ -0,0 +1,30 @@ +package com.tangem.features.txhistory.converter + +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +import com.tangem.domain.express.models.ExpressOnrampStatus +import com.tangem.utils.converter.Converter + +/** + * Collapses the typed onramp status into a UI [Status] bucket: the single success state + * ([Finished][ExpressOnrampStatus.Finished]), the failure states ([Failed][ExpressOnrampStatus.Failed]/ + * [Expired][ExpressOnrampStatus.Expired]/[Unknown][ExpressOnrampStatus.Unknown]) → [Failed][Status.Failed], + * everything in flight → [Unconfirmed][Status.Unconfirmed]. + */ +internal class ExpressOnrampStatusToUiStatusConverter : Converter { + + override fun convert(value: ExpressOnrampStatus): Status = when (value) { + ExpressOnrampStatus.Finished -> Status.Confirmed + ExpressOnrampStatus.Failed, + ExpressOnrampStatus.Expired, + ExpressOnrampStatus.Unknown, + -> Status.Failed + ExpressOnrampStatus.Created, + ExpressOnrampStatus.WaitingForPayment, + ExpressOnrampStatus.PaymentProcessing, + ExpressOnrampStatus.Verifying, + ExpressOnrampStatus.Paid, + ExpressOnrampStatus.Sending, + ExpressOnrampStatus.Paused, + -> Status.Unconfirmed + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt new file mode 100644 index 0000000000..71b734ba25 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt @@ -0,0 +1,230 @@ +package com.tangem.features.txhistory.converter + +import androidx.annotation.StringRes +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +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.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.network.TxInfo.TransactionType +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.entity.TxHistoryDetailsUM.StatusBannerUM.Severity +import com.tangem.features.txhistory.impl.R +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero +import com.tangem.utils.toBriefAddressFormat +import kotlinx.collections.immutable.persistentListOf +import org.joda.time.DateTime + +/** + * Converts a [TxHistoryInfo] row to a [TxHistoryDetailsUM] for the in-app transaction details card. + * + * The dispatch mirrors the row converters: an [OnChainTx.BSDK] always renders as [TxHistoryDetailsUM.SingleAsset] + * (a two-asset swap surfaces as [ExpressTx.Swap], handled separately), while an [ExpressTx] (swap / onramp) currently + * produces a header-only [TxHistoryDetailsUM.TwoAssets] with the express status banner. The express legs (`from`/`to` + * amounts, currencies, fiat) are populated in a follow-up ([REDACTED_TASK_KEY]). + */ +internal class TxHistoryInfoToTxHistoryDetailsUMConverter( + private val currency: CryptoCurrency, + private val onCopyAddress: (String) -> Unit, +) : Converter { + + private val iconStateConverter = CryptoCurrencyToIconStateConverter() + private val exchangeStatusConverter = ExpressExchangeStatusToUiStatusConverter() + private val onrampStatusConverter = ExpressOnrampStatusToUiStatusConverter() + + override fun convert(value: TxHistoryInfo): TxHistoryDetailsUM = when (value) { + is OnChainTx.BSDK -> convertOnChain(value.txInfo) + is ExpressTx.Swap -> convertExpressSwap(value) + is ExpressTx.Onramp -> convertExpressOnramp(value) + } + + // region On-chain (TxInfo) + + /** + * Every on-chain row renders as [TxHistoryDetailsUM.SingleAsset]. A two-asset swap always surfaces as + * [ExpressTx.Swap] (handled separately); an on-chain `TxInfo` of type `Swap` (e.g. a DEX swap with no express + * record) carries no legs, so it falls back to the single amount it does have rather than an empty two-asset card. + */ + private fun convertOnChain(value: TxInfo): TxHistoryDetailsUM = TxHistoryDetailsUM.SingleAsset( + header = value.toHeaderUM(), + amountBlock = value.toAmountBlockUM(), + counterparty = value.toCounterpartyUM(), + // TODO: TxInfo has no network fee / rate yet — empty until those fields are added to TxInfo. + rows = persistentListOf(), + ) + + private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM( + iconRes = headerIcon(), + status = status.toUiStatus(), + title = headerTitle(), + subtitle = headerSubtitle(timestampInMillis), + ) + + private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM( + currencyIcon = iconStateConverter.convert(currency), + amount = stringReference(signedAmount(currency)), + // TODO: TxInfo has no fiat amount yet — empty until the fiat field is added to TxInfo; a hardcoded + // placeholder would show a misleading value. + fiatAmount = TextReference.EMPTY, + isFailed = status is TxInfo.TransactionStatus.Failed, + ) + + /** + * Counterparty card ("Recipient" / "From"). Currently only the external-address avatar is produced — built from + * the `User` interaction address (the same source the history list uses for its external-address subtitle). + * + * The own-account / own-wallet avatars require the address->owner lookup the list assembles in + * `TxHistoryLookupContext`; wiring that into the detail model is a follow-up, so for now a counterparty that is not + * a plain external `User` address yields no card (`null`). + */ + private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? { + val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null + return TxHistoryDetailsUM.CounterpartyUM( + label = counterpartyLabel(), + title = stringReference(address.toBriefAddressFormat()), + avatar = TxHistoryDetailsUM.CounterpartyAvatar.Address(rawAddress = address), + onCopyClick = { onCopyAddress(address) }, + ) + } + + /** Section label above the counterparty: "Recipient" for outgoing transfers, "From" for incoming. */ + private fun TxInfo.counterpartyLabel(): TextReference = + if (isOutgoing) resourceReference(R.string.send_recipient) else resourceReference(R.string.common_from) + + // endregion + + // region Express (swap / onramp) + + private fun convertExpressSwap(swap: ExpressTx.Swap): TxHistoryDetailsUM.TwoAssets { + val status = exchangeStatusConverter.convert(swap.tx.status) + return TxHistoryDetailsUM.TwoAssets( + header = TxHistoryDetailsUM.HeaderUM( + iconRes = R.drawable.ic_exchange_vertical_24, + status = status, + title = status.statusAwareTitle(R.string.common_swapping, R.string.common_swapped), + subtitle = headerSubtitle(swap.timestampMillis), + ), + statusBanner = status.toStatusBannerUM(), + ) + } + + private fun convertExpressOnramp(onramp: ExpressTx.Onramp): TxHistoryDetailsUM.TwoAssets { + val status = onrampStatusConverter.convert(onramp.tx.status) + return TxHistoryDetailsUM.TwoAssets( + header = TxHistoryDetailsUM.HeaderUM( + iconRes = R.drawable.ic_tangem_card_24, + status = status, + title = status.statusAwareTitle( + R.string.tx_history_onramp_top_up, + R.string.tx_history_onramp_topped_up, + ), + subtitle = headerSubtitle(onramp.timestampMillis), + ), + statusBanner = status.toStatusBannerUM(), + ) + } + + // endregion +} + +// region Status helpers + +/** + * Express status plaque under the two-asset block, keyed on the collapsed UI [Status] bucket. + * + * A stopgap shared by on-chain swaps and express ops — [Severity.Warning] (verification) is not reachable here yet. + * [REDACTED_TODO_COMMENT] + */ +private fun Status.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM = when (this) { + is Status.Unconfirmed -> TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Info, + title = resourceReference(R.string.express_exchange_status_receiving_active), + isLoading = true, + ) + is Status.Confirmed -> TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Success, + title = resourceReference(R.string.express_exchange_status_exchanged), + isLoading = false, + ) + is Status.Failed -> TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Error, + title = resourceReference(R.string.express_exchange_status_failed), + subtitle = resourceReference(R.string.express_exchange_notification_failed_text), + isLoading = false, + ) +} + +private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (this) { + is Status.Failed -> resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending))) + is Status.Unconfirmed -> resourceReference(pending) + is Status.Confirmed -> resourceReference(confirmed) +} + +// endregion + +// region Amount building helpers + +/** + * Signed crypto amount with inline symbol, e.g. `+ 350.31 USDT` / `- 350.31 USDT`. The sign is `-` for outgoing, `+` + * otherwise, and is dropped for zero amounts and for the failed state (a failed tx moved nothing) — the UI then only + * strikes the amount through and dims it via [TxHistoryDetailsUM.AmountBlockUM.isFailed]. + */ +private fun TxInfo.signedAmount(currency: CryptoCurrency): String { + val formatted = amount.format { crypto(cryptoCurrency = currency, ignoreSymbolPosition = true) } + val prefix = when { + status is TxInfo.TransactionStatus.Failed -> "" + amount.isZero() -> "" + isOutgoing -> "${StringsSigns.MINUS} " + else -> "${StringsSigns.PLUS} " + } + return (prefix + formatted).trim() +} + +// endregion + +// region Header building helpers + +/** Type glyph. Unlike the history list, the failed state keeps the type glyph (only the color changes). */ +private fun TxInfo.headerIcon(): Int = when (type) { + is TransactionType.Swap -> R.drawable.ic_exchange_vertical_24 + else -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 +} + +private fun TxInfo.headerTitle(): TextReference = when (type) { + is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped) + is TransactionType.Transfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred) + else -> stringReference(type.toString()) +} + +private fun headerSubtitle(timestampMillis: Long): TextReference { + val dateTime = DateTime(timestampMillis) + val date = DateTimeFormatters.dateMMMdYYYY.print(dateTime) + val time = DateTimeFormatters.timeFormatter.print(dateTime) + return stringReference("$date, $time") +} + +private fun TxInfo.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (status) { + is TxInfo.TransactionStatus.Failed -> + resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending))) + is TxInfo.TransactionStatus.Unconfirmed -> resourceReference(pending) + is TxInfo.TransactionStatus.Confirmed -> resourceReference(confirmed) +} + +private fun TxInfo.TransactionStatus.toUiStatus(): Status = when (this) { + TxInfo.TransactionStatus.Confirmed -> Status.Confirmed + TxInfo.TransactionStatus.Failed -> Status.Failed + TxInfo.TransactionStatus.Unconfirmed -> Status.Unconfirmed +} + +// endregion \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt index 70bf982a6d..0afece079a 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt @@ -89,8 +89,9 @@ internal class TxInfoToTxHistoryDetailsUMConverter( private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM( currencyIcon = iconStateConverter.convert(currency), amount = stringReference(signedAmount(currency)), - // TODO: TxInfo has no fiat amount yet — placeholder until the fiat field is added to TxInfo. - fiatAmount = stringReference("\$0.00"), + // TODO: TxInfo has no fiat amount yet — empty until the fiat field is added to TxInfo; a hardcoded + // placeholder would show a misleading value. + fiatAmount = TextReference.EMPTY, isFailed = status is TxInfo.TransactionStatus.Failed, ) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt index 3b3464e998..de525f1387 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt @@ -14,7 +14,7 @@ import kotlinx.collections.immutable.ImmutableList * UI model for the in-app transaction details ("Operation") card. * * One model for all transaction types; the layout family is chosen from the transaction type by - * `TxInfoToTxHistoryDetailsUMConverter`: + * `TxHistoryInfoToTxHistoryDetailsUMConverter`: * - [SingleAsset] — Receive / Send / Transfer * - [TwoAssets] — Swap / Onramp */ @@ -131,7 +131,7 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { * * The layout is identical across counterparty kinds; the only variance is the [avatar] (see [CounterpartyAvatar]) * and whether copy is offered. Only the [CounterpartyAvatar.Address] kind is currently produced by - * [com.tangem.features.txhistory.converter.TxInfoToTxHistoryDetailsUMConverter]; the own-account / own-wallet + * [com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter]; the own-account / own-wallet * avatars are populated in a follow-up, once the detail model assembles the same address->owner lookup the list * uses (`TxHistoryLookupContext`). * diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt index cf23065cdc..fe82f69948 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt @@ -52,6 +52,18 @@ internal class HistoryTxListManager @AssistedInject constructor( val paginationStatus: Flow> = state.map { it.status }.distinctUntilChanged() + /** + * Reactive stream of a single row tracked by its [TxHistoryInfo.txId], for the in-app details sheet. + * + * Seeded with the tapped [item] so the sheet always has an immediate snapshot, then re-emits the matching row from + * the live merged list as its status changes. The seed also covers rows not present in [items] yet (e.g. a pending + * tx surfaced from the currency status), which would otherwise never resolve. + */ + fun txHistoryInfoFlow(item: TxHistoryInfo): Flow = items + .mapNotNull { list -> list.firstOrNull { it.txId == item.txId } } + .onStart { emit(item) } + .distinctUntilChanged() + @OptIn(ExperimentalCoroutinesApi::class) suspend fun init() { coroutineScope { diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverterTest.kt new file mode 100644 index 0000000000..406b89b603 --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverterTest.kt @@ -0,0 +1,50 @@ +package com.tangem.features.txhistory.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +import com.tangem.domain.express.models.ExpressOnrampStatus +import com.tangem.test.core.ProvideTestModels +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ExpressOnrampStatusToUiStatusConverterTest { + + private val converter = ExpressOnrampStatusToUiStatusConverter() + + @ParameterizedTest + @ProvideTestModels + fun convert(model: Model) { + // Act + val actual = converter.convert(model.status) + + // Assert + assertThat(actual).isEqualTo(model.expected) + } + + @Test + fun `GIVEN every onramp status WHEN listing test models THEN all enum entries are covered`() { + // Asserts the mapping table below stays in lockstep with the enum, so a newly added status + // can never silently fall through with an untested UI bucket. + val covered = provideTestModels().map { it.status }.toSet() + + assertThat(covered).containsExactlyElementsIn(ExpressOnrampStatus.entries) + } + + private fun provideTestModels() = listOf( + Model(status = ExpressOnrampStatus.Finished, expected = Status.Confirmed), + Model(status = ExpressOnrampStatus.Failed, expected = Status.Failed), + Model(status = ExpressOnrampStatus.Expired, expected = Status.Failed), + Model(status = ExpressOnrampStatus.Unknown, expected = Status.Failed), + Model(status = ExpressOnrampStatus.Created, expected = Status.Unconfirmed), + Model(status = ExpressOnrampStatus.WaitingForPayment, expected = Status.Unconfirmed), + Model(status = ExpressOnrampStatus.PaymentProcessing, expected = Status.Unconfirmed), + Model(status = ExpressOnrampStatus.Verifying, expected = Status.Unconfirmed), + Model(status = ExpressOnrampStatus.Paid, expected = Status.Unconfirmed), + Model(status = ExpressOnrampStatus.Sending, expected = Status.Unconfirmed), + Model(status = ExpressOnrampStatus.Paused, expected = Status.Unconfirmed), + ) + + internal data class Model(val status: ExpressOnrampStatus, val expected: Status) +} \ No newline at end of file