Merge branch 'main' into test-383-robolectric-mailbox
This commit is contained in:
+10
-7
@@ -315,7 +315,8 @@ val jacocoNonJvmTestableSurface = listOf(
|
||||
"**/LibreMailApp*",
|
||||
// AccountPickerScreen, AppPasswordSetupScreen & ManualSetupScreen converted to Robolectric JVM
|
||||
// Compose tests (#378) — now JVM-covered.
|
||||
"**/ComposeScreen*",
|
||||
// ComposeScreen (the email editor) converted to a Robolectric JVM Compose test (#382) — now
|
||||
// JVM-covered.
|
||||
// ColorSwatch(Row), FontPicker, FontSizePicker & ParagraphAlignmentControl converted to
|
||||
// Robolectric JVM Compose tests (#376) — now JVM-covered.
|
||||
// DraftsScreen, OutboxScreen & ProblemReportsScreen converted to Robolectric JVM Compose tests
|
||||
@@ -326,12 +327,14 @@ val jacocoNonJvmTestableSurface = listOf(
|
||||
// Robolectric JVM Compose tests (#383) — now JVM-covered.
|
||||
// AddAnotherAccountScreen (#373) plus the onboarding welcome/license and contacts/battery steps
|
||||
// (#377) converted to Robolectric JVM Compose tests — now JVM-covered.
|
||||
"**/ReaderScreen*",
|
||||
"**/AccountSettingsScreen*",
|
||||
"**/SettingsScreen*",
|
||||
"**/SettingsComponents*",
|
||||
"**/SignatureEditScreen*",
|
||||
"**/SignaturesScreen*",
|
||||
// ReaderScreen converted to a Robolectric JVM Compose test (#381) — now JVM-covered. Its HTML body
|
||||
// renders through HtmlBody, a hardened WebView that Robolectric can only present as a non-rendering
|
||||
// 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.
|
||||
// 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,389 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.compose
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.OnBackPressedDispatcherOwner
|
||||
import androidx.activity.compose.LocalActivityResultRegistryOwner
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.activity.result.ActivityResultRegistry
|
||||
import androidx.activity.result.ActivityResultRegistryOwner
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.hasProgressBarRangeInfo
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
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.performTextInput
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
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.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
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.contacts.ContactSuggestion
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.OutgoingAttachment
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
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 port of the instrumented [ComposeScreenTest] (batch 7/9 of umbrella #373): drives
|
||||
* the email-editor [ComposeScreen] on the JVM via the v2 `createComposeRule()` — no emulator — so its
|
||||
* render + interaction code counts toward JaCoCo's JVM-testable surface (this file is dropped from
|
||||
* `jacocoNonJvmTestableSurface`). The instrumented [ComposeScreenTest] stays as the on-device E2E.
|
||||
*
|
||||
* Because [ComposeViewModel] is large (7 collaborators, several `Context`/Room-backed) and its logic
|
||||
* is exercised elsewhere, it is **mocked** here (mirroring [org.libremail.ui.accountsetup]'s
|
||||
* `AccountPickerScreenJvmTest` / `ManualSetupScreenJvmTest`): the three flows the screen reads
|
||||
* (`state`, `accounts`, `finished`) are stubbed so every render/state branch can be injected directly,
|
||||
* and interactions are asserted by verifying they forward to the view model. The embedded
|
||||
* [RichTextBodyField] (from `RichTextEditor.kt`, itself unit-tested by `RichTextEditorTest`) renders
|
||||
* live; the toolbar's accessibility labels and its body-change plumbing are covered here too.
|
||||
*
|
||||
* A RESUMED [LocalLifecycleOwner] is provided so `collectAsStateWithLifecycle` collects; the same
|
||||
* owner backs [LocalOnBackPressedDispatcherOwner] for the screen's `BackHandler`, and a no-op
|
||||
* [ActivityResultRegistryOwner] lets the attachment/inline-image `rememberLauncherForActivityResult`
|
||||
* register without surfacing a real picker. `@GraphicsMode(NATIVE)` + `@Config(sdk = [36])` match the
|
||||
* rest of the suite; the tall `qualifiers` keeps the whole scrolling form on-screen.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
// A tall display so the whole scrolling compose form — From/To/Subject/attachments/body + toolbar —
|
||||
// fits Robolectric's small default viewport, keeping controls on-screen for the assertions below.
|
||||
@Config(sdk = [36], qualifiers = "+w411dp-h2000dp")
|
||||
class ComposeScreenJvmTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private val context: Context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun string(resId: Int): String = context.getString(resId)
|
||||
|
||||
private val account = Account(
|
||||
id = "imap:me@example.com",
|
||||
email = "me@example.com",
|
||||
displayName = "Me",
|
||||
authType = AuthType.PASSWORD_IMAP,
|
||||
imap = ServerConfig("imap.example.com", 993, MailSecurity.SSL_TLS),
|
||||
smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS),
|
||||
)
|
||||
|
||||
private val otherAccount = Account(
|
||||
id = "imap:other@example.com",
|
||||
email = "other@example.com",
|
||||
displayName = "Other",
|
||||
authType = AuthType.PASSWORD_IMAP,
|
||||
imap = ServerConfig("imap.example.com", 993, MailSecurity.SSL_TLS),
|
||||
smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS),
|
||||
)
|
||||
|
||||
/** RESUMED owner (also the back-press dispatcher owner) so state collects and `BackHandler` composes. */
|
||||
private val owner = object : OnBackPressedDispatcherOwner {
|
||||
private val registry =
|
||||
LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED }
|
||||
private val dispatcher = OnBackPressedDispatcher()
|
||||
override val lifecycle: Lifecycle get() = registry
|
||||
override val onBackPressedDispatcher: OnBackPressedDispatcher get() = dispatcher
|
||||
}
|
||||
|
||||
/**
|
||||
* A no-op registry so the screen's attachment / inline-image `rememberLauncherForActivityResult`
|
||||
* can register (and, if tapped, launch) on the JVM without surfacing a real document picker.
|
||||
*/
|
||||
private val noopRegistryOwner = object : ActivityResultRegistryOwner {
|
||||
override val activityResultRegistry = object : ActivityResultRegistry() {
|
||||
override fun <I, O> onLaunch(
|
||||
requestCode: Int,
|
||||
contract: ActivityResultContract<I, O>,
|
||||
input: I,
|
||||
options: ActivityOptionsCompat?,
|
||||
) {
|
||||
// Intentionally never dispatch a result: the launch is a no-op in this JVM test.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun viewModel(
|
||||
state: ComposeUiState = ComposeUiState(),
|
||||
accounts: List<Account> = listOf(account),
|
||||
finished: Flow<Unit> = emptyFlow(),
|
||||
): ComposeViewModel {
|
||||
val vm = mockk<ComposeViewModel>(relaxed = true)
|
||||
every { vm.state } returns MutableStateFlow(state)
|
||||
every { vm.accounts } returns MutableStateFlow(accounts)
|
||||
every { vm.finished } returns finished
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun setContent(viewModel: ComposeViewModel, onBack: () -> Unit = {}) {
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(
|
||||
LocalLifecycleOwner provides owner,
|
||||
LocalOnBackPressedDispatcherOwner provides owner,
|
||||
LocalActivityResultRegistryOwner provides noopRegistryOwner,
|
||||
) {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ComposeScreen(onBack = onBack, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(TIMEOUT_MS) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
/** Matches the editable field labelled [labelRes] but not the collapsed Cc/Bcc link buttons. */
|
||||
private fun editableField(labelRes: Int) = composeTestRule.onNode(hasText(string(labelRes)) and hasSetTextAction())
|
||||
|
||||
@Test
|
||||
fun initialRender_showsRecipientSubjectBodyAttachAndSingleAccountFrom() {
|
||||
setContent(viewModel())
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_to)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_subject)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_body)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_attach)).assertIsDisplayed()
|
||||
// Exactly one account → the From row shows the address as plain text (no dropdown affordance).
|
||||
composeTestRule.onNodeWithText(account.email).assertIsDisplayed()
|
||||
// An empty recipient leaves Send disabled.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recipientEntered_enablesSend_andTapInvokesSend() {
|
||||
val vm = viewModel(ComposeUiState(to = "you@example.com"))
|
||||
setContent(vm)
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_send))
|
||||
.assertIsEnabled()
|
||||
.performClick()
|
||||
|
||||
verify { vm.send() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendingState_showsSpinner_andDisablesSend() {
|
||||
setContent(viewModel(ComposeUiState(to = "you@example.com", sending = true)))
|
||||
|
||||
// The blocking overlay spinner renders...
|
||||
composeTestRule.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed()
|
||||
// ...and Send is disabled while a send is in flight, even with a valid recipient.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingBack_invokesOnExit() {
|
||||
val vm = viewModel()
|
||||
setContent(vm)
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_back)).performClick()
|
||||
|
||||
verify { vm.onExit() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typingRecipientSubjectAndBody_forwardsToTheViewModel() {
|
||||
val vm = viewModel()
|
||||
setContent(vm)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_subject)).performTextInput("Hello")
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("Body text")
|
||||
|
||||
verify { vm.onToChange(any()) }
|
||||
verify { vm.onSubjectChange(any()) }
|
||||
verify { vm.onBodyChange(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun suggestions_render_andTappingOneForwardsToPickSuggestion() {
|
||||
val suggestion = ContactSuggestion(name = "Alice Example", email = "alice@example.org")
|
||||
val vm = viewModel(ComposeUiState(to = "al", suggestions = listOf(suggestion)))
|
||||
setContent(vm)
|
||||
|
||||
composeTestRule.onNodeWithText("Alice Example").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("alice@example.org").assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithText("Alice Example").performClick()
|
||||
|
||||
verify { vm.pickSuggestion(suggestion) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachmentPrompt_whenFlagged_showsDialog_andYesInvokesAttachInstead() {
|
||||
val vm = viewModel(ComposeUiState(to = "you@example.com", showAttachmentPrompt = true))
|
||||
setContent(vm)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.confirm_attachment_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_yes)).performClick()
|
||||
|
||||
verify { vm.attachInstead() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachmentPrompt_noInvokesSendAnyway() {
|
||||
val vm = viewModel(ComposeUiState(to = "you@example.com", showAttachmentPrompt = true))
|
||||
setContent(vm)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.action_no)).performClick()
|
||||
|
||||
verify { vm.sendAnyway() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noAccounts_showsNoAccountLabel() {
|
||||
setContent(viewModel(accounts = emptyList()))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_no_account)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleAccounts_showFromDropdown_andSelectingOneForwards() {
|
||||
val vm = viewModel(
|
||||
state = ComposeUiState(fromAccountId = account.id),
|
||||
accounts = listOf(account, otherAccount),
|
||||
)
|
||||
setContent(vm)
|
||||
|
||||
// With >1 account the From row is a dropdown anchor showing the selected address.
|
||||
composeTestRule.onNodeWithText(account.email).assertIsDisplayed().performClick()
|
||||
// The opened menu lists the other account; picking it forwards the id to the view model.
|
||||
composeTestRule.onNodeWithText(otherAccount.email).performClick()
|
||||
|
||||
verify { vm.selectFrom(otherAccount.id) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachments_normalRendersChip_inlineHidden_andTappingRemoves() {
|
||||
val normal = OutgoingAttachment(uri = "content://doc/1", name = "report.pdf")
|
||||
val inline = OutgoingAttachment(
|
||||
uri = "content://img/1",
|
||||
name = "inline-image.png",
|
||||
contentId = "cid-1",
|
||||
isInline = true,
|
||||
)
|
||||
val vm = viewModel(ComposeUiState(attachments = listOf(normal, inline)))
|
||||
setContent(vm)
|
||||
|
||||
// Regular attachments show as removable chips; inline images live in the body, not as chips.
|
||||
composeTestRule.onNodeWithText("report.pdf").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("inline-image.png").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.compose_attachment_remove)).assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithText("report.pdf").performClick()
|
||||
|
||||
verify { vm.removeAttachment("content://doc/1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ccBcc_startCollapsed_expandViaLink_andForwardEdits() {
|
||||
val vm = viewModel()
|
||||
setContent(vm)
|
||||
|
||||
// Collapsed: Cc exists only as a link, not as an editable field.
|
||||
editableField(R.string.compose_cc).assertDoesNotExist()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_cc)).performClick()
|
||||
editableField(R.string.compose_cc).performTextInput("cc@example.com")
|
||||
|
||||
verify { vm.onCcChange(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefilledCc_startsExpandedWithItsValue() {
|
||||
setContent(viewModel(ComposeUiState(cc = "cc@example.com")))
|
||||
|
||||
editableField(R.string.compose_cc).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("cc@example.com").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formattingToolbar_buttonsCarryOnClickLabelsForAccessibility() {
|
||||
setContent(viewModel())
|
||||
|
||||
// Every toolbar button (see FormattingToolbar in RichTextEditor.kt) is a plain clickable Box with
|
||||
// a bare glyph Text as its only visible content, so TalkBack relies entirely on the click action's
|
||||
// label (there is no separate contentDescription) to announce what the button does.
|
||||
val buttons = listOf(
|
||||
"B" to R.string.format_bold,
|
||||
"I" to R.string.format_italic,
|
||||
"U" to R.string.format_underline,
|
||||
"•" to R.string.format_bullet_list,
|
||||
"1." to R.string.format_numbered_list,
|
||||
"❝" to R.string.format_quote,
|
||||
"🔗" to R.string.format_link,
|
||||
)
|
||||
buttons.forEach { (glyph, descriptionRes) ->
|
||||
val config = composeTestRule.onNodeWithText(glyph).fetchSemanticsNode().config
|
||||
val clickLabel = if (config.contains(SemanticsActions.OnClick)) {
|
||||
config[SemanticsActions.OnClick].label
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val message = "toolbar button \"$glyph\" is missing its accessibility label"
|
||||
assertEquals(message, string(descriptionRes), clickLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingAToolbarButton_flowsABodyChangeToTheViewModel() {
|
||||
val vm = viewModel()
|
||||
setContent(vm)
|
||||
|
||||
// The bullet button applies a block marker to the caret's line and re-emits the body — proving a
|
||||
// toolbar tap reaches ComposeViewModel.onBodyChange (both plaintext + HTML forms).
|
||||
composeTestRule.onNodeWithText("•").performClick()
|
||||
|
||||
verify { vm.onBodyChange(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun finishedSignal_invokesOnBack() {
|
||||
var backed = false
|
||||
setContent(viewModel(finished = flowOf(Unit)), onBack = { backed = true })
|
||||
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) { backed }
|
||||
assertTrue(backed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun errorState_isSurfacedAsASnackbar() {
|
||||
setContent(viewModel(ComposeUiState(error = "Could not send")))
|
||||
|
||||
waitForText("Could not send")
|
||||
composeTestRule.onNodeWithText("Could not send").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.reader
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
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.lifecycle.SavedStateHandle
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.domain.model.Attachment
|
||||
import org.libremail.domain.model.Message
|
||||
import org.libremail.domain.model.ReplyMode
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
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 port of the reader screen's chrome (issue #381, umbrella #373): drives the real
|
||||
* [ReaderViewModel] over a mocked [MailRepository] / [SettingsRepository] on the JVM under
|
||||
* [RobolectricTestRunner] via the v2 `createComposeRule()` — no emulator — so [ReaderScreen] counts
|
||||
* toward JaCoCo's JVM-testable surface. Mirrors the instrumented `ReaderScreenTest` for the
|
||||
* interactions (attachments accordion, reply/reply-all/forward, star, delete) and additionally
|
||||
* exercises the loading / plain-text / empty / error branches and the remote-images banner.
|
||||
*
|
||||
* **WebView caveat.** The HTML body renders through [HtmlBody] — a hardened `WebView` embedded via
|
||||
* `AndroidView`. Under Robolectric the `WebView` is a non-rendering *shadow*, so these tests never
|
||||
* assert WebView-rendered HTML. The remote-images banner (which only appears for an HTML message) is
|
||||
* driven with an HTML message whose body is **blank**: that hits the `isHtml && !loadRemoteImages`
|
||||
* banner branch and the blank-body placeholder branch *without* invoking [HtmlBody] at all, so no
|
||||
* WebView shadow is instantiated. `HtmlBody.kt` therefore stays out of this batch — its logic
|
||||
* (`cidKey`/`resolveInlineImage`/`wrapHtml`/`toCssHex`) is already JVM-covered by `HtmlBodyTest` and
|
||||
* `InlineImageResolverTest`, and its `WebView` render is not meaningfully JVM-testable. The
|
||||
* instrumented `ReaderScreenTest` stays as the on-device E2E. See
|
||||
* [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern.
|
||||
*
|
||||
* `@Config(sdk = [36])` because compileSdk/targetSdk 37 (preview) has no Robolectric 4.16 sandbox;
|
||||
* `@GraphicsMode(NATIVE)` is required for Compose to render under Robolectric.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36])
|
||||
class ReaderScreenJvmTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private val context: Context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun string(resId: Int): String = context.getString(resId)
|
||||
|
||||
private fun seeMore(extraCount: Int): String =
|
||||
context.resources.getQuantityString(R.plurals.attachments_see_more, extraCount, extraCount)
|
||||
|
||||
private val messageId = "imap:a:INBOX:1"
|
||||
|
||||
private val plainMessage = Message(
|
||||
id = messageId, accountId = "imap:a", sender = "Sender", senderEmail = "s@example.org",
|
||||
subject = "Subject line", snippet = "", body = "Hello plain body", isHtml = false,
|
||||
timestampMillis = 1_000L, isRead = true, isStarred = false,
|
||||
)
|
||||
|
||||
private fun attachment(partIndex: Int, filename: String, sizeBytes: Long = 1_000L) =
|
||||
Attachment(messageId, partIndex, filename, "application/pdf", sizeBytes)
|
||||
|
||||
/** The draft id the reader last asked to open compose on (via OpenCompose), or null. */
|
||||
private var openedDraftId: String? = null
|
||||
|
||||
/** Set when the reader invokes onBack (after a delete, or its own back button). */
|
||||
private var backInvoked = false
|
||||
|
||||
/**
|
||||
* A relaxed [MailRepository] whose reads return [message] (or [openResult]) plus the given
|
||||
* [attachments] / [downloadedParts]. Stubs the value-class-returning suspend functions explicitly
|
||||
* — MockK can't synthesize a relaxed [Result] — so `buildReplyDraft` yields `draft-<id>` (matching
|
||||
* the instrumented fake) and `downloadAttachment` fails (the failure path is what's driven here;
|
||||
* the success path launches a FileProvider intent that isn't meaningful on the JVM).
|
||||
*/
|
||||
private fun mailRepo(
|
||||
openResult: Result<Message> = Result.success(plainMessage),
|
||||
attachments: List<Attachment> = emptyList(),
|
||||
downloadedParts: Set<Int> = emptySet(),
|
||||
): MailRepository {
|
||||
val repo = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { repo.openMessage(messageId) } returns openResult
|
||||
every { repo.observeAttachments(messageId) } returns flowOf(attachments)
|
||||
coEvery { repo.downloadedAttachmentParts(messageId) } returns downloadedParts
|
||||
coEvery { repo.inlineImages(messageId) } returns emptyList()
|
||||
coEvery { repo.setStarred(any(), any()) } returns Result.success(Unit)
|
||||
coEvery { repo.deleteMessage(any()) } returns Result.success(Unit)
|
||||
coEvery { repo.buildReplyDraft(eq(messageId), any()) } returns Result.success("draft-$messageId")
|
||||
coEvery { repo.downloadAttachment(any(), any()) } returns Result.failure(RuntimeException("no network"))
|
||||
return repo
|
||||
}
|
||||
|
||||
private fun settingsRepo(loadRemoteImages: Boolean = false): SettingsRepository {
|
||||
val settings = mockk<SettingsRepository>()
|
||||
every { settings.settings } returns flowOf(AppSettings(loadRemoteImages = loadRemoteImages))
|
||||
return settings
|
||||
}
|
||||
|
||||
private fun setContent(repo: MailRepository = mailRepo(), settings: SettingsRepository = settingsRepo()) {
|
||||
val viewModel = ReaderViewModel(
|
||||
SavedStateHandle(mapOf(Routes.READER_ARG_ID to messageId)),
|
||||
repo,
|
||||
settings,
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ReaderScreen(
|
||||
onBack = { backInvoked = true },
|
||||
onOpenCompose = { openedDraftId = it },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits until the message has loaded — the Reply action appears once `state.message` is non-null. */
|
||||
private fun awaitLoaded() {
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) {
|
||||
composeTestRule.onAllNodesWithText(string(R.string.reader_reply)).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loading_showsProgressIndicator_andNoActions() {
|
||||
// openMessage never returns, so the initial loading=true state stays and the spinner shows.
|
||||
val repo = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { repo.openMessage(messageId) } coAnswers { awaitCancellation() }
|
||||
every { repo.observeAttachments(messageId) } returns flowOf(emptyList())
|
||||
coEvery { repo.downloadedAttachmentParts(messageId) } returns emptySet()
|
||||
setContent(repo)
|
||||
|
||||
composeTestRule
|
||||
.onNode(SemanticsMatcher.keyIsDefined(SemanticsProperties.ProgressBarRangeInfo))
|
||||
.assertExists()
|
||||
// No message yet, so the toolbar actions aren't rendered.
|
||||
composeTestRule.onNodeWithText(string(R.string.reader_reply)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainTextMessage_rendersTitleHeaderAndBody() {
|
||||
setContent()
|
||||
awaitLoaded()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.title_reader)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_back)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Subject line").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Sender").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("s@example.org").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Hello plain body").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankBody_showsNoTextPlaceholder() {
|
||||
setContent(mailRepo(openResult = Result.success(plainMessage.copy(body = ""))))
|
||||
awaitLoaded()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.reader_empty)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadFailure_showsErrorText() {
|
||||
setContent(mailRepo(openResult = Result.failure(RuntimeException("Load failed"))))
|
||||
|
||||
// The message never loads, so awaitLoaded() would time out; wait for the error branch instead.
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) {
|
||||
composeTestRule.onAllNodesWithText("Load failed").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeTestRule.onNodeWithText("Load failed").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.reader_reply)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingStar_togglesStarredViaRepository() {
|
||||
val repo = mailRepo()
|
||||
setContent(repo)
|
||||
awaitLoaded()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.reader_star)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// The starting message is unstarred, so the tap flips it to starred.
|
||||
coVerify(exactly = 1) { repo.setStarred(messageId, true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingDelete_deletesAndInvokesOnBack() {
|
||||
val repo = mailRepo()
|
||||
setContent(repo)
|
||||
awaitLoaded()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.reader_delete)).performClick()
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) { backInvoked }
|
||||
|
||||
coVerify(exactly = 1) { repo.deleteMessage(messageId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingReply_buildsReplyDraftAndOpensCompose() {
|
||||
val repo = mailRepo()
|
||||
setContent(repo)
|
||||
awaitLoaded()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.reader_reply)).performClick()
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) { openedDraftId != null }
|
||||
|
||||
// #303: reply goes through buildReplyDraft (quoted original + signature), then opens that draft.
|
||||
assertEquals("draft-$messageId", openedDraftId)
|
||||
coVerify(exactly = 1) { repo.buildReplyDraft(messageId, ReplyMode.REPLY) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replyAll_viaOverflow_buildsReplyAllDraft() {
|
||||
val repo = mailRepo()
|
||||
setContent(repo)
|
||||
awaitLoaded()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_reply_all)).performClick()
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) { openedDraftId != null }
|
||||
|
||||
coVerify(exactly = 1) { repo.buildReplyDraft(messageId, ReplyMode.REPLY_ALL) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forward_viaOverflow_buildsForwardDraft() {
|
||||
val repo = mailRepo()
|
||||
setContent(repo)
|
||||
awaitLoaded()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_forward)).performClick()
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) { openedDraftId != null }
|
||||
|
||||
coVerify(exactly = 1) { repo.buildReplyDraft(messageId, ReplyMode.FORWARD) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleAttachment_showsRowWithoutAccordion() {
|
||||
// 2_000_000 bytes exercises formatSize's MB branch ("1.9 MB").
|
||||
setContent(mailRepo(attachments = listOf(attachment(0, "solo.pdf", sizeBytes = 2_000_000L))))
|
||||
awaitLoaded()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.attachments_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("solo.pdf").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("1.9 MB").assertIsDisplayed()
|
||||
// A lone attachment has no "See more" accordion.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.attachments_expand)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleAttachments_collapseExtrasUntilExpanded() {
|
||||
// "noext" (no extension, 0 bytes) exercises fileExtension's "FILE" fallback and formatSize's
|
||||
// empty branch; "three.pdf" at 2048 bytes exercises the KB branch once revealed.
|
||||
setContent(
|
||||
mailRepo(
|
||||
attachments = listOf(
|
||||
attachment(0, "one.pdf", sizeBytes = 1_000L),
|
||||
attachment(1, "noext", sizeBytes = 0L),
|
||||
attachment(2, "three.pdf", sizeBytes = 2_048L),
|
||||
),
|
||||
),
|
||||
)
|
||||
awaitLoaded()
|
||||
|
||||
// First row shown; the two extras hide behind the collapsed accordion.
|
||||
composeTestRule.onNodeWithText("one.pdf").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(seeMore(2)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("noext").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("three.pdf").assertDoesNotExist()
|
||||
|
||||
// Tapping the control reveals the remaining rows.
|
||||
composeTestRule.onNodeWithText(seeMore(2)).performClick()
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) {
|
||||
composeTestRule.onAllNodesWithText("three.pdf").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeTestRule.onNodeWithText("noext").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("three.pdf").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadedAttachment_showsDownloadedIndicator() {
|
||||
// 2048 bytes exercises formatSize's KB branch ("2 KB").
|
||||
setContent(
|
||||
mailRepo(
|
||||
attachments = listOf(attachment(0, "report.pdf", sizeBytes = 2_048L)),
|
||||
downloadedParts = setOf(0),
|
||||
),
|
||||
)
|
||||
awaitLoaded()
|
||||
|
||||
composeTestRule.onNodeWithText("report.pdf").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("2 KB").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.attachment_downloaded)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingAttachment_whoseDownloadFails_showsSnackbar() {
|
||||
setContent(mailRepo(attachments = listOf(attachment(0, "report.pdf"))))
|
||||
awaitLoaded()
|
||||
|
||||
composeTestRule.onNodeWithText("report.pdf").performClick()
|
||||
|
||||
val failed = string(R.string.attachment_download_failed).format("report.pdf")
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) {
|
||||
composeTestRule.onAllNodesWithText(failed).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeTestRule.onNodeWithText(failed).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun htmlMessageBlankBody_showsRemoteImagesBanner_thenHidesOnShowImages() {
|
||||
// An HTML message with a blank body hits the `isHtml && !loadRemoteImages` banner branch and the
|
||||
// blank-body placeholder — without invoking HtmlBody's (non-rendering) WebView shadow.
|
||||
setContent(mailRepo(openResult = Result.success(plainMessage.copy(isHtml = true, body = ""))))
|
||||
awaitLoaded()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.reader_images_blocked)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.reader_empty)).assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.reader_show_images)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Opting in clears the banner.
|
||||
composeTestRule.onNodeWithText(string(R.string.reader_images_blocked)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun htmlMessage_withLoadRemoteImagesPreference_showsNoBanner() {
|
||||
// The "load remote images by default" preference flips state.loadRemoteImages on init, so the
|
||||
// banner never appears.
|
||||
setContent(
|
||||
repo = mailRepo(openResult = Result.success(plainMessage.copy(isHtml = true, body = ""))),
|
||||
settings = settingsRepo(loadRemoteImages = true),
|
||||
)
|
||||
awaitLoaded()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.reader_images_blocked)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -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