Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-16 15:01:35 +04:00
parent 8edf21d95b
commit d6a43b3b62
5 changed files with 211 additions and 2 deletions

View file

@ -12,7 +12,16 @@ internal class SendDestinationAddressTransformer(
val state = prevState as? DestinationUM.Content ?: return prevState val state = prevState as? DestinationUM.Content ?: return prevState
return state.copy( return state.copy(
addressTextField = state.addressTextField.copy(value = address, isValuePasted = isPasted), addressTextField = state.addressTextField.copy(
value = address,
isValuePasted = isPasted,
// Editing the recipient invalidates any previously resolved/recognized data. It is
// recomputed by the following validation; keeping it would let a stale canonical
// address (blockchainAddress) leak into the transaction for the new, not-yet-validated value.
blockchainAddress = null,
contactName = null,
contactIcon = null,
),
) )
} }
} }

View file

@ -7,6 +7,9 @@ internal object SendDestinationValidationStartedTransformer : Transformer<Destin
override fun transform(prevState: DestinationUM): DestinationUM { override fun transform(prevState: DestinationUM): DestinationUM {
val state = prevState as? DestinationUM.Content ?: return prevState val state = prevState as? DestinationUM.Content ?: return prevState
return state.copy(isValidating = true) // Disable the primary button while validation is pending so it cannot be pressed with a
// stale enablement carried over from the previously validated value. It is re-enabled by
// SendDestinationValidationResultTransformer only when the new value is valid.
return state.copy(isValidating = true, isPrimaryButtonEnabled = false)
} }
} }

View file

@ -52,12 +52,14 @@ import io.mockk.*
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
@ -543,6 +545,68 @@ internal class SendDestinationModelTest {
} }
} }
@Nested
inner class EditAfterResolve {
@Test
fun `GIVEN resolved address WHEN edited mid-validation THEN no stale address and button disabled`() =
runTest {
// Arrange — R1 resolves to canonical A1; R2 validation stays pending (never completes)
coEvery {
validateWalletAddressUseCase(any(), any(), eq("r1.eth"), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.ValidNamedAddress("0xCanonicalA1").right()
coEvery {
validateWalletAddressUseCase(any(), any(), eq("r2.eth"), any<List<CryptoCurrencyAddress>>(), any())
} coAnswers { awaitCancellation() }
val sut = buildModel()
advanceUntilIdle()
// R1 resolved: canonical address stored, primary button enabled
sut.onRecipientAddressValueChange("r1.eth", EnterAddressSource.InputField)
advanceUntilIdle()
assertThat(content(sut).addressTextField.blockchainAddress).isEqualTo("0xCanonicalA1")
assertThat(content(sut).isPrimaryButtonEnabled).isTrue()
// Act — replace with R2 while its validation is still in flight
sut.onRecipientAddressValueChange("r2.eth", EnterAddressSource.InputField)
runCurrent()
// Assert — no stale A1 leaks: actualAddress is the raw R2 and the button is disabled while pending
val field = content(sut).addressTextField
assertThat(field.value).isEqualTo("r2.eth")
assertThat(field.blockchainAddress).isNull()
assertThat(field.actualAddress).isEqualTo("r2.eth")
assertThat(content(sut).isValidating).isTrue()
assertThat(content(sut).isPrimaryButtonEnabled).isFalse()
}
@Test
fun `GIVEN edited recipient WHEN new value resolves valid THEN button re-enabled with new address`() = runTest {
// Arrange — both names resolve, to different canonical addresses
coEvery {
validateWalletAddressUseCase(any(), any(), eq("r1.eth"), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.ValidNamedAddress("0xCanonicalA1").right()
coEvery {
validateWalletAddressUseCase(any(), any(), eq("r2.eth"), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.ValidNamedAddress("0xCanonicalA2").right()
val sut = buildModel()
advanceUntilIdle()
sut.onRecipientAddressValueChange("r1.eth", EnterAddressSource.InputField)
advanceUntilIdle()
// Act — edit to R2 and let its validation complete
sut.onRecipientAddressValueChange("r2.eth", EnterAddressSource.InputField)
advanceUntilIdle()
// Assert — the new canonical address replaces the old one and the button is enabled again
val field = content(sut).addressTextField
assertThat(field.value).isEqualTo("r2.eth")
assertThat(field.blockchainAddress).isEqualTo("0xCanonicalA2")
assertThat(field.actualAddress).isEqualTo("0xCanonicalA2")
assertThat(content(sut).isPrimaryButtonEnabled).isTrue()
}
}
// region fixtures // region fixtures
private fun TestScope.buildModel( private fun TestScope.buildModel(

View file

@ -0,0 +1,83 @@
package com.tangem.features.send.subcomponents.destination.model.transformers
import androidx.compose.foundation.text.KeyboardOptions
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class SendDestinationAddressTransformerTest {
@Test
fun `GIVEN previously resolved address WHEN recipient edited THEN blockchainAddress and contact reset`() {
// Arrange — R1 was resolved to canonical A1 and recognized as a contact
val state = contentState(
value = "r1.eth",
blockchainAddress = "0xCanonicalAddressA1",
contactName = "Alice",
contactIcon = mockk(),
)
// Act — user replaces the recipient with a new, not-yet-validated name R2
val result = SendDestinationAddressTransformer(address = "r2.eth", isPasted = false)
.transform(state) as DestinationUM.Content
// Assert — the new value is shown and all previously resolved/recognized data is cleared,
// so actualAddress falls back to the raw R2 instead of the stale canonical A1
val addressField = result.addressTextField
assertThat(addressField.value).isEqualTo("r2.eth")
assertThat(addressField.blockchainAddress).isNull()
assertThat(addressField.contactName).isNull()
assertThat(addressField.contactIcon).isNull()
assertThat(addressField.actualAddress).isEqualTo("r2.eth")
assertThat(addressField.isValuePasted).isFalse()
}
@Test
fun `GIVEN pasted value WHEN recipient edited THEN isValuePasted propagated`() {
val state = contentState(value = "")
val result = SendDestinationAddressTransformer(address = "0xPasted", isPasted = true)
.transform(state) as DestinationUM.Content
assertThat(result.addressTextField.value).isEqualTo("0xPasted")
assertThat(result.addressTextField.isValuePasted).isTrue()
}
@Test
fun `GIVEN empty state WHEN transform THEN state unchanged`() {
val state = DestinationUM.Empty()
val result = SendDestinationAddressTransformer(address = "r2.eth", isPasted = false).transform(state)
assertThat(result).isEqualTo(state)
}
private fun contentState(
value: String,
blockchainAddress: String? = null,
contactName: String? = null,
contactIcon: AccountIconUM.CryptoPortfolio? = null,
) = DestinationUM.Content(
isPrimaryButtonEnabled = false,
addressTextField = DestinationTextFieldUM.RecipientAddress(
value = value,
keyboardOptions = KeyboardOptions.Default,
placeholder = TextReference.EMPTY,
label = TextReference.EMPTY,
isValuePasted = false,
blockchainAddress = blockchainAddress,
contactName = contactName,
contactIcon = contactIcon,
),
memoTextField = null,
recent = persistentListOf(),
wallets = persistentListOf(),
networkName = "Ethereum",
isRecentHidden = false,
)
}

View file

@ -0,0 +1,50 @@
package com.tangem.features.send.subcomponents.destination.model.transformers
import androidx.compose.foundation.text.KeyboardOptions
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class SendDestinationValidationStartedTransformerTest {
@Test
fun `GIVEN previously enabled button WHEN validation started THEN validating and button disabled`() {
// Arrange — button was enabled by a prior successful validation
val state = contentState(isPrimaryButtonEnabled = true)
// Act — a new validation begins for the edited value
val result = SendDestinationValidationStartedTransformer.transform(state) as DestinationUM.Content
// Assert — button cannot be pressed with the stale enablement while validation is pending
assertThat(result.isValidating).isTrue()
assertThat(result.isPrimaryButtonEnabled).isFalse()
}
@Test
fun `GIVEN empty state WHEN transform THEN state unchanged`() {
val state = DestinationUM.Empty()
val result = SendDestinationValidationStartedTransformer.transform(state)
assertThat(result).isEqualTo(state)
}
private fun contentState(isPrimaryButtonEnabled: Boolean) = DestinationUM.Content(
isPrimaryButtonEnabled = isPrimaryButtonEnabled,
addressTextField = DestinationTextFieldUM.RecipientAddress(
value = "r2.eth",
keyboardOptions = KeyboardOptions.Default,
placeholder = TextReference.EMPTY,
label = TextReference.EMPTY,
isValuePasted = false,
),
memoTextField = null,
recent = persistentListOf(),
wallets = persistentListOf(),
networkName = "Ethereum",
isRecentHidden = false,
)
}