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
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 {
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.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
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
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,
)
}