From c42f9a7e01fc4ea62a38257fdc64def86d3e4998 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 20:49:49 -0500 Subject: [PATCH] test(compose): Robolectric JVM tests for settings screens (#380) Convert the settings screens/components to Robolectric JVM Compose tests (umbrella #373, batch 5/9) and drop their globs from `jacocoNonJvmTestableSurface`, so they count toward JaCoCo's JVM-testable surface without an emulator. New `src/test` Robolectric Compose tests (v2 createComposeRule, @Config sdk=36, NATIVE graphics), mocking each ViewModel where needed: - SettingsComponentsJvmTest (SectionHeader/SwitchRow/ClickRow/RadioRow/RetentionSection) - SettingsScreenJvmTest (+ stateless ContactAutocompleteRow) - AccountSettingsScreenJvmTest - SignaturesScreenJvmTest - SignatureEditScreenJvmTest Line coverage of the newly-included files: SettingsComponents 100%, SignatureEditScreen 100%, SignaturesScreen 97%, SettingsScreen 95%, AccountSettingsScreen 84%. Overall scoped line coverage 86.2%. The instrumented androidTest E2E stay; the JaCoCo floor is unchanged (re-ratchet is #386). Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 8 +- .../settings/AccountSettingsScreenJvmTest.kt | 200 ++++++++++++ .../ui/settings/SettingsComponentsJvmTest.kt | 200 ++++++++++++ .../ui/settings/SettingsScreenJvmTest.kt | 295 ++++++++++++++++++ .../ui/settings/SignatureEditScreenJvmTest.kt | 139 +++++++++ .../ui/settings/SignaturesScreenJvmTest.kt | 161 ++++++++++ 6 files changed, 998 insertions(+), 5 deletions(-) create mode 100644 app/src/test/kotlin/org/libremail/ui/settings/AccountSettingsScreenJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/settings/SettingsComponentsJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/settings/SettingsScreenJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/settings/SignatureEditScreenJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/settings/SignaturesScreenJvmTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 857ad63..a54f84c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -331,11 +331,9 @@ val jacocoNonJvmTestableSurface = listOf( // shadow, so ReaderScreenJvmTest asserts the chrome (top bar, star/delete/reply actions, attachment // accordion) and the loading/plain-text/empty/error/remote-images-banner branches — never the // WebView's rendered HTML. HtmlBody.kt stays in scope covered by HtmlBodyTest/InlineImageResolverTest. - "**/AccountSettingsScreen*", - "**/SettingsScreen*", - "**/SettingsComponents*", - "**/SignatureEditScreen*", - "**/SignaturesScreen*", + // SettingsScreen (+ ContactAutocompleteRow), AccountSettingsScreen, SettingsComponents (SwitchRow/ + // ClickRow/RadioRow/RetentionSection), SignaturesScreen & SignatureEditScreen converted to + // Robolectric JVM Compose tests (#380) — now JVM-covered. // CacheEncryptionGate.kt (issue #359/#367 fail-closed encryption gate) is pure render: the gate // composable, its blank cover, the error screen, and the ephemeral report-review screen — no plain // top-level logic. Spelled out to "...GateKt*" (the file's compiled facade class), NOT the bare diff --git a/app/src/test/kotlin/org/libremail/ui/settings/AccountSettingsScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/AccountSettingsScreenJvmTest.kt new file mode 100644 index 0000000..7319046 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/AccountSettingsScreenJvmTest.kt @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import android.content.Context +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +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.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.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.domain.model.Account +import org.libremail.domain.model.AccountSettings +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 tests for [AccountSettingsScreen] (umbrella #373, batch #380). Drives the + * per-account settings screen on the JVM under [RobolectricTestRunner] via the v2 `createComposeRule()` + * — no emulator — with a MockK [AccountSettingsViewModel] (its own logic is covered by + * `AccountSettingsViewModelTest`) so `AccountSettingsScreen.kt` counts toward JaCoCo's JVM-testable + * surface: the email-vs-fallback title branch, the default-account/signature/notification switches, the + * signatures-summary count branch, the per-account retention (with "use default"), and the remove-account + * row. The instrumented `AccountSettingsScreenTest` stays as the on-device E2E. See + * [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class AccountSettingsScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val accountId = "imap:me@example.com" + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + // collectAsStateWithLifecycle needs a LocalLifecycleOwner; the v2 rule hosts no Activity. + private val lifecycleOwner: LifecycleOwner = object : LifecycleOwner { + private val registry = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + override val lifecycle: Lifecycle get() = registry + } + + private fun mockViewModel( + account: Account? = Account.outlook("me@example.com"), + settings: AccountSettings = AccountSettings(accountId), + signatureCount: Int = 0, + defaultSignatureName: String = "", + isDefaultAccount: Boolean = false, + removing: Boolean = false, + ): AccountSettingsViewModel { + val viewModel = mockk(relaxed = true) + every { viewModel.account } returns MutableStateFlow(account) + every { viewModel.settings } returns MutableStateFlow(settings) + every { viewModel.signatureCount } returns MutableStateFlow(signatureCount) + every { viewModel.defaultSignatureName } returns MutableStateFlow(defaultSignatureName) + every { viewModel.isDefaultAccount } returns MutableStateFlow(isDefaultAccount) + every { viewModel.removing } returns MutableStateFlow(removing) + return viewModel + } + + private fun setContent( + viewModel: AccountSettingsViewModel, + onBack: () -> Unit = {}, + onManageSignatures: () -> Unit = {}, + ) { + composeTestRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + AccountSettingsScreen( + onBack = onBack, + onManageSignatures = onManageSignatures, + viewModel = viewModel, + ) + } + } + } + } + + @Test + fun nullAccount_showsFallbackTitle() { + setContent(mockViewModel(account = null)) + + composeTestRule.onNodeWithText(string(R.string.settings_account_title)).assertIsDisplayed() + } + + @Test + fun presentAccount_showsEmailAsTitle() { + setContent(mockViewModel(account = Account.outlook("me@example.com"))) + + composeTestRule.onNodeWithText("me@example.com").assertIsDisplayed() + } + + @Test + fun noSignatures_showsNoneSummary() { + setContent(mockViewModel(signatureCount = 0)) + + composeTestRule.onNodeWithText(string(R.string.settings_signatures_summary_none)) + .performScrollTo().assertIsDisplayed() + } + + @Test + fun withSignatures_showsManageRow() { + setContent(mockViewModel(signatureCount = 2, defaultSignatureName = "Work")) + + composeTestRule.onNodeWithText(string(R.string.settings_signatures_manage)) + .performScrollTo().assertIsDisplayed() + } + + @Test + fun tappingManageSignatures_invokesCallback() { + var managed = false + setContent( + mockViewModel(signatureCount = 1, defaultSignatureName = "Work"), + onManageSignatures = { managed = true }, + ) + + composeTestRule.onNodeWithText(string(R.string.settings_signatures_manage)).performScrollTo().performClick() + assertTrue(managed) + } + + @Test + fun togglingSignatureEnabled_callsViewModel() { + val viewModel = mockViewModel(settings = AccountSettings(accountId, signatureEnabled = true)) + setContent(viewModel) + + composeTestRule.onNodeWithText(string(R.string.settings_signature_enable)).performScrollTo().performClick() + + verify { viewModel.setSignatureEnabled(false) } + } + + @Test + fun togglingAccountNotifications_callsViewModel() { + val viewModel = mockViewModel(settings = AccountSettings(accountId, notificationsEnabled = true)) + setContent(viewModel) + + composeTestRule.onNodeWithText(string(R.string.settings_account_new_mail)).performScrollTo().performClick() + + verify { viewModel.setNotificationsEnabled(false) } + } + + @Test + fun togglingSetDefaultAccount_callsViewModel() { + val viewModel = mockViewModel(isDefaultAccount = false) + setContent(viewModel) + + composeTestRule.onNodeWithText(string(R.string.settings_account_set_default)).performScrollTo().performClick() + + verify { viewModel.setDefaultAccount(true) } + } + + @Test + fun perAccountRetention_offersUseDefault() { + setContent(mockViewModel(settings = AccountSettings(accountId, retentionCount = null))) + + // includeUseDefault = true on the per-account screen, so "use the global default" is offered in + // both the message-count group and the age group. + composeTestRule.onAllNodesWithText(string(R.string.retention_use_default)).assertCountEquals(2) + } + + @Test + fun tappingRemoveAccount_callsViewModel() { + val viewModel = mockViewModel(removing = false) + setContent(viewModel) + + composeTestRule.onNodeWithText(string(R.string.account_remove)).performScrollTo().performClick() + + verify { viewModel.removeAccount(any()) } + } + + @Test + fun tappingBack_invokesCallback() { + var backInvoked = false + setContent(mockViewModel(), onBack = { backInvoked = true }) + + composeTestRule.onNodeWithContentDescription(string(R.string.action_back)).performClick() + assertTrue(backInvoked) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/settings/SettingsComponentsJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/SettingsComponentsJvmTest.kt new file mode 100644 index 0000000..d40d66a --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/SettingsComponentsJvmTest.kt @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import android.content.Context +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.isNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import org.junit.Assert.assertEquals +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 tests for the shared settings row/section composables in + * [SettingsComponents] (umbrella #373, batch #380): [SectionHeader], [SwitchRow], [ClickRow], + * [RadioRow] and the device-only [RetentionSection]. Each stateless piece is driven directly and its + * render + interaction/branch logic asserted deterministically on the JVM under [RobolectricTestRunner] + * via the v2 `createComposeRule()` — no emulator — so `SettingsComponents.kt` counts toward JaCoCo's + * JVM-testable surface. The instrumented `SettingsScreenTest`/`AccountSettingsScreenTest` stay as the + * on-device E2E. See [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class SettingsComponentsJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + /** Renders [content] inside the app theme and a scroll container so tall sections can `performScrollTo`. */ + private fun setContent(content: @Composable () -> Unit) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + Column(Modifier.verticalScroll(rememberScrollState())) { content() } + } + } + } + + @Test + fun sectionHeader_rendersItsText() { + setContent { SectionHeader("Downloading") } + + composeTestRule.onNodeWithText("Downloading").assertIsDisplayed() + } + + @Test + fun switchRow_offWithSubtitle_rowClickTogglesOn() { + var reported: Boolean? = null + setContent { + SwitchRow( + title = "Dynamic color", + checked = false, + onCheckedChange = { reported = it }, + subtitle = "Use wallpaper", + ) + } + + composeTestRule.onNodeWithText("Dynamic color").assertIsDisplayed() + composeTestRule.onNodeWithText("Use wallpaper").assertIsDisplayed() + + composeTestRule.onNodeWithText("Dynamic color").performClick() + assertEquals(true, reported) + } + + @Test + fun switchRow_onWithoutSubtitle_rowClickTogglesOff() { + var reported: Boolean? = null + setContent { SwitchRow(title = "Push", checked = true, onCheckedChange = { reported = it }) } + + composeTestRule.onNodeWithText("Push").performClick() + assertEquals(false, reported) + } + + @Test + fun clickRow_withSubtitle_invokesOnClick() { + var clicked = false + setContent { ClickRow(title = "Add account", onClick = { clicked = true }, subtitle = "Another mailbox") } + + composeTestRule.onNodeWithText("Another mailbox").assertIsDisplayed() + composeTestRule.onNodeWithText("Add account").performClick() + assertTrue(clicked) + } + + @Test + fun clickRow_disabled_rendersAsNotEnabled() { + setContent { ClickRow(title = "Remove", onClick = {}, enabled = false) } + + // enabled = false marks the row disabled (no click semantics action) — the `enabled` branch. + composeTestRule.onNodeWithText("Remove").assert(isNotEnabled()) + } + + @Test + fun radioRow_rendersSubtitle_andClickInvokesOnClick() { + var clicked = false + setContent { + RadioRow(title = "On demand", subtitle = "Manual only", selected = false, onClick = { clicked = true }) + } + + composeTestRule.onNodeWithText("Manual only").assertIsDisplayed() + composeTestRule.onNodeWithText("On demand").performClick() + assertTrue(clicked) + } + + @Test + fun radioRow_selectedWithoutSubtitle_renders() { + setContent { RadioRow(title = "Always", selected = true, onClick = {}) } + + composeTestRule.onNodeWithText("Always").assertIsDisplayed() + } + + @Test + fun retentionSection_globalScreen_hidesUseDefault_andShowsBothGroups() { + setContent { + RetentionSection(count = 0, months = 0, includeUseDefault = false, onCountChange = {}, onMonthsChange = {}) + } + + composeTestRule.onNodeWithText(string(R.string.settings_retention)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.retention_count_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.retention_age_title)).assertIsDisplayed() + // The global screen omits the "use the global default" option in both groups. + composeTestRule.onAllNodesWithText(string(R.string.retention_use_default)).assertCountEquals(0) + } + + @Test + fun retentionSection_accountScreen_showsUseDefaultInBothGroups_andSelectingItReportsNull() { + var reportedCount: Int? = -1 + setContent { + RetentionSection( + count = 500, + months = 6, + includeUseDefault = true, + onCountChange = { reportedCount = it }, + onMonthsChange = {}, + ) + } + + // The per-account screen adds "use the global default" to the count group and the age group. + composeTestRule.onAllNodesWithText(string(R.string.retention_use_default)).assertCountEquals(2) + + // The count group renders first, so its "use default" row is index 0; tapping it persists null. + composeTestRule.onAllNodesWithText(string(R.string.retention_use_default))[0].performScrollTo().performClick() + assertEquals(null, reportedCount) + } + + @Test + fun retentionSection_selectingACountOption_reportsItsValue() { + var reportedCount: Int? = null + setContent { + RetentionSection( + count = 0, + months = 0, + includeUseDefault = false, + onCountChange = { reportedCount = it }, + onMonthsChange = {}, + ) + } + + composeTestRule.onNodeWithText(string(R.string.retention_count_500)).performScrollTo().performClick() + assertEquals(500, reportedCount) + } + + @Test + fun retentionSection_selectingAnAgeOption_reportsItsMonths() { + var reportedMonths: Int? = null + setContent { + RetentionSection( + count = 0, + months = 0, + includeUseDefault = false, + onCountChange = {}, + onMonthsChange = { reportedMonths = it }, + ) + } + + composeTestRule.onNodeWithText(string(R.string.retention_age_3m)).performScrollTo().performClick() + assertEquals(3, reportedMonths) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/settings/SettingsScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/SettingsScreenJvmTest.kt new file mode 100644 index 0000000..3901ba2 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/SettingsScreenJvmTest.kt @@ -0,0 +1,295 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +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.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.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.contacts.ContactPermissionState +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.FetchPolicy +import org.libremail.domain.model.Account +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 tests for [SettingsScreen] + its stateless [ContactAutocompleteRow] (umbrella + * #373, batch #380). Drives the global settings screen on the JVM under [RobolectricTestRunner] via the + * v2 `createComposeRule()` — no emulator — with a MockK [SettingsViewModel] (its own logic is covered by + * `SettingsViewModelTest`) so `SettingsScreen.kt` counts toward JaCoCo's JVM-testable surface: the + * empty-vs-populated accounts branch, the fetch-policy radios, the appearance/notification/backup + * switches, the collapsible Advanced section, the battery subtitle branch, the retention wiring, and the + * contacts row's rationale-vs-blocked dialog branch. The instrumented `SettingsScreenTest` and + * `ContactAutocompleteRowTest` stay as the on-device E2E. See + * [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class SettingsScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + // collectAsStateWithLifecycle / LifecycleEventEffect need a LocalLifecycleOwner; the v2 rule hosts no + // Activity, so provide a RESUMED one by hand (createUnsafe skips the main-thread assertion on the JVM). + private val lifecycleOwner: LifecycleOwner = object : LifecycleOwner { + private val registry = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + override val lifecycle: Lifecycle get() = registry + } + + private fun mockViewModel( + settings: AppSettings = AppSettings(), + accounts: List = emptyList(), + advancedExpanded: Boolean = false, + appLockMessage: Int? = null, + batteryUnrestricted: Boolean = false, + contactsRequested: Boolean = false, + hasContactsPermission: Boolean = false, + ): SettingsViewModel { + val viewModel = mockk(relaxed = true) + every { viewModel.settings } returns MutableStateFlow(settings) + every { viewModel.accounts } returns MutableStateFlow(accounts) + every { viewModel.advancedExpanded } returns MutableStateFlow(advancedExpanded) + every { viewModel.appLockMessage } returns MutableStateFlow(appLockMessage) + every { viewModel.batteryUnrestricted } returns MutableStateFlow(batteryUnrestricted) + every { viewModel.contactsPermissionRequested } returns MutableStateFlow(contactsRequested) + every { viewModel.hasContactsPermission() } returns hasContactsPermission + return viewModel + } + + private fun setContent( + viewModel: SettingsViewModel, + onAddAccount: () -> Unit = {}, + onOpenAccount: (String) -> Unit = {}, + onSelectTab: (org.libremail.ui.TopDest) -> Unit = {}, + onReportProblem: () -> Unit = {}, + ) { + composeTestRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + SettingsScreen( + onAddAccount = onAddAccount, + onOpenAccount = onOpenAccount, + onSelectTab = onSelectTab, + onReportProblem = onReportProblem, + viewModel = viewModel, + ) + } + } + } + } + + private fun setRow(content: @Composable () -> Unit) { + composeTestRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + LibreMailTheme(darkTheme = false, dynamicColor = false) { content() } + } + } + } + + @Test + fun emptyAccounts_showsTitleAndNoAccountsMessage() { + setContent(mockViewModel(accounts = emptyList())) + + // "Settings" also labels the bottom-nav tab, so match the (app-bar) title among all its nodes. + composeTestRule.onAllNodesWithText(string(R.string.title_settings)).onFirst().assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.settings_accounts)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.settings_no_accounts)).assertIsDisplayed() + } + + @Test + fun withAccounts_rendersAccountRow_andNoEmptyMessage() { + setContent(mockViewModel(accounts = listOf(Account.outlook("me@example.com")))) + + composeTestRule.onNodeWithText("me@example.com").assertIsDisplayed() + composeTestRule.onAllNodesWithText(string(R.string.settings_no_accounts)).assertCountEquals(0) + } + + @Test + fun tappingAddAccountRow_invokesCallback() { + var added = false + setContent(mockViewModel(), onAddAccount = { added = true }) + + composeTestRule.onNodeWithText(string(R.string.settings_add_account)).performScrollTo().performClick() + assertTrue(added) + } + + @Test + fun selectingOnDemandFetchPolicy_callsViewModel() { + val viewModel = mockViewModel(settings = AppSettings(fetchPolicy = FetchPolicy.ALWAYS)) + setContent(viewModel) + + composeTestRule.onNodeWithText(string(R.string.fetch_on_demand)).performScrollTo().performClick() + + verify { viewModel.setFetchPolicy(FetchPolicy.ON_DEMAND) } + } + + @Test + fun togglingDynamicColor_callsViewModel() { + val viewModel = mockViewModel(settings = AppSettings(dynamicColor = false)) + setContent(viewModel) + + composeTestRule.onNodeWithText(string(R.string.settings_dynamic_color)).performScrollTo().performClick() + + verify { viewModel.setDynamicColor(true) } + } + + @Test + fun togglingNewMailNotifications_callsViewModel() { + val viewModel = mockViewModel(settings = AppSettings(newMailNotifications = true)) + setContent(viewModel) + + composeTestRule.onNodeWithText(string(R.string.settings_new_mail)).performScrollTo().performClick() + + verify { viewModel.setNewMailNotifications(false) } + } + + @Test + fun batteryRow_showsOptimizedSubtitle_whenRestricted() { + setContent(mockViewModel(batteryUnrestricted = false)) + + composeTestRule.onNodeWithText(string(R.string.settings_adv_battery_optimized)) + .performScrollTo().assertIsDisplayed() + } + + @Test + fun batteryRow_showsUnrestrictedSubtitle_whenUnrestricted() { + setContent(mockViewModel(batteryUnrestricted = true)) + + composeTestRule.onNodeWithText(string(R.string.settings_adv_battery_unrestricted)) + .performScrollTo().assertIsDisplayed() + } + + @Test + fun selectingGlobalRetentionCount_callsViewModel() { + val viewModel = mockViewModel(settings = AppSettings(retentionCount = 0)) + setContent(viewModel) + + composeTestRule.onNodeWithText(string(R.string.retention_count_500)).performScrollTo().performClick() + + // The global screen maps a chosen count straight through (null coalesced to 0, but 500 here). + verify { viewModel.setRetentionCount(500) } + } + + @Test + fun tappingReportProblemRow_invokesCallback() { + var reported = false + setContent(mockViewModel(), onReportProblem = { reported = true }) + + composeTestRule.onNodeWithText(string(R.string.settings_report_problem)).performScrollTo().performClick() + assertTrue(reported) + } + + @Test + fun tappingAdvancedHeader_callsToggleAdvanced() { + val viewModel = mockViewModel(advancedExpanded = false) + setContent(viewModel) + + composeTestRule.onNodeWithText(string(R.string.settings_advanced)).performScrollTo().performClick() + + verify { viewModel.toggleAdvanced() } + } + + @Test + fun advancedExpanded_showsAdvancedSwitches_andTogglingOneCallsViewModel() { + val viewModel = mockViewModel( + advancedExpanded = true, + settings = AppSettings(pushIdle = true, allowStartTls = false), + ) + setContent(viewModel) + + composeTestRule.onNodeWithText(string(R.string.settings_adv_app_lock)).performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.settings_adv_encrypt_cache)) + .performScrollTo().assertIsDisplayed() + + composeTestRule.onNodeWithText(string(R.string.settings_adv_starttls)).performScrollTo().performClick() + verify { viewModel.setAllowStartTls(true) } + } + + @Test + fun contactsRow_deniedState_showsOffSubtitle_andTapOpensRationaleDialog() { + setContent(mockViewModel(contactsRequested = false, hasContactsPermission = false)) + + composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_off)) + .performScrollTo().assertIsDisplayed() + + composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete)).performScrollTo().performClick() + + // DENIED (never asked, re-requestable) opens the rationale dialog before the system prompt. Assert + // on the unique rationale body — the dialog *title* reuses the row's own "Recipient autocomplete". + composeTestRule.onNodeWithText(string(R.string.settings_contacts_rationale)).assertIsDisplayed() + + // Cancelling dismisses the dialog. + composeTestRule.onNodeWithText(string(R.string.cancel)).performClick() + composeTestRule.onAllNodesWithText(string(R.string.settings_contacts_rationale)).assertCountEquals(0) + } + + @Test + fun contactsRow_blockedState_showsBlockedSubtitle_andTapOpensBlockedDialog() { + // granted = false, no activity (so no rationale), already requested → permanently blocked. + setContent(mockViewModel(contactsRequested = true, hasContactsPermission = false)) + + composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_blocked)) + .performScrollTo().assertIsDisplayed() + + composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete)).performScrollTo().performClick() + + composeTestRule.onNodeWithText(string(R.string.settings_contacts_blocked_body)).assertIsDisplayed() + } + + // --- ContactAutocompleteRow (stateless, lives in SettingsScreen.kt) — mirrors ContactAutocompleteRowTest --- + + @Test + fun contactAutocompleteRow_granted_showsOnSubtitle_andIsClickable() { + var clicked = false + setRow { ContactAutocompleteRow(state = ContactPermissionState.GRANTED, onClick = { clicked = true }) } + + composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_on)).assertIsDisplayed() + + composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_on)).performClick() + assertTrue(clicked) + } + + @Test + fun contactAutocompleteRow_denied_showsOffSubtitle() { + setRow { ContactAutocompleteRow(state = ContactPermissionState.DENIED, onClick = {}) } + + composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_off)).assertIsDisplayed() + } + + @Test + fun contactAutocompleteRow_blocked_showsBlockedSubtitle() { + setRow { ContactAutocompleteRow(state = ContactPermissionState.BLOCKED, onClick = {}) } + + composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_blocked)).assertIsDisplayed() + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/settings/SignatureEditScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/SignatureEditScreenJvmTest.kt new file mode 100644 index 0000000..6fccdf0 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/SignatureEditScreenJvmTest.kt @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import android.content.Context +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.isNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +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 tests for [SignatureEditScreen] (umbrella #373, batch #380). Drives the + * signature editor on the JVM under [RobolectricTestRunner] via the v2 `createComposeRule()` — no + * emulator — with a MockK [SignatureEditViewModel] (its own logic is covered by + * `SignatureEditViewModelTest`) so `SignatureEditScreen.kt` counts toward JaCoCo's JVM-testable surface: + * the new-vs-edit title branch, the name field, the loaded/not-loaded body-field branch, the name-change + * callback, and the save button (including its saving-disabled state). The instrumented + * `SignatureEditScreenTest` stays as the on-device E2E. See + * [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class SignatureEditScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + private val lifecycleOwner: LifecycleOwner = object : LifecycleOwner { + private val registry = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + override val lifecycle: Lifecycle get() = registry + } + + private fun mockViewModel(isNew: Boolean, state: SignatureEditUiState): SignatureEditViewModel { + val viewModel = mockk(relaxed = true) + every { viewModel.isNew } returns isNew + every { viewModel.state } returns MutableStateFlow(state) + return viewModel + } + + private fun setContent(viewModel: SignatureEditViewModel, onBack: () -> Unit = {}) { + composeTestRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + SignatureEditScreen(onBack = onBack, viewModel = viewModel) + } + } + } + } + + @Test + fun newSignature_showsNewTitle_andNameField() { + setContent(mockViewModel(isNew = true, state = SignatureEditUiState(loaded = true))) + + composeTestRule.onNodeWithText(string(R.string.signature_new_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.signature_name)).assertIsDisplayed() + } + + @Test + fun existingSignature_showsEditTitle_prefillsName_andShowsBodyField() { + setContent( + mockViewModel( + isNew = false, + state = SignatureEditUiState( + name = "Personal", + body = "Regards", + bodyHtml = "

Regards

", + loaded = true, + ), + ), + ) + + composeTestRule.onNodeWithText(string(R.string.signature_edit_title)).assertIsDisplayed() + composeTestRule.onNodeWithText("Personal").assertIsDisplayed() + // The rich-text body field (loaded == true) renders with the "signature content" label. + composeTestRule.onNodeWithText(string(R.string.signature_content)).assertIsDisplayed() + } + + @Test + fun notLoaded_hidesBodyField() { + setContent(mockViewModel(isNew = false, state = SignatureEditUiState(name = "Personal", loaded = false))) + + // The body field is gated on state.loaded, so its label is absent until the signature loads. + composeTestRule.onAllNodesWithText(string(R.string.signature_content)).assertCountEquals(0) + } + + @Test + fun typingIntoName_callsOnNameChange() { + val viewModel = mockViewModel(isNew = true, state = SignatureEditUiState(loaded = true)) + setContent(viewModel) + + composeTestRule.onNodeWithText(string(R.string.signature_name)).performTextInput("Work") + + verify { viewModel.onNameChange(any()) } + } + + @Test + fun tappingSave_callsViewModel() { + val viewModel = mockViewModel(isNew = true, state = SignatureEditUiState(loaded = true, saving = false)) + setContent(viewModel) + + composeTestRule.onNodeWithText(string(R.string.signature_save)).performClick() + + verify { viewModel.save(any()) } + } + + @Test + fun whileSaving_saveButtonIsDisabled() { + setContent(mockViewModel(isNew = true, state = SignatureEditUiState(loaded = true, saving = true))) + + composeTestRule.onNodeWithText(string(R.string.signature_save)).assert(isNotEnabled()) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/settings/SignaturesScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/SignaturesScreenJvmTest.kt new file mode 100644 index 0000000..00701d1 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/SignaturesScreenJvmTest.kt @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import android.content.Context +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.isNotSelected +import androidx.compose.ui.test.isSelectable +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +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.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.domain.model.Signature +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 tests for [SignaturesScreen] (umbrella #373, batch #380). Drives the + * signatures list on the JVM under [RobolectricTestRunner] via the v2 `createComposeRule()` — no + * emulator — with a MockK [SignaturesViewModel] (its own logic is covered by `SignaturesViewModelTest`) + * so `SignaturesScreen.kt` counts toward JaCoCo's JVM-testable surface: the empty state, name + + * default-badge rendering, the row → edit tap, the make-default radio, the delete action, and the add FAB. + * The instrumented `SignaturesScreenTest` stays as the on-device E2E. See + * [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class SignaturesScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + private val lifecycleOwner: LifecycleOwner = object : LifecycleOwner { + private val registry = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + override val lifecycle: Lifecycle get() = registry + } + + private fun signature(id: String, name: String, isDefault: Boolean) = + Signature(id = id, accountId = "imap:a", name = name, html = "

$name body

", isDefault = isDefault) + + private fun mockViewModel(signatures: List): SignaturesViewModel { + val viewModel = mockk(relaxed = true) + every { viewModel.signatures } returns MutableStateFlow(signatures) + return viewModel + } + + private fun setContent(viewModel: SignaturesViewModel, onEdit: (String) -> Unit = {}, onAdd: () -> Unit = {}) { + composeTestRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + SignaturesScreen(onBack = {}, onEdit = onEdit, onAdd = onAdd, viewModel = viewModel) + } + } + } + } + + @Test + fun noSignatures_showsEmptyState() { + setContent(mockViewModel(emptyList())) + + composeTestRule.onNodeWithText(string(R.string.signatures_empty)).assertIsDisplayed() + } + + @Test + fun signatures_renderNames_withExactlyOneDefaultBadge() { + setContent( + mockViewModel( + listOf( + signature("s1", "Work", isDefault = true), + signature("s2", "Personal", isDefault = false), + ), + ), + ) + + composeTestRule.onNodeWithText("Work").assertIsDisplayed() + composeTestRule.onNodeWithText("Personal").assertIsDisplayed() + composeTestRule.onAllNodesWithText(string(R.string.signature_default_badge)).assertCountEquals(1) + } + + @Test + fun tappingASignatureRow_invokesOnEditWithItsId() { + var editedId: String? = null + setContent( + mockViewModel( + listOf( + signature("s1", "Work", isDefault = true), + signature("s2", "Personal", isDefault = false), + ), + ), + onEdit = { editedId = it }, + ) + + composeTestRule.onNodeWithText("Personal").performClick() + assertEquals("s2", editedId) + } + + @Test + fun tappingRadioOnNonDefault_callsSetDefault() { + val viewModel = mockViewModel( + listOf( + signature("s1", "Work", isDefault = true), + signature("s2", "Personal", isDefault = false), + ), + ) + setContent(viewModel) + + // "Work" is the default (its radio is selected); the only unselected radio is "Personal"'s. + composeTestRule.onNode(isSelectable() and isNotSelected()).performClick() + + verify { viewModel.setDefault("s2") } + } + + @Test + fun tappingDelete_callsDeleteForThatRow() { + val viewModel = mockViewModel( + listOf( + signature("s1", "Work", isDefault = true), + signature("s2", "Personal", isDefault = false), + ), + ) + setContent(viewModel) + + composeTestRule.onAllNodesWithContentDescription(string(R.string.signature_delete))[0].performClick() + + verify { viewModel.delete("s1") } + } + + @Test + fun tappingAddFab_invokesOnAdd() { + var added = false + setContent(mockViewModel(emptyList()), onAdd = { added = true }) + + composeTestRule.onNodeWithContentDescription(string(R.string.signatures_add)).performClick() + assertTrue(added) + } +}