test(compose): Robolectric JVM tests for account setup screens (#378)

Add Robolectric JVM Compose tests (umbrella #373, batch 3/9) for the
account-setup screens and drop them from `jacocoNonJvmTestableSurface` so
their render/interaction code counts toward the JVM-testable coverage surface:

- AccountPickerScreen (98.9% line)
- AppPasswordSetupScreen (98.7% line)
- ManualSetupScreen (98.5% line)

Each test drives the real screen via the v2 `createComposeRule()` under
RobolectricTestRunner with a mocked ViewModel (their own logic stays covered by
the ViewModel unit tests), a RESUMED LifecycleOwner for
`collectAsStateWithLifecycle`, a no-op ActivityResultRegistry for the Outlook
launcher, and a recording UriHandler for the app-password help links — covering
render, per-provider chrome, field/submit wiring, and the enabled/busy/error/
done branches. The instrumented androidTest E2Es stay as the on-device coverage.

The JaCoCo floor (0.79) is unchanged — the re-ratchet is the final #373 step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 20:34:21 -05:00
co-authored by Claude Opus 4.8
parent ae10a5ff3b
commit 3ea00a1ed9
4 changed files with 668 additions and 3 deletions
+2 -3
View File
@@ -313,9 +313,8 @@ val jacocoGeneratedExcludes = listOf(
val jacocoNonJvmTestableSurface = listOf(
// --- Compose UI render code: one glob per screen/component file (see the exceptions above) ---
"**/LibreMailApp*",
"**/AccountPickerScreen*",
"**/AppPasswordSetupScreen*",
"**/ManualSetupScreen*",
// AccountPickerScreen, AppPasswordSetupScreen & ManualSetupScreen converted to Robolectric JVM
// Compose tests (#378) — now JVM-covered.
"**/ComposeScreen*",
// ColorSwatch(Row), FontPicker, FontSizePicker & ParagraphAlignmentControl converted to
// Robolectric JVM Compose tests (#376) — now JVM-covered.
@@ -0,0 +1,216 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.accountsetup
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasProgressBarRangeInfo
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.core.app.ActivityOptionsCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.domain.model.MailProvider
import org.libremail.ui.theme.LibreMailTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Robolectric JVM Compose test (#378, umbrella #373) for the account-vendor picker. Mirrors the
* instrumented [AccountPickerScreenTest] on the JVM via the v2 `createComposeRule()` under
* [RobolectricTestRunner] — no emulator — so [AccountPickerScreen] counts toward JaCoCo's
* JVM-testable surface (this file is dropped from `jacocoNonJvmTestableSurface`). The instrumented
* `AccountPickerScreenTest` stays as the on-device E2E, and it (with Espresso-Intents) remains the
* coverage for the actual Outlook browser launch the JVM rule cannot safely surface — here the
* Outlook tap is driven through a mocked [AccountSetupViewModel] so no real activity ever starts.
*
* [AccountSetupViewModel] is mocked (its own logic is covered by [AccountSetupViewModelTest]); this
* exercises the screen's render, routing, and the busy/error/done state branches.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
// A tall display so the whole scrolling provider list fits Robolectric's small default viewport,
// keeping every row on-screen for assertIsDisplayed / performClick without scrolling.
@Config(sdk = [36], qualifiers = "+w411dp-h2000dp")
class AccountPickerScreenJvmTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context get() = RuntimeEnvironment.getApplication()
private fun string(resId: Int): String = context.getString(resId)
/** RESUMED owner so `collectAsStateWithLifecycle` collects the view-model state. */
private val resumedOwner = object : LifecycleOwner {
private val registry =
LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED }
override val lifecycle: Lifecycle get() = registry
}
/**
* A no-op registry so [AccountPickerScreen]'s `rememberLauncherForActivityResult` (the Outlook
* sign-in launcher) can register and launch on the JVM without surfacing a real activity/browser.
*/
private val noopRegistryOwner = object : ActivityResultRegistryOwner {
override val activityResultRegistry = object : ActivityResultRegistry() {
override fun <I, O> onLaunch(
requestCode: Int,
contract: ActivityResultContract<I, O>,
input: I,
options: ActivityOptionsCompat?,
) {
// Intentionally never dispatch a result: the launch is a no-op in this JVM test.
}
}
}
private fun viewModel(state: AccountSetupUiState = AccountSetupUiState()): AccountSetupViewModel {
val vm = mockk<AccountSetupViewModel>(relaxed = true)
every { vm.state } returns MutableStateFlow(state)
return vm
}
private fun setContent(
viewModel: AccountSetupViewModel,
onBack: () -> Unit = {},
onAccountAdded: (String) -> Unit = {},
onPickProvider: (MailProvider) -> Unit = {},
onManualSetup: () -> Unit = {},
) {
composeTestRule.setContent {
CompositionLocalProvider(
LocalLifecycleOwner provides resumedOwner,
LocalActivityResultRegistryOwner provides noopRegistryOwner,
) {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
AccountPickerScreen(
onBack = onBack,
onAccountAdded = onAccountAdded,
onPickProvider = onPickProvider,
onManualSetup = onManualSetup,
viewModel = viewModel,
)
}
}
}
}
@Test
fun listsOutlook_everyAppPasswordVendor_andOther() {
setContent(viewModel())
composeTestRule.onNodeWithText(string(R.string.account_setup_subtitle)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.account_setup_outlook)).assertIsDisplayed()
MailProvider.entries.forEach { provider ->
composeTestRule.onNodeWithText(provider.displayName).assertIsDisplayed()
}
composeTestRule.onNodeWithText(string(R.string.account_setup_other)).assertIsDisplayed()
}
@Test
fun tappingAppPasswordProvider_invokesOnPickProvider() {
var picked: MailProvider? = null
setContent(viewModel(), onPickProvider = { picked = it })
composeTestRule.onNodeWithText(MailProvider.GMAIL.displayName).performClick()
assertEquals(MailProvider.GMAIL, picked)
}
@Test
fun tappingOther_invokesOnManualSetup() {
var manualRequested = false
setContent(viewModel(), onManualSetup = { manualRequested = true })
composeTestRule.onNodeWithText(string(R.string.account_setup_other)).performClick()
assertTrue(manualRequested)
}
@Test
fun tappingBack_invokesOnBack() {
var backed = false
setContent(viewModel(), onBack = { backed = true })
composeTestRule.onNodeWithContentDescription(string(R.string.action_back)).performClick()
assertTrue(backed)
}
@Test
fun tappingOutlook_buildsTheAuthIntentAndLaunchesIt() {
val vm = viewModel()
// A real (empty) Intent is safe here: the no-op registry never actually starts it.
every { vm.outlookAuthIntent() } returns Result.success(Intent())
setContent(vm)
composeTestRule.onNodeWithText(string(R.string.account_setup_outlook)).performClick()
verify { vm.outlookAuthIntent() }
}
@Test
fun tappingOutlook_whenTheIntentCannotBeBuilt_reportsLaunchFailure() {
val vm = viewModel()
every { vm.outlookAuthIntent() } returns Result.failure(ActivityNotFoundException("no browser"))
setContent(vm)
composeTestRule.onNodeWithText(string(R.string.account_setup_outlook)).performClick()
verify { vm.onOutlookLaunchFailed(any()) }
}
@Test
fun doneStatus_reportsTheNewAccountIdToOnAccountAdded() {
var addedId: String? = null
setContent(
viewModel(AccountSetupUiState(status = SetupStatus.DONE, addedAccountId = "outlook:me@outlook.com")),
onAccountAdded = { addedId = it },
)
composeTestRule.waitUntil(5_000) { addedId != null }
assertEquals("outlook:me@outlook.com", addedId)
}
@Test
fun anError_isSurfacedAsASnackbar() {
setContent(viewModel(AccountSetupUiState(error = "Microsoft sign-in failed")))
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Microsoft sign-in failed").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText("Microsoft sign-in failed").assertIsDisplayed()
}
@Test
fun connectingStatus_showsTheBusyOverlaySpinner() {
setContent(viewModel(AccountSetupUiState(status = SetupStatus.CONNECTING)))
composeTestRule.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed()
}
}
@@ -0,0 +1,235 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.accountsetup
import android.content.Context
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.domain.model.MailProvider
import org.libremail.ui.theme.LibreMailTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Robolectric JVM Compose test (#378, umbrella #373) for the guided app-password setup screen (#29).
* Mirrors the instrumented [AppPasswordSetupScreenTest] on the JVM via the v2 `createComposeRule()`
* under [RobolectricTestRunner] — no emulator — so [AppPasswordSetupScreen] counts toward JaCoCo's
* JVM-testable surface (this file is dropped from `jacocoNonJvmTestableSurface`). The instrumented
* test stays as the on-device E2E.
*
* [AppPasswordViewModel] is mocked (its own logic is covered by [AppPasswordViewModelTest]); this
* exercises the screen's per-provider chrome, the unknown-provider fallback, the credential-field and
* help-link wiring, and the enabled/busy/error/done branches. A recording [UriHandler] captures the
* help-link launches instead of opening a real browser (mirroring the instrumented Espresso-Intents
* assertion).
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
// A tall display so the whole scrolling setup form fits Robolectric's small default viewport,
// keeping every control on-screen for assertIsDisplayed / performClick without scrolling.
@Config(sdk = [36], qualifiers = "+w411dp-h2000dp")
class AppPasswordSetupScreenJvmTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context get() = RuntimeEnvironment.getApplication()
private fun string(resId: Int, vararg args: Any): String = context.getString(resId, *args)
/** RESUMED owner so `collectAsStateWithLifecycle` collects the form state. */
private val resumedOwner = object : LifecycleOwner {
private val registry =
LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED }
override val lifecycle: Lifecycle get() = registry
}
/** Records outbound help-link launches so the screen's `openUrl` is exercised without a browser. */
private val openedUrls = mutableListOf<String>()
private val recordingUriHandler = object : UriHandler {
override fun openUri(uri: String) {
openedUrls.add(uri)
}
}
private fun viewModel(
provider: MailProvider? = MailProvider.GMAIL,
form: AppPasswordForm = AppPasswordForm(),
): AppPasswordViewModel {
val vm = mockk<AppPasswordViewModel>(relaxed = true)
every { vm.provider } returns provider
every { vm.form } returns MutableStateFlow(form)
return vm
}
private fun setContent(viewModel: AppPasswordViewModel, onAccountAdded: (String) -> Unit = {}) {
composeTestRule.setContent {
CompositionLocalProvider(
LocalLifecycleOwner provides resumedOwner,
LocalUriHandler provides recordingUriHandler,
) {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
AppPasswordSetupScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel)
}
}
}
}
@Test
fun rendersGmailChrome_credentialFields_andDisabledSubmit() {
setContent(viewModel(MailProvider.GMAIL))
composeTestRule.onNodeWithText(string(R.string.app_password_title, "Gmail")).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_what_is)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_warning)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_open_page, "Gmail")).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_email)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_field)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_show_servers)).assertIsDisplayed()
// Blank credentials -> the form is invalid, so the submit button starts disabled.
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)).assertIsNotEnabled()
}
@Test
fun icloudChrome_usesTheAppleTwoFactorLabel() {
setContent(viewModel(MailProvider.ICLOUD))
composeTestRule.onNodeWithText(string(R.string.app_password_title, "iCloud Mail")).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help_icloud)).assertIsDisplayed()
}
@Test
fun yahooChrome_hasNoTwoFactorPrerequisiteLink() {
setContent(viewModel(MailProvider.YAHOO))
composeTestRule.onNodeWithText(string(R.string.app_password_title, "Yahoo Mail")).assertIsDisplayed()
// Yahoo has no twoFactorHelpUrl, so neither two-factor button variant is shown.
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help)).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help_icloud)).assertDoesNotExist()
}
@Test
fun aolChrome_rendersItsTitleWithoutTwoFactorLink() {
setContent(viewModel(MailProvider.AOL))
composeTestRule.onNodeWithText(string(R.string.app_password_title, "AOL Mail")).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help)).assertDoesNotExist()
}
@Test
fun unknownProvider_showsTheFallbackAndNoForm() {
setContent(viewModel(provider = null))
composeTestRule.onNodeWithText(string(R.string.app_password_unknown_provider)).assertIsDisplayed()
// The defensive early-return means the credential form is never rendered.
composeTestRule.onNodeWithText(string(R.string.app_password_email)).assertDoesNotExist()
}
@Test
fun typingCredentials_forwardsToTheViewModel() {
val vm = viewModel(MailProvider.GMAIL)
setContent(vm)
composeTestRule.onNodeWithText(string(R.string.app_password_email)).performTextInput("me@gmail.com")
composeTestRule.onNodeWithText(string(R.string.app_password_field)).performTextInput("app-pw-1234")
verify { vm.onEmail(any()) }
verify { vm.onAppPassword(any()) }
}
@Test
fun validCredentials_enableSubmit_andTapCallsTestAndSave() {
val vm = viewModel(MailProvider.GMAIL, AppPasswordForm(email = "me@gmail.com", appPassword = "app-pw"))
setContent(vm)
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)).assertIsEnabled().performClick()
verify { vm.testAndSave() }
}
@Test
fun connectingStatus_disablesSubmit() {
setContent(
viewModel(
MailProvider.GMAIL,
AppPasswordForm(email = "me@gmail.com", appPassword = "app-pw", status = SetupStatus.CONNECTING),
),
)
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)).assertIsNotEnabled()
}
@Test
fun doneStatus_reportsTheNewAccountIdToOnAccountAdded() {
var addedId: String? = null
setContent(
viewModel(
MailProvider.GMAIL,
AppPasswordForm(status = SetupStatus.DONE, addedAccountId = "imap:me@gmail.com"),
),
onAccountAdded = { addedId = it },
)
composeTestRule.waitUntil(5_000) { addedId != null }
assertEquals("imap:me@gmail.com", addedId)
}
@Test
fun anError_isSurfacedAsASnackbar() {
setContent(viewModel(MailProvider.GMAIL, AppPasswordForm(error = "Login failed")))
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Login failed").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText("Login failed").assertIsDisplayed()
}
@Test
fun expandedAdvanced_showsPresetServers_andToggleCallsViewModel() {
val vm = viewModel(MailProvider.GMAIL, AppPasswordForm(advancedExpanded = true))
setContent(vm)
composeTestRule.onNodeWithText(string(R.string.app_password_show_servers)).performClick()
verify { vm.toggleAdvanced() }
// Expanded, the read-only preset servers for the vendor are shown.
composeTestRule.onNodeWithText("imap.gmail.com", substring = true).assertIsDisplayed()
composeTestRule.onNodeWithText("smtp.gmail.com", substring = true).assertIsDisplayed()
}
@Test
fun tappingHelpLinks_opensTheProviderTwoFactorAndAppPasswordPages() {
setContent(viewModel(MailProvider.GMAIL))
composeTestRule.onNodeWithText(string(R.string.app_password_open_page, "Gmail")).performClick()
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help)).performClick()
assertTrue(openedUrls.contains(MailProvider.GMAIL.appPasswordHelpUrl))
assertTrue(openedUrls.contains(MailProvider.GMAIL.twoFactorHelpUrl))
}
}
@@ -0,0 +1,215 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.accountsetup
import android.content.Context
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.domain.model.MailSecurity
import org.libremail.ui.theme.LibreMailTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Robolectric JVM Compose test (#378, umbrella #373) for manual IMAP/SMTP account setup. Mirrors the
* instrumented [ManualSetupScreenTest] on the JVM via the v2 `createComposeRule()` under
* [RobolectricTestRunner] — no emulator — so [ManualSetupScreen] counts toward JaCoCo's JVM-testable
* surface (this file is dropped from `jacocoNonJvmTestableSurface`). The instrumented test stays as
* the on-device E2E.
*
* [ManualSetupViewModel] is mocked (its own logic is covered by [ManualSetupViewModelTest]); this
* exercises the screen's field/submit render, the advanced disclosure (ports + the security selector
* that omits the cleartext option), and the enabled/busy/error/done branches.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
// A tall display so the whole scrolling setup form — including the expanded advanced section — fits
// Robolectric's small default viewport, keeping every control on-screen for the assertions below.
@Config(sdk = [36], qualifiers = "+w411dp-h2000dp")
class ManualSetupScreenJvmTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context get() = RuntimeEnvironment.getApplication()
private fun string(resId: Int): String = context.getString(resId)
/** RESUMED owner so `collectAsStateWithLifecycle` collects the form state. */
private val resumedOwner = object : LifecycleOwner {
private val registry =
LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED }
override val lifecycle: Lifecycle get() = registry
}
private fun viewModel(form: ManualSetupForm = ManualSetupForm()): ManualSetupViewModel {
val vm = mockk<ManualSetupViewModel>(relaxed = true)
every { vm.form } returns MutableStateFlow(form)
return vm
}
private fun setContent(viewModel: ManualSetupViewModel, onAccountAdded: (String) -> Unit = {}) {
composeTestRule.setContent {
CompositionLocalProvider(LocalLifecycleOwner provides resumedOwner) {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ManualSetupScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel)
}
}
}
}
@Test
fun rendersCredentialAndServerFields_withDisabledSubmit() {
setContent(viewModel())
composeTestRule.onNodeWithText(string(R.string.manual_email)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.manual_password)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.manual_incoming)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.manual_imap_server)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.manual_outgoing)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.manual_smtp_server)).assertIsDisplayed()
// An empty form is invalid, so the submit button starts disabled.
composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).assertIsNotEnabled()
}
@Test
fun tappingBack_invokesOnBack() {
var backed = false
composeTestRule.setContent {
CompositionLocalProvider(LocalLifecycleOwner provides resumedOwner) {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ManualSetupScreen(onBack = { backed = true }, onAccountAdded = {}, viewModel = viewModel())
}
}
}
composeTestRule.onNodeWithContentDescription(string(R.string.action_back)).performClick()
assertTrue(backed)
}
@Test
fun typingCredentialsAndServers_forwardsToTheViewModel() {
val vm = viewModel()
setContent(vm)
composeTestRule.onNodeWithText(string(R.string.manual_email)).performTextInput("user@example.com")
composeTestRule.onNodeWithText(string(R.string.manual_password)).performTextInput("secret")
composeTestRule.onNodeWithText(string(R.string.manual_imap_server)).performTextInput("imap.example.com")
composeTestRule.onNodeWithText(string(R.string.manual_smtp_server)).performTextInput("smtp.example.com")
verify { vm.onEmail(any()) }
verify { vm.onPassword(any()) }
verify { vm.onImapHost(any()) }
verify { vm.onSmtpHost(any()) }
}
@Test
fun validForm_enablesSubmit_andTapCallsTestAndSave() {
val vm = viewModel(
ManualSetupForm(
email = "user@example.com",
password = "secret",
imapHost = "imap.example.com",
smtpHost = "smtp.example.com",
),
)
setContent(vm)
composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).assertIsEnabled().performClick()
verify { vm.testAndSave() }
}
@Test
fun connectingStatus_disablesSubmit() {
setContent(
viewModel(
ManualSetupForm(
email = "user@example.com",
password = "secret",
imapHost = "imap.example.com",
smtpHost = "smtp.example.com",
status = SetupStatus.CONNECTING,
),
),
)
composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).assertIsNotEnabled()
}
@Test
fun doneStatus_reportsTheNewAccountIdToOnAccountAdded() {
var addedId: String? = null
setContent(
viewModel(ManualSetupForm(status = SetupStatus.DONE, addedAccountId = "imap:user@example.com")),
onAccountAdded = { addedId = it },
)
composeTestRule.waitUntil(5_000) { addedId != null }
assertEquals("imap:user@example.com", addedId)
}
@Test
fun anError_isSurfacedAsASnackbar() {
setContent(viewModel(ManualSetupForm(error = "Login failed")))
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Login failed").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText("Login failed").assertIsDisplayed()
}
@Test
fun expandedAdvanced_revealsPortsAndSecurity_andOmitsTheCleartextOption() {
setContent(viewModel(ManualSetupForm(advancedExpanded = true)))
composeTestRule.onNodeWithText(string(R.string.manual_imap_port)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.manual_imap_security)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.manual_smtp_port)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.manual_smtp_security)).assertIsDisplayed()
// Each selector offers only the two encrypted modes...
composeTestRule.onAllNodesWithText("SSL/TLS")[0].assertIsDisplayed()
composeTestRule.onAllNodesWithText("STARTTLS")[0].assertIsDisplayed()
// ...never the cleartext (NONE) option.
composeTestRule.onNodeWithText("None").assertDoesNotExist()
}
@Test
fun advancedToggleAndSecurityChips_forwardToTheViewModel() {
val vm = viewModel(ManualSetupForm(advancedExpanded = true))
setContent(vm)
composeTestRule.onNodeWithText(string(R.string.settings_advanced)).performClick()
verify { vm.toggleAdvanced() }
// Two selectors -> the IMAP one renders first, the SMTP one second.
composeTestRule.onAllNodesWithText("STARTTLS")[0].performClick()
verify { vm.onImapSecurity(MailSecurity.STARTTLS) }
composeTestRule.onAllNodesWithText("STARTTLS")[1].performClick()
verify { vm.onSmtpSecurity(MailSecurity.STARTTLS) }
}
}