From 3ea00a1ed9725428e04ed037fb59a7cc44bbb3a4 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 20:34:21 -0500 Subject: [PATCH] test(compose): Robolectric JVM tests for account setup screens (#378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/build.gradle.kts | 5 +- .../AccountPickerScreenJvmTest.kt | 216 ++++++++++++++++ .../AppPasswordSetupScreenJvmTest.kt | 235 ++++++++++++++++++ .../accountsetup/ManualSetupScreenJvmTest.kt | 215 ++++++++++++++++ 4 files changed, 668 insertions(+), 3 deletions(-) create mode 100644 app/src/test/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenJvmTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f505ed9..e93ecbc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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. diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenJvmTest.kt new file mode 100644 index 0000000..33b1557 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenJvmTest.kt @@ -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 onLaunch( + requestCode: Int, + contract: ActivityResultContract, + 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(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() + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenJvmTest.kt new file mode 100644 index 0000000..d83c866 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenJvmTest.kt @@ -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() + 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(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)) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenJvmTest.kt new file mode 100644 index 0000000..6e84ade --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenJvmTest.kt @@ -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(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) } + } +}