From 799669d6a61e627d80db791baaed35cc0664d734 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 20:04:17 -0500 Subject: [PATCH] test(compose): Robolectric JVM tests for onboarding & lock screens (#377) Add Robolectric JVM Compose tests (umbrella #373, batch 2/9) for the stateless onboarding + lock screens, and drop each from jacocoNonJvmTestableSurface so its render/interaction code now counts as JVM-testable surface: - LockScreen: locked title/body, optional error, unlock callback. - WelcomeContent + OnboardingWelcomeScreen: render + add-account; the wrapper's NotificationPermissionEffect launcher is wired to a no-op ActivityResultRegistry so no system dialog is surfaced on the JVM. - LicenseScreen: real bundled GPL text renders, Agree gated on scroll-to-end, Decline. - ContactsAccessContent (skip/grant/request/rationale) plus the ContactsAccessScreen wrapper, driven by a mocked OnboardingViewModel. - BatteryOptimizationScreen: offered vs. done states; Take me there marks the prompt handled and resolves the settings intent; Not now finishes. Contacts/Battery use a tall @Config qualifier so their centered, non-scrolling columns fit without the lower controls clipping. The instrumented androidTest tests are kept (and remain the coverage for the system back press, which the JVM compose rule cannot drive). JaCoCo floor unchanged at 0.79 (the re-ratchet is the final #373 step). Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 9 +- .../libremail/ui/lock/LockScreenJvmTest.kt | 82 +++++++ .../BatteryOptimizationScreenJvmTest.kt | 122 +++++++++++ .../onboarding/ContactsAccessScreenJvmTest.kt | 204 ++++++++++++++++++ .../ui/onboarding/LicenseScreenJvmTest.kt | 113 ++++++++++ .../OnboardingWelcomeScreenJvmTest.kt | 110 ++++++++++ 6 files changed, 634 insertions(+), 6 deletions(-) create mode 100644 app/src/test/kotlin/org/libremail/ui/lock/LockScreenJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreenJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/onboarding/ContactsAccessScreenJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/onboarding/LicenseScreenJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreenJvmTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f505ed9..3960ccd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -320,15 +320,12 @@ val jacocoNonJvmTestableSurface = listOf( // ColorSwatch(Row), FontPicker, FontSizePicker & ParagraphAlignmentControl converted to // Robolectric JVM Compose tests (#376) — now JVM-covered. "**/DraftsScreen*", - "**/LockScreen*", + // LockScreen converted to a Robolectric JVM Compose test (#377) — now JVM-covered. "**/AppLockGateHost*", "**/FolderDrawer*", "**/MailboxScreen*", - // AddAnotherAccountScreen converted to a Robolectric JVM Compose test (#373) — now JVM-covered. - "**/BatteryOptimizationScreen*", - "**/ContactsAccessScreen*", - "**/LicenseScreen*", - "**/OnboardingWelcomeScreen*", + // AddAnotherAccountScreen (#373) plus the onboarding welcome/license and contacts/battery steps + // (#377) converted to Robolectric JVM Compose tests — now JVM-covered. "**/OutboxScreen*", "**/ReaderScreen*", "**/ProblemReportsScreen*", diff --git a/app/src/test/kotlin/org/libremail/ui/lock/LockScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/lock/LockScreenJvmTest.kt new file mode 100644 index 0000000..d8bcb92 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/lock/LockScreenJvmTest.kt @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.lock + +import android.content.Context +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 org.junit.Assert.assertTrue +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 (#377, umbrella #373) for the app-lock gate. Mirrors the instrumented + * [LockScreenTest] on the JVM via the v2 `createComposeRule()` under [RobolectricTestRunner], so + * [LockScreen]'s render + interaction paths become JaCoCo JVM-testable (this file is dropped from + * `jacocoNonJvmTestableSurface`). The instrumented `androidTest` [LockScreenTest] stays. + * + * [LockScreen] is presentational — its biometric prompt is driven by the caller via [onUnlock] — so + * it is exercised in isolation: the locked title/body always show, an optional error string appears + * only when non-null, and the unlock button reports back. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class LockScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + private fun setContent(error: String? = null, onUnlock: () -> Unit = {}) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + LockScreen(error = error, onUnlock = onUnlock) + } + } + } + + @Test + fun showsLockedTitleBody_andUnlockButton() { + setContent() + + composeTestRule.onNodeWithText(string(R.string.app_lock_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.app_lock_locked_body)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.app_lock_unlock)).assertIsDisplayed() + } + + @Test + fun noError_hidesErrorText() { + setContent(error = null) + + composeTestRule.onNodeWithText(string(R.string.app_lock_unlock_failed)).assertDoesNotExist() + } + + @Test + fun error_isDisplayed() { + setContent(error = string(R.string.app_lock_unlock_failed)) + + composeTestRule.onNodeWithText(string(R.string.app_lock_unlock_failed)).assertIsDisplayed() + } + + @Test + fun tappingUnlock_invokesOnUnlock() { + var unlocked = false + setContent(onUnlock = { unlocked = true }) + + composeTestRule.onNodeWithText(string(R.string.app_lock_unlock)).performClick() + + assertTrue(unlocked) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreenJvmTest.kt new file mode 100644 index 0000000..9474018 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreenJvmTest.kt @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import android.content.Context +import android.content.Intent +import android.provider.Settings +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.assertFalse +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.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 (#377, umbrella #373) for the onboarding battery opt-in step (#49). + * The whole screen is the [OnboardingViewModel] wrapper, so it is driven with a mocked view model + * exposing the `batteryUnrestricted` StateFlow: the "already unrestricted → done" state and the + * "offered" state (guidance + Take me there + Not now) are both rendered, the Take-me-there path is + * asserted to mark the prompt handled and resolve the settings intent, and the ON_RESUME re-read is + * verified. Mirrors the instrumented `BatteryOptimizationStepTest` (which covers the same paths + * through the real view model + NavHost). This file is dropped from `jacocoNonJvmTestableSurface`; + * the instrumented test stays. + */ +// A tall display (`+h2000dp`, merged onto the default device config) so the centered, non-scrolling +// column fits its full "offered" state — guidance + Take me there + Not now — without the lower +// controls clipping below Robolectric's small default viewport (which would make performClick miss). +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "+w411dp-h2000dp") +class BatteryOptimizationScreenJvmTest { + + @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_RESUME 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 setContent(viewModel: OnboardingViewModel, onFinish: () -> Unit = {}) { + composeTestRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides resumedOwner) { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + BatteryOptimizationScreen(viewModel = viewModel, onFinish = onFinish) + } + } + } + } + + @Test + fun unrestricted_showsDoneState_andContinues() { + val vm = mockk(relaxed = true) + every { vm.batteryUnrestricted } returns MutableStateFlow(true) + var finished = false + setContent(vm, onFinish = { finished = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_done_title)).assertIsDisplayed() + // The request/skip affordances are gone in the "done" state. + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)).assertDoesNotExist() + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).assertDoesNotExist() + + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_continue)).performClick() + assertTrue(finished) + // The ON_RESUME effect re-reads the live status. + verify { vm.refreshBatteryStatus() } + } + + @Test + fun notUnrestricted_takeMeThere_marksHandledAndResolvesSettingsIntent() { + val vm = mockk(relaxed = true) + every { vm.batteryUnrestricted } returns MutableStateFlow(false) + every { vm.batterySettingsIntent() } returns Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + var finished = false + setContent(vm, onFinish = { finished = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_guidance)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)).performClick() + + // Take me there marks the prompt handled up front, then launches the resolved settings intent. + verify { vm.markBatteryPromptHandled() } + verify { vm.batterySettingsIntent() } + // Leaving for Settings does not itself finish onboarding. + assertFalse(finished) + } + + @Test + fun notUnrestricted_notNow_finishes() { + val vm = mockk(relaxed = true) + every { vm.batteryUnrestricted } returns MutableStateFlow(false) + var finished = false + setContent(vm, onFinish = { finished = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).performClick() + + assertTrue(finished) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/ContactsAccessScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/ContactsAccessScreenJvmTest.kt new file mode 100644 index 0000000..8aa723f --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/onboarding/ContactsAccessScreenJvmTest.kt @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import android.content.Context +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.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +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.assertFalse +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.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 (#377, umbrella #373) for the onboarding contacts-access step (#127, + * #128). Two layers: + * - the stateless [ContactsAccessContent] driven with explicit signals for its three paths — skip, + * grant (the "done" state), and request (with the re-ask rationale) — mirroring the instrumented + * `ContactsAccessStepTest`; + * - the [ContactsAccessScreen] wrapper driven with a mocked [OnboardingViewModel] exposing the + * `contactsGranted` StateFlow, so the wrapper's own wiring (the allow → mark-requested → launch + * path, skip/continue, and the ON_RESUME re-read) gains JVM coverage without a live permission + * dialog. + * + * This file is dropped from `jacocoNonJvmTestableSurface`; the instrumented test stays. + */ +// A tall display (`+h2000dp`, merged onto the default device config) so the centered, non-scrolling +// column fits its full state — including the re-ask rationale plus Allow and Not now — without the +// lower controls clipping below Robolectric's small default viewport (which would make performClick +// miss). +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "+w411dp-h2000dp") +class ContactsAccessScreenJvmTest { + + @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_RESUME effect fires. */ + 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 the wrapper's READ_CONTACTS launcher never surfaces a system dialog. */ + private val noopRegistryOwner = object : ActivityResultRegistryOwner { + override val activityResultRegistry = object : ActivityResultRegistry() { + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + // Intentionally never dispatch a result: the launch is a no-op in this JVM test. + } + } + } + + // --- Stateless ContactsAccessContent ------------------------------------------------------- + + private fun setContent( + granted: Boolean, + showRationale: Boolean = false, + onAllow: () -> Unit = {}, + onSkip: () -> Unit = {}, + onContinue: () -> Unit = {}, + ) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + ContactsAccessContent( + granted = granted, + showRationale = showRationale, + onAllow = onAllow, + onSkip = onSkip, + onContinue = onContinue, + ) + } + } + } + + @Test + fun notGranted_notNow_skipsTheStep() { + var skipped = false + var allowed = false + setContent(granted = false, onAllow = { allowed = true }, onSkip = { skipped = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_not_now)).performClick() + + assertTrue("Not now must invoke the skip callback", skipped) + assertFalse("Skipping must not request the permission", allowed) + } + + @Test + fun notGranted_allow_triggersTheRequest() { + var allowed = false + setContent(granted = false, onAllow = { allowed = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_allow)).performClick() + + assertTrue("Allow must trigger the permission request", allowed) + } + + @Test + fun granted_showsDoneState_andContinues() { + var continued = false + setContent(granted = true, onContinue = { continued = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_done_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_allow)).assertDoesNotExist() + composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_not_now)).assertDoesNotExist() + + composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_continue)).performClick() + assertTrue("Continue must invoke the continue callback", continued) + } + + @Test + fun reRequest_showsRationale() { + setContent(granted = false, showRationale = true) + + composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_rationale)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_allow)).assertIsDisplayed() + } + + // --- ContactsAccessScreen wrapper (VM-driven) ---------------------------------------------- + + private fun setWrapper(viewModel: OnboardingViewModel, onFinish: () -> Unit = {}) { + composeTestRule.setContent { + CompositionLocalProvider( + LocalLifecycleOwner provides resumedOwner, + LocalActivityResultRegistryOwner provides noopRegistryOwner, + ) { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + ContactsAccessScreen(viewModel = viewModel, onFinish = onFinish) + } + } + } + } + + @Test + fun wrapper_granted_showsDone_continuesAndRereadsOnResume() { + val vm = mockk(relaxed = true) + every { vm.contactsGranted } returns MutableStateFlow(true) + var finished = false + setWrapper(vm, onFinish = { finished = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_done_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_continue)).performClick() + + assertTrue(finished) + // The ON_RESUME effect re-reads the live grant when the step comes to the fore. + verify { vm.refreshContactsStatus() } + } + + @Test + fun wrapper_notGranted_allow_marksRequestedAndLaunches() { + val vm = mockk(relaxed = true) + every { vm.contactsGranted } returns MutableStateFlow(false) + setWrapper(vm) + + composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_allow)).performClick() + + // Allow persists "the dialog was shown" up front, then launches the system request. + verify { vm.markContactsPermissionRequested() } + } + + @Test + fun wrapper_notGranted_notNow_finishes() { + val vm = mockk(relaxed = true) + every { vm.contactsGranted } returns MutableStateFlow(false) + var finished = false + setWrapper(vm, onFinish = { finished = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_not_now)).performClick() + + assertTrue(finished) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/LicenseScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/LicenseScreenJvmTest.kt new file mode 100644 index 0000000..2e9b192 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/onboarding/LicenseScreenJvmTest.kt @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import android.content.Context +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.runtime.CompositionLocalProvider +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.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner +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.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 (#377, umbrella #373) for [LicenseScreen] (#172). Mirrors the + * instrumented `LicenseScreenTest` on the JVM: Agree is gated on having scrolled to the end, Decline + * always works, and the real bundled GPL-3.0 text (`res/raw/license.txt`) actually renders — proving + * the merged Android resources loaded on the JVM. This file is dropped from + * `jacocoNonJvmTestableSurface`; the instrumented test stays (and remains the coverage for the system + * back press, which the JVM rule cannot drive — see [owner]). + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class LicenseScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + // LicenseScreen's BackHandler requires both a lifecycle owner and an OnBackPressedDispatcherOwner + // in the composition, so a resumed owner is provided for it to compose against. The actual + // back-press → onDecline behaviour is covered by the instrumented LicenseScreenTest (kept per + // #377); here the BackHandler registration is exercised by rendering and the onDecline path by the + // Decline button, so this JVM test deliberately does not re-drive a system back press (which the + // v2 Robolectric compose rule does not surface a reliable dispatch hook for). + private val owner = object : OnBackPressedDispatcherOwner { + private val registry = + LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + private val dispatcher = OnBackPressedDispatcher() + override val lifecycle: Lifecycle get() = registry + override val onBackPressedDispatcher: OnBackPressedDispatcher get() = dispatcher + } + + private fun setContent(onAgree: () -> Unit = {}, onDecline: () -> Unit = {}) { + composeTestRule.setContent { + CompositionLocalProvider( + LocalLifecycleOwner provides owner, + LocalOnBackPressedDispatcherOwner provides owner, + ) { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + LicenseScreen(onAgree = onAgree, onDecline = onDecline) + } + } + } + } + + @Test + fun licenseText_rendersTheRealBundledGplText() { + setContent() + + // Proves res/raw/license.txt actually loaded, not just that some placeholder text exists. + composeTestRule.onNodeWithText("GNU GENERAL PUBLIC LICENSE", substring = true).assertExists() + } + + @Test + fun agreeButton_isDisabledUntilScrolledToTheEnd() { + setContent() + + composeTestRule.onNodeWithText(string(R.string.license_agree)).assertIsNotEnabled() + } + + @Test + fun agreeButton_scrolledToEnd_becomesEnabledAndInvokesOnAgree() { + var agreed = false + setContent(onAgree = { agreed = true }) + + composeTestRule.onNodeWithTag(LICENSE_SCROLL_END_TAG).performScrollTo() + + composeTestRule.onNodeWithText(string(R.string.license_agree)).assertIsEnabled().performClick() + assertTrue(agreed) + } + + @Test + fun declineButton_worksWithoutScrolling_andInvokesOnDecline() { + var declined = false + setContent(onDecline = { declined = true }) + + // No scrolling first: Decline must never be gated the way Agree is. + composeTestRule.onNodeWithText(string(R.string.license_decline)).assertIsEnabled().performClick() + + assertTrue(declined) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreenJvmTest.kt new file mode 100644 index 0000000..694f54c --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreenJvmTest.kt @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import android.content.Context +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.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.core.app.ActivityOptionsCompat +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.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 (#377, umbrella #373) for the first-run welcome. Renders + * [WelcomeContent] directly for the render + "add account" interaction — as the instrumented + * `OnboardingWelcomeScreenTest` does — to avoid the real POST_NOTIFICATIONS permission dialog that + * [OnboardingWelcomeScreen]'s `NotificationPermissionEffect` would otherwise fire on a device. + * + * A separate case does drive the full [OnboardingWelcomeScreen] wrapper (Scaffold + the notification + * effect), because on the JVM the effect's launcher can be wired to a no-op [ActivityResultRegistry] + * so it never surfaces a system dialog — bringing the wrapper + effect into JVM coverage too. This + * file is dropped from `jacocoNonJvmTestableSurface`; the instrumented test stays. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class OnboardingWelcomeScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + /** + * A no-op [ActivityResultRegistry] so [OnboardingWelcomeScreen]'s notification-permission launcher + * can register and fire on the JVM without ever surfacing a real system permission dialog. + */ + private val noopRegistryOwner = object : ActivityResultRegistryOwner { + override val activityResultRegistry = object : ActivityResultRegistry() { + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + // Intentionally never dispatch a result: the launch is a no-op in this JVM test. + } + } + } + + @Test + fun welcomeContent_rendersTitleSubtitleAndAddAccount() { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + WelcomeContent(onAddAccount = {}) + } + } + + composeTestRule.onNodeWithText(string(R.string.onboarding_welcome_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_welcome_subtitle)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).assertIsDisplayed() + } + + @Test + fun welcomeContent_tappingAddAccount_invokesCallback() { + var added = false + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + WelcomeContent(onAddAccount = { added = true }) + } + } + + composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick() + + assertTrue(added) + } + + @Test + fun onboardingWelcomeScreen_rendersWelcome_andAddAccountReachesCallback() { + var added = false + composeTestRule.setContent { + CompositionLocalProvider(LocalActivityResultRegistryOwner provides noopRegistryOwner) { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + OnboardingWelcomeScreen(onAddAccount = { added = true }) + } + } + } + + // The full screen (Scaffold + NotificationPermissionEffect) composes and shows the welcome copy. + composeTestRule.onNodeWithText(string(R.string.onboarding_welcome_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick() + + assertTrue(added) + } +}