diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e93ecbc..9a6d3d3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -319,15 +319,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) + } +}