test(compose): Robolectric JVM tests for settings screens (#380) #401

Merged
JMR-dev merged 1 commits from test-380-robolectric-settings into main 2026-07-07 05:19:42 +00:00
6 changed files with 998 additions and 5 deletions
+3 -5
View File
@@ -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
@@ -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<AccountSettingsViewModel>(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)
}
}
@@ -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)
}
}
@@ -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<Account> = emptyList(),
advancedExpanded: Boolean = false,
appLockMessage: Int? = null,
batteryUnrestricted: Boolean = false,
contactsRequested: Boolean = false,
hasContactsPermission: Boolean = false,
): SettingsViewModel {
val viewModel = mockk<SettingsViewModel>(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()
}
}
@@ -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<SignatureEditViewModel>(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 = "<p>Regards</p>",
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())
}
}
@@ -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 = "<p>$name body</p>", isDefault = isDefault)
private fun mockViewModel(signatures: List<Signature>): SignaturesViewModel {
val viewModel = mockk<SignaturesViewModel>(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)
}
}