test(compose): Robolectric JVM tests for settings screens (#380) #401
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user