test(compose): Robolectric JVM tests — app shell & lock gate host (#384) #417
@@ -312,6 +312,11 @@ val jacocoGeneratedExcludes = listOf(
|
||||
// every `*ViewModel*`.
|
||||
val jacocoNonJvmTestableSurface = listOf(
|
||||
// --- Compose UI render code: one glob per screen/component file (see the exceptions above) ---
|
||||
// LibreMailApp KEPT excluded (#384, the acceptable exception): the composable is a real NavHost whose
|
||||
// non-onboarding start destinations call hiltViewModel(), and standing the graph up needs owners a
|
||||
// plain JVM compose rule can't surface — so graph-level nav stays on the instrumented OnboardingFlowTest.
|
||||
// Its JVM-tractable parts (LibreMailBottomBar, StartupCrashPrompt, the cold-start hold guards) ARE
|
||||
// exercised by LibreMailAppJvmTest, but the file's compiled facade (LibreMailAppKt) stays excluded.
|
||||
"**/LibreMailApp*",
|
||||
// AccountPickerScreen, AppPasswordSetupScreen & ManualSetupScreen converted to Robolectric JVM
|
||||
// Compose tests (#378) — now JVM-covered.
|
||||
@@ -322,7 +327,7 @@ val jacocoNonJvmTestableSurface = listOf(
|
||||
// DraftsScreen, OutboxScreen & ProblemReportsScreen converted to Robolectric JVM Compose tests
|
||||
// (#379) — now JVM-covered.
|
||||
// LockScreen converted to a Robolectric JVM Compose test (#377) — now JVM-covered.
|
||||
"**/AppLockGateHost*",
|
||||
// AppLockGateHost converted to a Robolectric JVM Compose test (#384) — now JVM-covered.
|
||||
// FolderDrawer & MailboxScreen (the Paging 3 mailbox list + folder drawer) converted to
|
||||
// Robolectric JVM Compose tests (#383) — now JVM-covered.
|
||||
// AddAnotherAccountScreen (#373) plus the onboarding welcome/license and contacts/battery steps
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
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.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.reporting.ReportKind
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.reporting.ReportSummary
|
||||
import org.libremail.ui.reporting.StartupReportViewModel
|
||||
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 (#384, umbrella #373) for the JVM-tractable parts of `LibreMailApp.kt`:
|
||||
* the shared bottom bar ([LibreMailBottomBar]), the startup crash prompt ([StartupCrashPrompt] and its
|
||||
* dialog), and [LibreMailApp]'s cold-start "hold until known" guards. Runs on the JVM via the v2
|
||||
* `createComposeRule()` under [RobolectricTestRunner] — no emulator.
|
||||
*
|
||||
* Why the `LibreMailApp` glob stays in `jacocoNonJvmTestableSurface` (the acceptable exception noted in
|
||||
* #384): the [LibreMailApp] composable itself is a real `NavHost` whose non-onboarding start
|
||||
* destinations (mailbox, reader, settings, …) each call `hiltViewModel()`, and standing the graph up
|
||||
* needs `ViewModelStoreOwner` / `SavedStateRegistryOwner` / `OnBackPressedDispatcherOwner` a plain JVM
|
||||
* compose rule does not surface. Graph-level navigation therefore stays covered by the instrumented
|
||||
* `OnboardingFlowTest`; the render + interaction logic that CAN run headless is exercised here, and the
|
||||
* instrumented `LibreMailBottomBarTest` / `StartupCrashPromptTest` stay as the on-device E2E.
|
||||
*
|
||||
* The view models are mocked (their own logic has dedicated tests); this drives the composables' render
|
||||
* and callback branches only.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36])
|
||||
class LibreMailAppJvmTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private val context: Context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun string(resId: Int): String = context.getString(resId)
|
||||
|
||||
/** RESUMED owner so the composables' `collectAsStateWithLifecycle` reads collect their 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 appViewModel(startDestination: String?, licenseAccepted: Boolean?): AppViewModel {
|
||||
val vm = mockk<AppViewModel>(relaxed = true)
|
||||
every { vm.startDestination } returns MutableStateFlow(startDestination)
|
||||
every { vm.licenseAccepted } returns MutableStateFlow(licenseAccepted)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun startupViewModel(pending: ReportSummary? = null): StartupReportViewModel {
|
||||
val vm = mockk<StartupReportViewModel>(relaxed = true)
|
||||
every { vm.pendingCrash } returns MutableStateFlow(pending)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun crash() = ReportSummary(id = CRASH_ID, kind = ReportKind.CRASH, createdAtMillis = 1L)
|
||||
|
||||
private fun renderApp(appViewModel: AppViewModel, startupViewModel: StartupReportViewModel) {
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalLifecycleOwner provides resumedOwner) {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
LibreMailApp(appViewModel = appViewModel, startupViewModel = startupViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderPrompt(viewModel: StartupReportViewModel, onReview: (String) -> Unit = {}) {
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalLifecycleOwner provides resumedOwner) {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
StartupCrashPrompt(viewModel = viewModel, onReview = onReview)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- LibreMailApp cold-start "hold until known" guards (no NavHost is built on these paths) ---
|
||||
|
||||
@Test
|
||||
fun nullStartDestination_holdsAndRendersNothing() {
|
||||
renderApp(
|
||||
appViewModel(startDestination = null, licenseAccepted = true),
|
||||
startupViewModel(),
|
||||
)
|
||||
|
||||
// Nothing composes until BOTH the account-derived start and the license flag are known.
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_welcome_title)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullLicenseFlag_holdsAndRendersNothing() {
|
||||
renderApp(
|
||||
appViewModel(startDestination = Routes.ONBOARDING, licenseAccepted = null),
|
||||
startupViewModel(),
|
||||
)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_welcome_title)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
// --- StartupCrashPrompt (#255) + its dialog ---
|
||||
|
||||
@Test
|
||||
fun startupCrashPrompt_noPendingCrash_showsNoDialog() {
|
||||
renderPrompt(startupViewModel(pending = null))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startupCrashPrompt_pendingCrash_showsDialog() {
|
||||
renderPrompt(startupViewModel(pending = crash()))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startupCrashPrompt_review_marksSurfacedAndReportsForReview() {
|
||||
val vm = startupViewModel(pending = crash())
|
||||
var reviewed: String? = null
|
||||
renderPrompt(vm, onReview = { reviewed = it })
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.crash_prompt_review)).performClick()
|
||||
|
||||
verify { vm.dismiss(CRASH_ID) }
|
||||
assertEquals(CRASH_ID, reviewed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startupCrashPrompt_notNow_marksSurfacedWithoutReviewing() {
|
||||
val vm = startupViewModel(pending = crash())
|
||||
renderPrompt(vm)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.crash_prompt_later)).performClick()
|
||||
|
||||
verify { vm.dismiss(CRASH_ID) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startupCrashPrompt_discard_deletesTheReport() {
|
||||
val vm = startupViewModel(pending = crash())
|
||||
renderPrompt(vm)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.crash_prompt_discard)).performClick()
|
||||
|
||||
verify { vm.discard(CRASH_ID) }
|
||||
}
|
||||
|
||||
// --- LibreMailBottomBar ---
|
||||
|
||||
@Test
|
||||
fun bottomBar_rendersBothTabs() {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
LibreMailBottomBar(current = TopDest.MAILBOX, onSelect = {})
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.nav_mailbox)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.nav_settings)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bottomBar_tappingATab_reportsThatSelection() {
|
||||
var selected: TopDest? = null
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
LibreMailBottomBar(current = TopDest.MAILBOX, onSelect = { selected = it })
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.nav_settings)).performClick()
|
||||
|
||||
assertEquals(TopDest.SETTINGS, selected)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CRASH_ID = "crash-1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.lock
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
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.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
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 (#384, umbrella #373) for [AppLockGateHost], the gate that wraps the
|
||||
* whole app behind the screen lock. Mirrors on the JVM — via the v2 `createComposeRule()` under
|
||||
* [RobolectricTestRunner], no emulator — the three [AppLockUiState] branches the host renders:
|
||||
* [content] is composed only once unlocked, a blank cover hides it while [AppLockUiState.Checking], and
|
||||
* the [LockScreen] is drawn OVER the (still-composed) content while [AppLockUiState.Locked]. So
|
||||
* [AppLockGateHost] counts toward JaCoCo's JVM-testable surface (this file is dropped from
|
||||
* `jacocoNonJvmTestableSurface`).
|
||||
*
|
||||
* [AppLockViewModel] is mocked (its own gate/crypto logic is covered by `AppLockViewModelTest`); this
|
||||
* exercises the host composable's render + latch branches. The `BiometricPrompt` itself is device-only
|
||||
* (see `showAppLockPrompt`): with no hosting `FragmentActivity` in this JVM rule, requesting auth
|
||||
* resolves to `onAuthError(null)` instead of presenting a prompt — which is the branch asserted here.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36])
|
||||
class AppLockGateHostJvmTest {
|
||||
|
||||
@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 and the ON_START effect fires. */
|
||||
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(state: AppLockUiState): AppLockViewModel {
|
||||
val vm = mockk<AppLockViewModel>(relaxed = true)
|
||||
every { vm.uiState } returns MutableStateFlow(state)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun setContent(viewModel: AppLockViewModel) {
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalLifecycleOwner provides resumedOwner) {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
AppLockGateHost(viewModel = viewModel) { Text(CONTENT) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unlocked_showsAppContent() {
|
||||
setContent(viewModel(AppLockUiState.Unlocked))
|
||||
|
||||
composeTestRule.onNodeWithText(CONTENT).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checking_coversContent_showingNeitherAppContentNorLockScreen() {
|
||||
setContent(viewModel(AppLockUiState.Checking))
|
||||
|
||||
// The Checking cover is blank: content is not composed yet (never unlocked), and no lock UI shows.
|
||||
composeTestRule.onNodeWithText(CONTENT).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(string(R.string.app_lock_title)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun locked_showsLockScreen_andHidesContentBeforeTheFirstUnlock() {
|
||||
setContent(viewModel(AppLockUiState.Locked()))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.app_lock_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(CONTENT).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun locked_autoRequestsAuth_reportingAuthErrorWhenNoFragmentActivityHostsIt() {
|
||||
val vm = viewModel(AppLockUiState.Locked())
|
||||
setContent(vm)
|
||||
|
||||
// Entering the Locked branch auto-fires authenticate(); with no hosting FragmentActivity in this
|
||||
// JVM rule it resolves to onAuthError(null) rather than presenting a device-only BiometricPrompt.
|
||||
verify(timeout = 2_000) { vm.onAuthError(null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reLockAfterUnlock_drawsLockScreenButKeepsContentComposed() {
|
||||
val state = MutableStateFlow<AppLockUiState>(AppLockUiState.Unlocked)
|
||||
val vm = mockk<AppLockViewModel>(relaxed = true)
|
||||
every { vm.uiState } returns state
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalLifecycleOwner provides resumedOwner) {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
AppLockGateHost(viewModel = vm) { Text(CONTENT) }
|
||||
}
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText(CONTENT).assertIsDisplayed()
|
||||
|
||||
// Re-lock: once ever unlocked, the content stays composed (its nav/scroll/draft state survives)
|
||||
// and the lock screen is drawn OVER it rather than replacing it.
|
||||
state.value = AppLockUiState.Locked()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.app_lock_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(CONTENT).assertExists()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CONTENT = "app-content-marker"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user