diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5878c1c..a067624 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/test/kotlin/org/libremail/ui/LibreMailAppJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/LibreMailAppJvmTest.kt new file mode 100644 index 0000000..538f9a9 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/LibreMailAppJvmTest.kt @@ -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(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(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" + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/lock/AppLockGateHostJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/lock/AppLockGateHostJvmTest.kt new file mode 100644 index 0000000..3072a57 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/lock/AppLockGateHostJvmTest.kt @@ -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(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.Unlocked) + val vm = mockk(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" + } +}