fix(ui): reader Reply quotes original + guard one-shot actions against double-tap
#303: the reader's Reply now routes through MailRepository.buildReplyDraft (quotes the original into a <blockquote>, bakes the signature, prefixes Re:/Fwd: without double-prefixing) and opens compose on the built draft via ReaderEvent.OpenCompose — the same high-fidelity path the mailbox uses — instead of a bare compose prefill with an empty body. Adds Reply-All and Forward via an app-bar overflow menu. #304: SignatureEditViewModel.save, ReportReviewViewModel.submit, AccountSettingsViewModel.removeAccount, and ProblemReportsViewModel.createManualReport now flip a busy/saving flag synchronously before the first suspension and gate their buttons, so a rapid double-tap can't create duplicate signatures/reports, enqueue two uploads, or over-pop the back stack. Closes #303 Closes #304 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -18,6 +19,7 @@ import org.libremail.R
|
||||
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.ui.FakeMailRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
@@ -44,8 +46,17 @@ class ReaderScreenTest {
|
||||
private fun attachment(partIndex: Int, filename: String) =
|
||||
Attachment(messageId, partIndex, filename, "application/pdf", 1_000L)
|
||||
|
||||
/** Renders [ReaderScreen] for the fixed [message] with the given [attachments] and awaits load. */
|
||||
private fun renderReader(attachments: List<Attachment>, downloadedParts: Set<Int> = emptySet()) {
|
||||
/** The draft id the reader last asked to open compose on (via OpenCompose), or null. */
|
||||
private var openedDraftId: String? = null
|
||||
|
||||
/**
|
||||
* Renders [ReaderScreen] for the fixed [message] with the given [attachments], awaits load, and
|
||||
* returns the backing [FakeMailRepository] so a test can assert which reply drafts were built.
|
||||
*/
|
||||
private fun renderReader(
|
||||
attachments: List<Attachment> = emptyList(),
|
||||
downloadedParts: Set<Int> = emptySet(),
|
||||
): FakeMailRepository {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
val repo = FakeMailRepository(
|
||||
messages = listOf(message),
|
||||
@@ -59,12 +70,14 @@ class ReaderScreenTest {
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ReaderScreen(onBack = {}, onReply = { _, _, _ -> }, viewModel = viewModel)
|
||||
ReaderScreen(onBack = {}, onOpenCompose = { openedDraftId = it }, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
// The Reply action appears once the message loads, regardless of whether it has attachments.
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(attachments.first().filename).fetchSemanticsNodes().isNotEmpty()
|
||||
composeTestRule.onAllNodesWithText(string(R.string.reader_reply)).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -111,4 +124,39 @@ class ReaderScreenTest {
|
||||
composeTestRule.onNodeWithText(seeMore(1)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("b.pdf").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_reply_buildsQuotedReplyDraftAndOpensCompose() {
|
||||
val repo = renderReader()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.reader_reply)).performClick()
|
||||
composeTestRule.waitUntil(5_000) { openedDraftId != null }
|
||||
|
||||
// The reader routes through buildReplyDraft (quotes the original + bakes the signature),
|
||||
// not a bare compose prefill (#303), and opens compose on the resulting draft.
|
||||
assertEquals(listOf(messageId to ReplyMode.REPLY), repo.replyDrafts)
|
||||
assertEquals("draft-$messageId", openedDraftId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_forward_viaOverflow_buildsForwardDraft() {
|
||||
val repo = renderReader()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_forward)).performClick()
|
||||
composeTestRule.waitUntil(5_000) { openedDraftId != null }
|
||||
|
||||
assertEquals(listOf(messageId to ReplyMode.FORWARD), repo.replyDrafts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_replyAll_viaOverflow_buildsReplyAllDraft() {
|
||||
val repo = renderReader()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_reply_all)).performClick()
|
||||
composeTestRule.waitUntil(5_000) { openedDraftId != null }
|
||||
|
||||
assertEquals(listOf(messageId to ReplyMode.REPLY_ALL), repo.replyDrafts)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user