diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7ced059..54b003b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/test/kotlin/org/libremail/ui/compose/ComposeScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/ComposeScreenJvmTest.kt new file mode 100644 index 0000000..62a831d --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/compose/ComposeScreenJvmTest.kt @@ -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 onLaunch( + requestCode: Int, + contract: ActivityResultContract, + 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 = listOf(account), + finished: Flow = emptyFlow(), + ): ComposeViewModel { + val vm = mockk(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 + } +}