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:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user