Merge branch 'main' into test-377-robolectric-onboarding-lock
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user