Improve digit interactions on regV5 verification screen.

This commit is contained in:
Greyson Parrelli
2026-07-07 13:31:00 -04:00
parent 1358fefeee
commit 8b4a38a2c6
7 changed files with 406 additions and 134 deletions
@@ -319,7 +319,7 @@ private fun PinDescription(
Text(
text = titleString,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
textAlign = TextAlign.Start,
modifier = Modifier
.fillMaxWidth()
.attachDebugLogHelper()
@@ -33,10 +33,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -74,8 +71,7 @@ fun VerificationCodeScreen(
onEvent: (VerificationCodeScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
var digits by remember { mutableStateOf(List(6) { "" }) }
val focusRequesters = remember { List(6) { FocusRequester() } }
val focusRequesters = remember { List(VerificationCodeState.CODE_LENGTH) { FocusRequester() } }
val snackbarHostState = remember { SnackbarHostState() }
val resources = LocalResources.current
@@ -88,20 +84,12 @@ fun VerificationCodeScreen(
}
}
LaunchedEffect(digits) {
if (digits.all { it.isNotEmpty() } && !state.isSubmittingCode) {
val code = digits.joinToString("")
onEvent(VerificationCodeScreenEvents.CodeEntered(code))
}
}
LaunchedEffect(state.autoFillCode) {
val code = state.autoFillCode ?: return@LaunchedEffect
if (code.length == 6 && code.all { it.isDigit() } && !state.isSubmittingCode) {
if (code.length == VerificationCodeState.CODE_LENGTH && code.all { it.isDigit() } && !state.isSubmittingCode) {
code.forEachIndexed { index, digit ->
digits = digits.toMutableList().also { it[index] = digit.toString() }
delay(200)
onEvent(VerificationCodeScreenEvents.DigitChanged(index, digit.toString()))
}
}
onEvent(VerificationCodeScreenEvents.ConsumeAutoFillCode)
@@ -112,8 +100,6 @@ fun VerificationCodeScreen(
when (event) {
VerificationCodeState.OneTimeEvent.IncorrectVerificationCode -> {
digits = List(6) { "" }
focusRequesters[0].requestFocus()
snackbarHostState.showSnackbar(resources.getString(R.string.VerificationCodeScreen__incorrect_code))
}
@@ -144,8 +130,8 @@ fun VerificationCodeScreen(
onEvent(VerificationCodeScreenEvents.ConsumeInnerOneTimeEvent)
}
LaunchedEffect(Unit) {
focusRequesters[0].requestFocus()
LaunchedEffect(state.focusedDigitIndex) {
focusRequesters[state.focusedDigitIndex].requestFocus()
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
@@ -160,21 +146,17 @@ fun VerificationCodeScreen(
is RegistrationScaffold.Params.OnePane -> OnePaneLayout(
params = layoutParams,
innerPadding = innerPadding,
digits = digits,
focusRequesters = focusRequesters,
state = state,
onEvent = onEvent,
onDigitsChanged = { digits = it }
onEvent = onEvent
)
is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout(
params = layoutParams,
innerPadding = innerPadding,
digits = digits,
focusRequesters = focusRequesters,
state = state,
onEvent = onEvent,
onDigitsChanged = { digits = it }
onEvent = onEvent
)
}
}
@@ -184,11 +166,9 @@ fun VerificationCodeScreen(
private fun OnePaneLayout(
params: RegistrationScaffold.Params.OnePane,
innerPadding: PaddingValues,
digits: List<String>,
focusRequesters: List<FocusRequester>,
state: VerificationCodeState,
onEvent: (VerificationCodeScreenEvents) -> Unit,
onDigitsChanged: (List<String>) -> Unit
onEvent: (VerificationCodeScreenEvents) -> Unit
) {
val scrollState = rememberScrollState()
@@ -210,10 +190,9 @@ private fun OnePaneLayout(
Spacer(modifier = Modifier.height(32.dp))
CodeField(
digits = digits,
focusRequesters = focusRequesters,
state = state,
onDigitsChanged = onDigitsChanged
emitter = onEvent
)
Spacer(modifier = Modifier.height(32.dp))
@@ -244,11 +223,9 @@ private fun OnePaneLayout(
private fun TwoPaneLayout(
params: RegistrationScaffold.Params.TwoPane,
innerPadding: PaddingValues,
digits: List<String>,
focusRequesters: List<FocusRequester>,
state: VerificationCodeState,
onEvent: (VerificationCodeScreenEvents) -> Unit,
onDigitsChanged: (List<String>) -> Unit
onEvent: (VerificationCodeScreenEvents) -> Unit
) {
val firstPaneScrollState = rememberScrollState()
val secondPaneScrollState = rememberScrollState()
@@ -277,10 +254,9 @@ private fun TwoPaneLayout(
.padding(paddingValues)
) {
CodeField(
digits = digits,
focusRequesters = focusRequesters,
state = state,
onDigitsChanged = onDigitsChanged
emitter = onEvent
)
Spacer(modifier = Modifier.height(32.dp))
@@ -325,11 +301,12 @@ private fun TroubleButton(onEvent: (VerificationCodeScreenEvents) -> Unit) {
@Composable
private fun CodeField(
digits: List<String>,
focusRequesters: List<FocusRequester>,
state: VerificationCodeState,
onDigitsChanged: (List<String>) -> Unit
emitter: (VerificationCodeScreenEvents) -> Unit
) {
val digits = state.digits
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
@@ -344,16 +321,7 @@ private fun CodeField(
for (i in 0..2) {
DigitField(
value = digits[i],
onValueChange = { newValue, isBackspace ->
handleDigitChange(
index = i,
newValue = newValue,
isBackspace = isBackspace,
digits = digits,
focusRequesters = focusRequesters,
onDigitsChanged = onDigitsChanged
)
},
onValueChange = { newValue -> emitter(VerificationCodeScreenEvents.DigitChanged(i, newValue)) },
focusRequester = focusRequesters[i],
testTag = when (i) {
0 -> TestTags.VERIFICATION_CODE_DIGIT_0
@@ -380,16 +348,7 @@ private fun CodeField(
}
DigitField(
value = digits[i],
onValueChange = { newValue, isBackspace ->
handleDigitChange(
index = i,
newValue = newValue,
isBackspace = isBackspace,
digits = digits,
focusRequesters = focusRequesters,
onDigitsChanged = onDigitsChanged
)
},
onValueChange = { newValue -> emitter(VerificationCodeScreenEvents.DigitChanged(i, newValue)) },
focusRequester = focusRequesters[i],
testTag = when (i) {
3 -> TestTags.VERIFICATION_CODE_DIGIT_3
@@ -494,37 +453,10 @@ private fun Description(state: VerificationCodeState, onEvent: (VerificationCode
}
}
private fun handleDigitChange(
index: Int,
newValue: String,
isBackspace: Boolean,
digits: List<String>,
focusRequesters: List<FocusRequester>,
onDigitsChanged: (List<String>) -> Unit
) {
if (isBackspace) {
val deleteAt = if (digits[index].isNotEmpty()) index else index - 1
if (deleteAt >= 0) {
onDigitsChanged(
digits.toMutableList().apply {
for (j in deleteAt until 5) {
this[j] = this[j + 1]
}
this[5] = ""
}
)
focusRequesters[(index - 1).coerceAtLeast(0)].requestFocus()
}
} else if (newValue.isNotEmpty() && newValue[0].isDigit()) {
onDigitsChanged(digits.toMutableList().apply { this[index] = newValue })
focusRequesters[(index + 1).coerceAtMost(5)].requestFocus()
}
}
@Composable
private fun DigitField(
value: String,
onValueChange: (String, Boolean) -> Unit,
onValueChange: (String) -> Unit,
focusRequester: FocusRequester,
testTag: String,
modifier: Modifier = Modifier,
@@ -532,24 +464,14 @@ private fun DigitField(
) {
OutlinedTextField(
value = value,
onValueChange = { newValue ->
val capped = if (newValue.length > 1) {
if (newValue.first().toString() == value) {
newValue.last().toString()
} else {
newValue.first().toString()
}
} else newValue
val isBackspace = capped.isEmpty() && value.isNotEmpty()
onValueChange(capped, isBackspace)
},
onValueChange = onValueChange,
modifier = modifier
.width(48.dp)
.focusRequester(focusRequester)
.testTag(testTag)
.onKeyEvent { keyEvent ->
if ((keyEvent.key == Key.Backspace || keyEvent.key == Key.Delete) && value.isEmpty()) {
onValueChange("", true)
onValueChange("")
true
} else {
false
@@ -12,6 +12,15 @@ sealed class VerificationCodeScreenEvents {
override fun toString(): String = "CodeEntered(code=${code.censor()})"
}
/**
* The raw [value] of the digit field at [index] changed. The view model interprets it: a single digit is recorded
* (submitting once the full code is present), an empty [value] is a backspace (deleting a digit and shifting the
* following ones left), and multi-character input (e.g. a pasted "123-456") is treated as a pasted code.
*/
data class DigitChanged(val index: Int, val value: String) : VerificationCodeScreenEvents() {
override fun toString(): String = "DigitChanged(index=$index)"
}
/**
* A verification code was automatically retrieved from an incoming SMS via the Play Services SMS retriever.
*/
@@ -16,9 +16,30 @@ data class VerificationCodeState(
val rateLimits: SmsAndCallRateLimits = SmsAndCallRateLimits(),
val incorrectCodeAttempts: Int = 0,
val autoFillCode: String? = null,
val digits: List<String> = List(CODE_LENGTH) { "" },
val focusedDigitIndex: Int = 0,
val oneTimeEvent: OneTimeEvent? = null
) {
override fun toString(): String = "VerificationCodeState(sessionMetadata=${sessionMetadata?.let { "present" }}, e164=$e164, isSubmittingCode=$isSubmittingCode, rateLimits=$rateLimits, incorrectCodeAttempts=$incorrectCodeAttempts, autoFillCode=${autoFillCode?.let { "present" }}, oneTimeEvent=$oneTimeEvent)"
override fun toString(): String = "VerificationCodeState(sessionMetadata=${sessionMetadata?.let { "present" }}, e164=$e164, isSubmittingCode=$isSubmittingCode, rateLimits=$rateLimits, incorrectCodeAttempts=$incorrectCodeAttempts, autoFillCode=${autoFillCode?.let { "present" }}, digitsEntered=${digits.count { it.isNotEmpty() }}, focusedDigitIndex=$focusedDigitIndex, oneTimeEvent=$oneTimeEvent)"
/**
* The full code as currently entered. Only meaningful when [isComplete] is true.
*/
val code: String get() = digits.joinToString("")
/**
* True once every digit field has a value.
*/
val isComplete: Boolean get() = digits.size == CODE_LENGTH && digits.all { it.isNotEmpty() }
companion object {
const val CODE_LENGTH = 6
/**
* A fully empty set of digits, used to reset the fields.
*/
fun emptyDigits(): List<String> = List(CODE_LENGTH) { "" }
}
sealed interface OneTimeEvent {
data object NetworkError : OneTimeEvent
@@ -55,6 +55,8 @@ class VerificationCodeViewModel(
companion object {
private val TAG = Log.tag(VerificationCodeViewModel::class)
private const val CODE_LENGTH = VerificationCodeState.CODE_LENGTH
/**
* How old the in-progress registration data can be before we assume the verification session has expired and
* restart the flow. Checked whenever the screen is foregrounded.
@@ -118,10 +120,8 @@ class VerificationCodeViewModel(
@VisibleForTesting
suspend fun applyEvent(state: VerificationCodeState, event: VerificationCodeScreenEvents, stateEmitter: (VerificationCodeState) -> Unit) {
val result = when (event) {
is VerificationCodeScreenEvents.CodeEntered -> {
stateEmitter(state.copy(isSubmittingCode = true))
applyCodeEntered(state, event.code).copy(isSubmittingCode = false)
}
is VerificationCodeScreenEvents.CodeEntered -> submitCode(state, event.code, stateEmitter)
is VerificationCodeScreenEvents.DigitChanged -> applyDigitChanged(state, event.index, event.value, stateEmitter)
is VerificationCodeScreenEvents.CodeAutoFilled -> state.copy(autoFillCode = event.code)
is VerificationCodeScreenEvents.ConsumeAutoFillCode -> state.copy(autoFillCode = null)
is VerificationCodeScreenEvents.WrongNumber -> state.also { parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry) }
@@ -186,6 +186,91 @@ class VerificationCodeViewModel(
)
}
/**
* Interprets the raw [value] reported by the digit field at [index] and updates the digits and focus accordingly:
*
* - an empty [value] is a backspace, deleting a digit and moving focus back
* - a single digit is recorded and focus advances, submitting once the full code is present
* - multi-character input (e.g. a pasted "123-456") is treated as a pasted code
*/
private suspend fun applyDigitChanged(
state: VerificationCodeState,
index: Int,
value: String,
stateEmitter: (VerificationCodeState) -> Unit
): VerificationCodeState {
check(index in state.digits.indices) { "[DigitChanged] Out of bounds index $index." }
if (value.isEmpty()) {
return deleteDigit(state, index)
}
val currentValue = state.digits[index]
val remainder = if (currentValue.isNotEmpty()) value.replaceFirst(currentValue, "") else value
val addedDigits = remainder.filter { it.isDigit() }
return when {
addedDigits.isEmpty() -> state
addedDigits.length == 1 -> {
val updated = state.copy(
digits = state.digits.toMutableList().also { it[index] = addedDigits },
focusedDigitIndex = (index + 1).coerceAtMost(CODE_LENGTH - 1)
)
if (updated.isComplete && !updated.isSubmittingCode) {
submitCode(updated, updated.code, stateEmitter)
} else {
updated
}
}
else -> applyPastedCode(state, remainder)
}
}
/**
* Deletes the digit at [index] (or the previous one, if [index] is already empty), shifts any following digits left
* to fill the gap, and moves focus back.
*/
private fun deleteDigit(state: VerificationCodeState, index: Int): VerificationCodeState {
val deleteAt = if (state.digits[index].isNotEmpty()) index else index - 1
if (deleteAt < 0) {
return state
}
val newDigits = state.digits.toMutableList().apply {
for (j in deleteAt until CODE_LENGTH - 1) {
this[j] = this[j + 1]
}
this[CODE_LENGTH - 1] = ""
}
return state.copy(digits = newDigits, focusedDigitIndex = (index - 1).coerceAtLeast(0))
}
/**
* Emits an intermediate submitting state and then runs the submission, clearing the submitting flag when done.
*/
private suspend fun submitCode(state: VerificationCodeState, code: String, stateEmitter: (VerificationCodeState) -> Unit): VerificationCodeState {
stateEmitter(state.copy(isSubmittingCode = true))
return applyCodeEntered(state, code).copy(isSubmittingCode = false)
}
/**
* Strips any formatting (e.g. a hyphen) from pasted text and, if what remains is a full code, populates the fields
* by reusing the [VerificationCodeState.autoFillCode] path. Pasted text that doesn't contain a full code is ignored.
*/
private fun applyPastedCode(state: VerificationCodeState, rawCode: String): VerificationCodeState {
val digits = rawCode.filter { it.isDigit() }
if (digits.length != CODE_LENGTH) {
Log.w(TAG, "[DigitChanged] Ignoring pasted text containing ${digits.length} digits.")
return state
}
return state.copy(autoFillCode = digits)
}
private suspend fun applyCodeEntered(inputState: VerificationCodeState, code: String): VerificationCodeState {
var state = inputState
var sessionMetadata = state.sessionMetadata ?: return state.also {
@@ -205,7 +290,7 @@ class VerificationCodeViewModel(
is NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode -> {
Log.w(TAG, "[SubmitCode] Invalid sessionId or verification code entered. This is distinct from an *incorrect* verification code. Body: ${error.message}")
val newAttempts = state.incorrectCodeAttempts + 1
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode, incorrectCodeAttempts = newAttempts)
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode, incorrectCodeAttempts = newAttempts, digits = VerificationCodeState.emptyDigits(), focusedDigitIndex = 0)
}
is NetworkController.SubmitVerificationCodeError.SessionNotFound -> {
Log.w(TAG, "[SubmitCode] Session not found: ${error.message}")
@@ -244,7 +329,7 @@ class VerificationCodeViewModel(
if (!sessionMetadata.verified) {
Log.w(TAG, "[SubmitCode] Verification code was incorrect.")
val newAttempts = state.incorrectCodeAttempts + 1
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode, incorrectCodeAttempts = newAttempts)
return state.copy(oneTimeEvent = OneTimeEvent.IncorrectVerificationCode, incorrectCodeAttempts = newAttempts, digits = VerificationCodeState.emptyDigits(), focusedDigitIndex = 0)
}
// Attempt to register
@@ -7,6 +7,7 @@ package org.signal.registration.screens.verificationcode
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
@@ -143,67 +144,100 @@ class VerificationCodeScreenTest {
}
@Test
fun `entering complete code emits CodeEntered event`() {
fun `entering a digit emits DigitChanged for that field`() {
// Given
var emittedEvent: VerificationCodeScreenEvents? = null
val emittedEvents = mutableListOf<VerificationCodeScreenEvents>()
composeTestRule.setContent {
SignalTheme {
VerificationCodeScreen(
state = VerificationCodeState(),
onEvent = { event ->
emittedEvent = event
}
onEvent = { emittedEvents.add(it) }
)
}
}
// When - enter all 6 digits
// When
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).performTextInput("1")
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_1).performTextInput("2")
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_2).performTextInput("3")
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_3).performTextInput("4")
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_4).performTextInput("5")
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).performTextInput("6")
composeTestRule.waitForIdle()
// Then
assert(emittedEvent is VerificationCodeScreenEvents.CodeEntered) {
"Expected CodeEntered event but got $emittedEvent"
val digitChanges = emittedEvents.filterIsInstance<VerificationCodeScreenEvents.DigitChanged>()
assert(digitChanges.contains(VerificationCodeScreenEvents.DigitChanged(0, "1"))) {
"Expected DigitChanged(0, 1) but got $digitChanges"
}
assert((emittedEvent as VerificationCodeScreenEvents.CodeEntered).code == "123456") {
"Expected code '123456' but got ${(emittedEvent as VerificationCodeScreenEvents.CodeEntered).code}"
assert(digitChanges.contains(VerificationCodeScreenEvents.DigitChanged(1, "2"))) {
"Expected DigitChanged(1, 2) but got $digitChanges"
}
}
@Test
fun `autoFillCode populates the fields and emits CodeEntered`() {
fun `screen renders the digits from state`() {
// Given
var emittedEvent: VerificationCodeScreenEvents? = null
composeTestRule.setContent {
SignalTheme {
VerificationCodeScreen(
state = VerificationCodeState(digits = listOf("1", "2", "3", "4", "5", "6")),
onEvent = {}
)
}
}
// Then
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).assertTextEquals("1")
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_5).assertTextEquals("6")
}
@Test
fun `pasting into a field emits DigitChanged with the raw text`() {
// Given
val emittedEvents = mutableListOf<VerificationCodeScreenEvents>()
composeTestRule.setContent {
SignalTheme {
VerificationCodeScreen(
state = VerificationCodeState(),
onEvent = { emittedEvents.add(it) }
)
}
}
// When - paste the entire code, including the hyphen, into the first field
composeTestRule.onNodeWithTag(TestTags.VERIFICATION_CODE_DIGIT_0).performTextInput("123-456")
composeTestRule.waitForIdle()
// Then
val digitChanges = emittedEvents.filterIsInstance<VerificationCodeScreenEvents.DigitChanged>()
assert(digitChanges.contains(VerificationCodeScreenEvents.DigitChanged(0, "123-456"))) {
"Expected DigitChanged(0, 123-456) but got $digitChanges"
}
}
@Test
fun `autoFillCode emits a DigitChanged for each digit`() {
// Given
val emittedEvents = mutableListOf<VerificationCodeScreenEvents>()
composeTestRule.setContent {
SignalTheme {
VerificationCodeScreen(
state = VerificationCodeState(autoFillCode = "123456"),
onEvent = { event ->
if (event is VerificationCodeScreenEvents.CodeEntered) {
emittedEvent = event
}
}
onEvent = { emittedEvents.add(it) }
)
}
}
// When - the auto-fill effect staggers digits into the fields
composeTestRule.waitUntil(timeoutMillis = 5_000) { emittedEvent != null }
// When - the auto-fill effect populates the fields
composeTestRule.waitUntil(timeoutMillis = 5_000) {
emittedEvents.filterIsInstance<VerificationCodeScreenEvents.DigitChanged>().size == 6
}
// Then
assert(emittedEvent is VerificationCodeScreenEvents.CodeEntered) {
"Expected CodeEntered event but got $emittedEvent"
}
assert((emittedEvent as VerificationCodeScreenEvents.CodeEntered).code == "123456") {
"Expected code '123456' but got ${(emittedEvent as VerificationCodeScreenEvents.CodeEntered).code}"
val digitChanges = emittedEvents.filterIsInstance<VerificationCodeScreenEvents.DigitChanged>()
assert(digitChanges == (0 until 6).map { VerificationCodeScreenEvents.DigitChanged(it, "${it + 1}") }) {
"Expected a DigitChanged per digit but got $digitChanges"
}
}
@@ -196,6 +196,45 @@ class VerificationCodeViewModelTest {
assertThat(emittedStates.last().autoFillCode).isEqualTo("123456")
}
@Test
fun `DigitChanged with pasted hyphenated text stores the stripped code in autoFillCode`() = runTest {
val initialState = VerificationCodeState()
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.DigitChanged(0, "123-456"),
stateEmitter
)
assertThat(emittedStates.last().autoFillCode).isEqualTo("123456")
}
@Test
fun `DigitChanged with a pasted plain code stores it in autoFillCode`() = runTest {
val initialState = VerificationCodeState()
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.DigitChanged(0, "123456"),
stateEmitter
)
assertThat(emittedStates.last().autoFillCode).isEqualTo("123456")
}
@Test
fun `DigitChanged with pasted text of the wrong length is ignored`() = runTest {
val initialState = VerificationCodeState()
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.DigitChanged(0, "12-345"),
stateEmitter
)
assertThat(emittedStates.last().autoFillCode).isNull()
}
@Test
fun `ConsumeAutoFillCode clears autoFillCode`() = runTest {
val initialState = VerificationCodeState(autoFillCode = "123456")
@@ -223,6 +262,168 @@ class VerificationCodeViewModelTest {
assertThat(vm.state.value.autoFillCode).isEqualTo("123456")
}
// ==================== applyEvent: DigitChanged Tests ====================
@Test
fun `DigitChanged records the value at the given index`() = runTest {
val initialState = VerificationCodeState()
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.DigitChanged(2, "7"),
stateEmitter
)
assertThat(emittedStates.last().digits).isEqualTo(listOf("", "", "7", "", "", ""))
}
@Test
fun `DigitChanged advances the focused digit index`() = runTest {
val initialState = VerificationCodeState()
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.DigitChanged(2, "7"),
stateEmitter
)
assertThat(emittedStates.last().focusedDigitIndex).isEqualTo(3)
}
@Test
fun `DigitChanged with an empty value moves the focused digit index back`() = runTest {
val initialState = VerificationCodeState(digits = listOf("1", "2", "3", "", "", ""))
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.DigitChanged(2, ""),
stateEmitter
)
assertThat(emittedStates.last().focusedDigitIndex).isEqualTo(1)
}
@Test
fun `DigitChanged with an out-of-bounds index throws`() = runTest {
var threw = false
try {
viewModel.applyEvent(
VerificationCodeState(),
VerificationCodeScreenEvents.DigitChanged(9, "7"),
stateEmitter
)
} catch (e: IllegalStateException) {
threw = true
}
assertThat(threw).isTrue()
}
@Test
fun `DigitChanged completing the code submits it`() = runTest {
val sessionMetadata = createSessionMetadata()
val initialState = VerificationCodeState(
sessionMetadata = sessionMetadata,
e164 = "+15551234567",
digits = listOf("1", "2", "3", "4", "5", "")
)
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
RequestResult.NonSuccess(
NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode("Wrong code")
)
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.DigitChanged(5, "6"),
stateEmitter
)
coVerify { mockRepository.submitVerificationCode(sessionMetadata.id, "123456") }
assertThat(emittedStates.first().isSubmittingCode).isTrue()
assertThat(emittedStates.last().isSubmittingCode).isEqualTo(false)
}
@Test
fun `DigitChanged does not submit until the code is complete`() = runTest {
val initialState = VerificationCodeState(
sessionMetadata = createSessionMetadata(),
e164 = "+15551234567",
digits = listOf("1", "2", "3", "4", "", "")
)
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.DigitChanged(4, "5"),
stateEmitter
)
coVerify(exactly = 0) { mockRepository.submitVerificationCode(any(), any()) }
assertThat(emittedStates.last().isSubmittingCode).isEqualTo(false)
}
@Test
fun `an incorrect code clears the entered digits`() = runTest {
val initialState = VerificationCodeState(
sessionMetadata = createSessionMetadata(),
e164 = "+15551234567",
digits = listOf("1", "2", "3", "4", "5", "")
)
coEvery { mockRepository.submitVerificationCode(any(), any()) } returns
RequestResult.NonSuccess(
NetworkController.SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode("Wrong code")
)
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.DigitChanged(5, "6"),
stateEmitter
)
assertThat(emittedStates.last().digits).isEqualTo(listOf("", "", "", "", "", ""))
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(VerificationCodeState.OneTimeEvent.IncorrectVerificationCode)
}
@Test
fun `DigitChanged with an empty value clears the digit at the index`() = runTest {
val initialState = VerificationCodeState(digits = listOf("1", "2", "3", "", "", ""))
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.DigitChanged(2, ""),
stateEmitter
)
assertThat(emittedStates.last().digits).isEqualTo(listOf("1", "2", "", "", "", ""))
}
@Test
fun `DigitChanged with an empty value shifts the following digits left`() = runTest {
val initialState = VerificationCodeState(digits = listOf("1", "2", "3", "4", "5", "6"))
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.DigitChanged(2, ""),
stateEmitter
)
assertThat(emittedStates.last().digits).isEqualTo(listOf("1", "2", "4", "5", "6", ""))
}
@Test
fun `DigitChanged with an empty value on an empty field clears the previous digit`() = runTest {
val initialState = VerificationCodeState(digits = listOf("1", "2", "", "", "", ""))
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.DigitChanged(2, ""),
stateEmitter
)
assertThat(emittedStates.last().digits).isEqualTo(listOf("1", "", "", "", "", ""))
}
// ==================== applyEvent: WrongNumber Tests ====================
@Test