Add checkSettings with loading, error and success states to incoming config

This commit is contained in:
Wolf-Martell Montwé 2023-06-27 10:30:36 +02:00
parent 22d1784d60
commit e49d9e5c5e
No known key found for this signature in database
GPG key ID: 6D45B21512ACBF72
6 changed files with 465 additions and 120 deletions

View file

@ -36,7 +36,7 @@ internal class AccountAutoDiscoveryViewModel(
Event.OnNextClicked -> onNext()
Event.OnBackClicked -> onBack()
Event.OnRetryClicked -> retry()
Event.OnRetryClicked -> onRetry()
Event.OnEditConfigurationClicked -> navigateNext()
}
}
@ -84,7 +84,7 @@ internal class AccountAutoDiscoveryViewModel(
}
}
private fun retry() {
private fun onRetry() {
updateState {
it.copy(error = null)
}

View file

@ -28,6 +28,9 @@ import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
import app.k9mail.feature.account.setup.R
import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType
import app.k9mail.feature.account.setup.ui.common.item.ErrorItem
import app.k9mail.feature.account.setup.ui.common.item.LoadingItem
import app.k9mail.feature.account.setup.ui.common.item.SuccessItem
import app.k9mail.feature.account.setup.ui.common.item.defaultItemPadding
import app.k9mail.feature.account.setup.ui.common.toResourceString
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event
@ -56,118 +59,144 @@ internal fun AccountIncomingConfigContent(
.fillMaxSize()
.imePadding(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default),
verticalArrangement = if (state.isLoading || state.error != null || state.isSuccess) {
Arrangement.spacedBy(MainTheme.spacings.double, Alignment.CenterVertically)
} else {
Arrangement.spacedBy(MainTheme.spacings.default)
},
) {
item {
Spacer(modifier = Modifier.requiredHeight(MainTheme.sizes.smaller))
}
item {
SelectInput(
options = IncomingProtocolType.all(),
selectedOption = state.protocolType,
onOptionChange = { onEvent(Event.ProtocolTypeChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_protocol_type_label),
contentPadding = defaultItemPadding(),
)
}
item {
TextInput(
text = state.server.value,
errorMessage = state.server.error?.toResourceString(resources),
onTextChange = { onEvent(Event.ServerChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_server_label),
contentPadding = defaultItemPadding(),
)
}
item {
SelectInput(
options = ConnectionSecurity.all(),
optionToStringTransformation = { it.toResourceString(resources) },
selectedOption = state.security,
onOptionChange = { onEvent(Event.SecurityChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_security_label),
contentPadding = defaultItemPadding(),
)
}
item {
NumberInput(
value = state.port.value,
errorMessage = state.port.error?.toResourceString(resources),
onValueChange = { onEvent(Event.PortChanged(it)) },
label = stringResource(id = R.string.account_setup_outgoing_config_port_label),
contentPadding = defaultItemPadding(),
)
}
item {
TextInput(
text = state.username.value,
errorMessage = state.username.error?.toResourceString(resources),
onTextChange = { onEvent(Event.UsernameChanged(it)) },
label = stringResource(id = R.string.account_setup_outgoing_config_username_label),
contentPadding = defaultItemPadding(),
)
}
item {
PasswordInput(
password = state.password.value,
errorMessage = state.password.error?.toResourceString(resources),
onPasswordChange = { onEvent(Event.PasswordChanged(it)) },
contentPadding = defaultItemPadding(),
)
}
item {
// TODO add client certificate support
SelectInput(
options = persistentListOf(
stringResource(
id = R.string.account_setup_client_certificate_none_available,
),
),
optionToStringTransformation = { it },
selectedOption = stringResource(
id = R.string.account_setup_client_certificate_none_available,
),
onOptionChange = { onEvent(Event.ClientCertificateChanged(it)) },
label = stringResource(id = R.string.account_setup_outgoing_config_client_certificate_label),
contentPadding = defaultItemPadding(),
)
}
if (state.protocolType == IncomingProtocolType.IMAP) {
if (state.isLoading) {
item(key = "loading") {
LoadingItem(
message = stringResource(id = R.string.account_setup_incoming_config_loading_message),
)
}
} else if (state.error != null) {
item(key = "error") {
ErrorItem(
title = stringResource(id = R.string.account_setup_incoming_config_loading_error),
message = state.error.toString(), // TODO map to string
onRetry = { onEvent(Event.OnRetryClicked) },
)
}
} else if (state.isSuccess) {
item(key = "success") {
SuccessItem(
message = stringResource(id = R.string.account_setup_incoming_config_success),
)
}
} else {
item {
CheckboxInput(
text = stringResource(id = R.string.account_setup_incoming_config_imap_namespace_label),
checked = state.imapAutodetectNamespaceEnabled,
onCheckedChange = { onEvent(Event.ImapAutoDetectNamespaceChanged(it)) },
Spacer(modifier = Modifier.requiredHeight(MainTheme.sizes.smaller))
}
item {
SelectInput(
options = IncomingProtocolType.all(),
selectedOption = state.protocolType,
onOptionChange = { onEvent(Event.ProtocolTypeChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_protocol_type_label),
contentPadding = defaultItemPadding(),
)
}
item {
TextInput(
text = state.imapPrefix.value,
errorMessage = state.imapPrefix.error?.toResourceString(resources),
onTextChange = { onEvent(Event.ImapPrefixChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_imap_prefix_label),
text = state.server.value,
errorMessage = state.server.error?.toResourceString(resources),
onTextChange = { onEvent(Event.ServerChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_server_label),
contentPadding = defaultItemPadding(),
)
}
}
item {
CheckboxInput(
text = stringResource(id = R.string.account_setup_incoming_config_compression_label),
checked = state.useCompression,
onCheckedChange = { onEvent(Event.UseCompressionChanged(it)) },
contentPadding = defaultItemPadding(),
)
item {
SelectInput(
options = ConnectionSecurity.all(),
optionToStringTransformation = { it.toResourceString(resources) },
selectedOption = state.security,
onOptionChange = { onEvent(Event.SecurityChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_security_label),
contentPadding = defaultItemPadding(),
)
}
item {
NumberInput(
value = state.port.value,
errorMessage = state.port.error?.toResourceString(resources),
onValueChange = { onEvent(Event.PortChanged(it)) },
label = stringResource(id = R.string.account_setup_outgoing_config_port_label),
contentPadding = defaultItemPadding(),
)
}
item {
TextInput(
text = state.username.value,
errorMessage = state.username.error?.toResourceString(resources),
onTextChange = { onEvent(Event.UsernameChanged(it)) },
label = stringResource(id = R.string.account_setup_outgoing_config_username_label),
contentPadding = defaultItemPadding(),
)
}
item {
PasswordInput(
password = state.password.value,
errorMessage = state.password.error?.toResourceString(resources),
onPasswordChange = { onEvent(Event.PasswordChanged(it)) },
contentPadding = defaultItemPadding(),
)
}
item {
// TODO add client certificate support
SelectInput(
options = persistentListOf(
stringResource(
id = R.string.account_setup_client_certificate_none_available,
),
),
optionToStringTransformation = { it },
selectedOption = stringResource(
id = R.string.account_setup_client_certificate_none_available,
),
onOptionChange = { onEvent(Event.ClientCertificateChanged(it)) },
label = stringResource(id = R.string.account_setup_outgoing_config_client_certificate_label),
contentPadding = defaultItemPadding(),
)
}
if (state.protocolType == IncomingProtocolType.IMAP) {
item {
CheckboxInput(
text = stringResource(id = R.string.account_setup_incoming_config_imap_namespace_label),
checked = state.imapAutodetectNamespaceEnabled,
onCheckedChange = { onEvent(Event.ImapAutoDetectNamespaceChanged(it)) },
contentPadding = defaultItemPadding(),
)
}
item {
TextInput(
text = state.imapPrefix.value,
errorMessage = state.imapPrefix.error?.toResourceString(resources),
onTextChange = { onEvent(Event.ImapPrefixChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_imap_prefix_label),
contentPadding = defaultItemPadding(),
)
}
}
item {
CheckboxInput(
text = stringResource(id = R.string.account_setup_incoming_config_compression_label),
checked = state.useCompression,
onCheckedChange = { onEvent(Event.UseCompressionChanged(it)) },
contentPadding = defaultItemPadding(),
)
}
}
}
}

View file

@ -7,6 +7,8 @@ import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType
import app.k9mail.feature.account.setup.domain.entity.toDefaultPort
import app.k9mail.feature.account.setup.domain.input.NumberInputField
import app.k9mail.feature.account.setup.domain.input.StringInputField
import java.io.IOException
import java.security.cert.X509Certificate
interface AccountIncomingConfigContract {
@ -27,6 +29,10 @@ interface AccountIncomingConfigContract {
val imapAutodetectNamespaceEnabled: Boolean = true,
val imapPrefix: StringInputField = StringInputField(),
val useCompression: Boolean = true,
val isSuccess: Boolean = false,
val error: Error? = null,
val isLoading: Boolean = false,
)
sealed class Event {
@ -40,8 +46,10 @@ interface AccountIncomingConfigContract {
data class ImapAutoDetectNamespaceChanged(val enabled: Boolean) : Event()
data class ImapPrefixChanged(val imapPrefix: String) : Event()
data class UseCompressionChanged(val useCompression: Boolean) : Event()
object OnNextClicked : Event()
object OnBackClicked : Event()
object OnRetryClicked : Event()
}
sealed class Effect {
@ -56,4 +64,12 @@ interface AccountIncomingConfigContract {
fun validatePassword(password: String): ValidationResult
fun validateImapPrefix(imapPrefix: String): ValidationResult
}
sealed interface Error {
data class NetworkError(val exception: IOException) : Error
data class CertificateError(val certificateChain: List<X509Certificate>) : Error
data class AuthenticationError(val serverMessage: String?) : Error
data class ServerError(val serverMessage: String?) : Error
data class UnknownError(val exception: Exception) : Error
}
}

View file

@ -1,5 +1,6 @@
package app.k9mail.feature.account.setup.ui.incoming
import androidx.lifecycle.viewModelScope
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.setup.domain.DomainContract.UseCase
@ -7,11 +8,18 @@ import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType
import app.k9mail.feature.account.setup.domain.entity.toDefaultPort
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Effect
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Error
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Validator
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.ViewModel
import com.fsck.k9.mail.server.ServerSettingsValidationResult
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val CONTINUE_NEXT_DELAY = 1000L
@Suppress("TooManyFunctions")
internal class AccountIncomingConfigViewModel(
initialState: State = State(),
private val validator: Validator,
@ -33,12 +41,27 @@ internal class AccountIncomingConfigViewModel(
is Event.UsernameChanged -> updateState { it.copy(username = it.username.updateValue(event.username)) }
is Event.PasswordChanged -> updateState { it.copy(password = it.password.updateValue(event.password)) }
is Event.ClientCertificateChanged -> updateState { it.copy(clientCertificate = event.clientCertificate) }
is Event.ImapAutoDetectNamespaceChanged -> updateState { it.copy(imapAutodetectNamespaceEnabled = event.enabled) }
is Event.ImapPrefixChanged -> updateState { it.copy(imapPrefix = it.imapPrefix.updateValue(event.imapPrefix)) }
is Event.ImapAutoDetectNamespaceChanged -> updateState {
it.copy(imapAutodetectNamespaceEnabled = event.enabled)
}
is Event.ImapPrefixChanged -> updateState {
it.copy(imapPrefix = it.imapPrefix.updateValue(event.imapPrefix))
}
is Event.UseCompressionChanged -> updateState { it.copy(useCompression = event.useCompression) }
Event.OnBackClicked -> navigateBack()
Event.OnNextClicked -> submit()
Event.OnNextClicked -> onNext()
Event.OnBackClicked -> onBack()
Event.OnRetryClicked -> onRetry()
}
}
private fun onNext() {
if (state.value.isSuccess) {
navigateNext()
} else {
submit()
}
}
@ -84,10 +107,94 @@ internal class AccountIncomingConfigViewModel(
}
if (!hasError) {
checkSettings()
}
}
private fun checkSettings() {
viewModelScope.launch {
updateState {
it.copy(isLoading = true)
}
val result = checkIncomingServerConfig.execute(state.value.protocolType, state.value.toServerSettings())
when (result) {
ServerSettingsValidationResult.Success -> updateSuccess()
is ServerSettingsValidationResult.AuthenticationError -> updateError(
Error.AuthenticationError(result.serverMessage),
)
is ServerSettingsValidationResult.CertificateError -> updateError(
Error.CertificateError(result.certificateChain),
)
is ServerSettingsValidationResult.NetworkError -> updateError(
Error.NetworkError(result.exception),
)
is ServerSettingsValidationResult.ServerError -> updateError(
Error.ServerError(result.serverMessage),
)
is ServerSettingsValidationResult.UnknownError -> updateError(
Error.UnknownError(result.exception),
)
}
}
}
private fun updateSuccess() {
updateState {
it.copy(
isLoading = false,
isSuccess = true,
)
}
viewModelScope.launch {
delay(CONTINUE_NEXT_DELAY)
navigateNext()
}
}
private fun updateError(error: Error) {
updateState {
it.copy(
error = error,
isLoading = false,
isSuccess = false,
)
}
}
private fun onBack() {
if (state.value.isSuccess) {
updateState {
it.copy(
isSuccess = false,
)
}
} else if (state.value.error != null) {
updateState {
it.copy(
error = null,
)
}
} else {
navigateBack()
}
}
private fun onRetry() {
updateState {
it.copy(
error = null,
)
}
checkSettings()
}
private fun navigateBack() = emitEffect(Effect.NavigateBack)
private fun navigateNext() = emitEffect(Effect.NavigateNext)

View file

@ -40,6 +40,9 @@
<string name="account_setup_incoming_config_imap_namespace_label">Auto-detect IMAP namespace</string>
<string name="account_setup_incoming_config_imap_prefix_label">IMAP path prefix</string>
<string name="account_setup_incoming_config_compression_label">Use compression</string>
<string name="account_setup_incoming_config_loading_message">Checking incoming server settings…</string>
<string name="account_setup_incoming_config_loading_error">Incoming server Settings are not valid</string>
<string name="account_setup_incoming_config_success">Incoming server Settings are valid!</string>
<string name="account_setup_outgoing_config_top_bar_title">Outgoing server settings</string>
<string name="account_setup_outgoing_config_server_label">Server</string>

View file

@ -1,6 +1,5 @@
package app.k9mail.feature.account.setup.ui.incoming
import androidx.lifecycle.viewmodel.compose.viewModel
import app.cash.turbine.testIn
import app.k9mail.core.common.domain.usecase.validation.ValidationError
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
@ -13,18 +12,20 @@ import app.k9mail.feature.account.setup.domain.input.NumberInputField
import app.k9mail.feature.account.setup.domain.input.StringInputField
import app.k9mail.feature.account.setup.testing.eventStateTest
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Effect
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Error
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State
import assertk.assertThat
import assertk.assertions.assertThatAndTurbinesConsumed
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import com.fsck.k9.mail.server.ServerSettingsValidationResult
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AccountIncomingConfigViewModelTest {
@get:Rule
@ -32,7 +33,10 @@ class AccountIncomingConfigViewModelTest {
private val testSubject = AccountIncomingConfigViewModel(
validator = FakeAccountIncomingConfigValidator(),
checkIncomingServerConfig = { _, _ -> ServerSettingsValidationResult.Success },
checkIncomingServerConfig = { _, _ ->
delay(50)
ServerSettingsValidationResult.Success
},
)
@Test
@ -159,7 +163,32 @@ class AccountIncomingConfigViewModelTest {
}
@Test
fun `should change state and emit NavigateNext effect when OnNextClicked event is received and input is valid`() =
fun `should emit effect NavigateNext when OnNextClicked is received in success state`() = runTest {
val initialState = State(isSuccess = true)
testSubject.initState(initialState)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState)
}
testSubject.event(Event.OnNextClicked)
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateNext)
}
}
@Test
fun `should checkSettings when OnNextClicked event is received and input is valid`() =
runTest {
val viewModel = testSubject
val stateTurbine = viewModel.state.testIn(backgroundScope)
@ -175,15 +204,23 @@ class AccountIncomingConfigViewModelTest {
viewModel.event(Event.OnNextClicked)
assertThat(stateTurbine.awaitItem()).isEqualTo(
State(
server = StringInputField(value = "", isValid = true),
port = NumberInputField(value = 993L, isValid = true),
username = StringInputField(value = "", isValid = true),
password = StringInputField(value = "", isValid = true),
imapPrefix = StringInputField(value = "", isValid = true),
),
val validState = State(
server = StringInputField(value = "", isValid = true),
port = NumberInputField(value = 993L, isValid = true),
username = StringInputField(value = "", isValid = true),
password = StringInputField(value = "", isValid = true),
imapPrefix = StringInputField(value = "", isValid = true),
)
assertThat(stateTurbine.awaitItem()).isEqualTo(validState)
val loadingState = validState.copy(isLoading = true)
assertThat(stateTurbine.awaitItem()).isEqualTo(loadingState)
val successState = loadingState.copy(
isLoading = false,
isSuccess = true,
)
assertThat(stateTurbine.awaitItem()).isEqualTo(successState)
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
@ -196,11 +233,15 @@ class AccountIncomingConfigViewModelTest {
@Test
fun `should change state and not emit NavigateNext effect when OnNextClicked event received and input invalid`() =
runTest {
var checkSettingsCalled = false
val viewModel = AccountIncomingConfigViewModel(
validator = FakeAccountIncomingConfigValidator(
serverAnswer = ValidationResult.Failure(TestError),
),
checkIncomingServerConfig = { _, _ -> ServerSettingsValidationResult.Success },
checkIncomingServerConfig = { _, _ ->
checkSettingsCalled = true
ServerSettingsValidationResult.Success
},
)
val stateTurbine = viewModel.state.testIn(backgroundScope)
val effectTurbine = viewModel.effect.testIn(backgroundScope)
@ -229,8 +270,56 @@ class AccountIncomingConfigViewModelTest {
),
)
}
assertThat(checkSettingsCalled).isFalse()
}
@Test
fun `should set error state when OnNextClicked and input valid but check settings failed`() = runTest {
val viewModel = AccountIncomingConfigViewModel(
validator = FakeAccountIncomingConfigValidator(),
checkIncomingServerConfig = { _, _ ->
delay(50)
ServerSettingsValidationResult.ServerError("server error")
},
)
val stateTurbine = viewModel.state.testIn(backgroundScope)
val effectTurbine = viewModel.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(State())
}
viewModel.event(Event.OnNextClicked)
val validState = State(
server = StringInputField(value = "", isValid = true),
port = NumberInputField(value = 993L, isValid = true),
username = StringInputField(value = "", isValid = true),
password = StringInputField(value = "", isValid = true),
imapPrefix = StringInputField(value = "", isValid = true),
)
assertThat(stateTurbine.awaitItem()).isEqualTo(validState)
val loadingState = validState.copy(isLoading = true)
assertThat(stateTurbine.awaitItem()).isEqualTo(loadingState)
val failureState = loadingState.copy(
isLoading = false,
error = Error.ServerError("server error"),
)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(failureState)
}
}
@Test
fun `should emit NavigateBack effect when OnBackClicked event received`() = runTest {
val viewModel = testSubject
@ -255,5 +344,106 @@ class AccountIncomingConfigViewModelTest {
}
}
@Test
fun `should clear isSuccess when OnBackClicked event received`() = runTest {
val initialState = State(isSuccess = true)
testSubject.initState(initialState)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState)
}
testSubject.event(Event.OnBackClicked)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState.copy(isSuccess = false))
}
}
@Test
fun `should clear error when OnBackClicked event received`() = runTest {
val initialState = State(error = Error.ServerError("server error"))
testSubject.initState(initialState)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState)
}
testSubject.event(Event.OnBackClicked)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState.copy(error = null))
}
}
@Test
fun `should clear error and trigger check settings when OnRetryClicked event received`() = runTest {
val initialState = State(
server = StringInputField(value = "", isValid = true),
port = NumberInputField(value = 993L, isValid = true),
username = StringInputField(value = "", isValid = true),
password = StringInputField(value = "", isValid = true),
imapPrefix = StringInputField(value = "", isValid = true),
error = Error.ServerError("server error"),
)
var checkSettingsCalled = false
val viewModel = AccountIncomingConfigViewModel(
validator = FakeAccountIncomingConfigValidator(),
checkIncomingServerConfig = { _, _ ->
checkSettingsCalled = true
delay(50)
ServerSettingsValidationResult.Success
},
initialState = initialState,
)
val stateTurbine = viewModel.state.testIn(backgroundScope)
val effectTurbine = viewModel.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState)
}
viewModel.event(Event.OnRetryClicked)
val stateWithoutError = initialState.copy(error = null)
assertThat(stateTurbine.awaitItem()).isEqualTo(stateWithoutError)
val loadingState = stateWithoutError.copy(isLoading = true)
assertThat(stateTurbine.awaitItem()).isEqualTo(loadingState)
val successState = loadingState.copy(isLoading = false, isSuccess = true)
assertThat(stateTurbine.awaitItem()).isEqualTo(successState)
assertThat(checkSettingsCalled).isTrue()
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateNext)
}
}
private object TestError : ValidationError
}