Updated on 2026-08-14
This commit is contained in:
parent
8edf21d95b
commit
d6a43b3b62
5 changed files with 211 additions and 2 deletions
|
|
@ -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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue