test(compose): Robolectric JVM tests — app shell & lock gate host (#384)

Batch 9/9 (final) of the Robolectric Compose JVM-test epic (#373).

- AppLockGateHostJvmTest: drives the app-lock gate host on the JVM via the
  v2 createComposeRule under Robolectric, covering the Unlocked / Checking /
  Locked render branches, the "content stays composed after re-lock" latch,
  and the no-FragmentActivity auth-error path. AppLockViewModel is mocked.
  Drops **/AppLockGateHost* from jacocoNonJvmTestableSurface.
- LibreMailAppJvmTest: covers the JVM-tractable parts of LibreMailApp.kt —
  LibreMailBottomBar, StartupCrashPrompt (+ its dialog buttons), and
  LibreMailApp's cold-start "hold until known" guards.
- LibreMailApp itself KEPT excluded (the acceptable exception noted in #384):
  its NavHost start destinations call hiltViewModel() and the graph needs
  owners a plain JVM compose rule can't surface, so graph-level nav stays on
  the instrumented OnboardingFlowTest. Documented in the jacoco list.

Instrumented LibreMailBottomBarTest / StartupCrashPromptTest stay as the
on-device E2E. JaCoCo floor unchanged (0.79); scoped line coverage 0.8426.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 01:15:07 -05:00
co-authored by Claude Opus 4.8
parent 97a51ed17d
commit 9f7ebdafb9
3 changed files with 349 additions and 1 deletions
+6 -1
View File
@@ -312,6 +312,11 @@ val jacocoGeneratedExcludes = listOf(
// every `*ViewModel*`. // every `*ViewModel*`.
val jacocoNonJvmTestableSurface = listOf( val jacocoNonJvmTestableSurface = listOf(
// --- Compose UI render code: one glob per screen/component file (see the exceptions above) --- // --- 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*", "**/LibreMailApp*",
// AccountPickerScreen, AppPasswordSetupScreen & ManualSetupScreen converted to Robolectric JVM // AccountPickerScreen, AppPasswordSetupScreen & ManualSetupScreen converted to Robolectric JVM
// Compose tests (#378) — now JVM-covered. // Compose tests (#378) — now JVM-covered.
@@ -322,7 +327,7 @@ val jacocoNonJvmTestableSurface = listOf(
// DraftsScreen, OutboxScreen & ProblemReportsScreen converted to Robolectric JVM Compose tests // DraftsScreen, OutboxScreen & ProblemReportsScreen converted to Robolectric JVM Compose tests
// (#379) — now JVM-covered. // (#379) — now JVM-covered.
// LockScreen converted to a Robolectric JVM Compose test (#377) — 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 // FolderDrawer & MailboxScreen (the Paging 3 mailbox list + folder drawer) converted to
// Robolectric JVM Compose tests (#383) — now JVM-covered. // Robolectric JVM Compose tests (#383) — now JVM-covered.
// AddAnotherAccountScreen (#373) plus the onboarding welcome/license and contacts/battery steps // 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"
}
}