diff --git a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt index f59995c..0edc039 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt @@ -76,6 +76,8 @@ class FakeMailRepository( private val attachments: List = emptyList(), private val downloadedParts: Set = emptySet(), private val unreadCounts: List = emptyList(), + drafts: List = emptyList(), + outbox: List = emptyList(), ) : MailRepository { val sentMessages = mutableListOf() @@ -87,6 +89,14 @@ class FakeMailRepository( val expungedIds = mutableListOf>() val movedToFolder = mutableListOf, String>>() val replyDrafts = mutableListOf>() + val canceledOutboxIds = mutableListOf() + var retryOutboxCount = 0 + private set + + // Backed by mutable state so a delete/cancel is reflected in the observed list, letting UI tests + // assert the row actually disappears (mirroring the real DB-backed repository's reactivity). + private val draftsFlow = MutableStateFlow(drafts) + private val outboxFlow = MutableStateFlow(outbox) override fun pagedUnifiedFolderMessages(folder: String): Flow> = flowOf(PagingData.from(messages.filter { it.folder == folder && it.inInbox })) @@ -179,9 +189,9 @@ class FakeMailRepository( return sendResult } - override fun observeDrafts(): Flow> = flowOf(emptyList()) + override fun observeDrafts(): Flow> = draftsFlow - override suspend fun getDraft(id: String): Draft? = null + override suspend fun getDraft(id: String): Draft? = draftsFlow.value.firstOrNull { it.id == id } override suspend fun saveDraft(draft: Draft) { savedDrafts += draft @@ -189,13 +199,19 @@ class FakeMailRepository( override suspend fun deleteDraft(id: String) { deletedDraftIds += id + draftsFlow.value = draftsFlow.value.filterNot { it.id == id } } - override fun observeOutbox(): Flow> = flowOf(emptyList()) + override fun observeOutbox(): Flow> = outboxFlow - override suspend fun cancelOutboxMessage(id: String) {} + override suspend fun cancelOutboxMessage(id: String) { + canceledOutboxIds += id + outboxFlow.value = outboxFlow.value.filterNot { it.id == id } + } - override suspend fun retryOutbox() {} + override suspend fun retryOutbox() { + retryOutboxCount++ + } override suspend fun searchServer(query: String, accountId: String?, folder: String) {} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenTest.kt new file mode 100644 index 0000000..c06369a --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenTest.kt @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.auth.OutlookAuthManager +import org.libremail.domain.model.MailProvider +import org.libremail.ui.FakeAccountRepository +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end UI test for the account-vendor picker used by onboarding and "Add account". Drives the + * real [AccountPickerScreen] + [AccountSetupViewModel]: every setup choice is listed, tapping an + * app-password vendor routes to [onPickProvider] with that [MailProvider], and tapping "Other" + * routes to [onManualSetup]. The Outlook row is deliberately not tapped (it launches a browser). + */ +@RunWith(AndroidJUnit4::class) +class AccountPickerScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val context: Context = + InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + private fun setContent(onPickProvider: (MailProvider) -> Unit = {}, onManualSetup: () -> Unit = {}) { + val viewModel = AccountSetupViewModel(OutlookAuthManager(context), FakeAccountRepository()) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + AccountPickerScreen( + onBack = {}, + onAccountAdded = {}, + onPickProvider = onPickProvider, + onManualSetup = onManualSetup, + viewModel = viewModel, + ) + } + } + } + + @Test + fun allSetupChoices_areListed() { + setContent() + + composeTestRule.onNodeWithText(string(R.string.account_setup_outlook)).performScrollTo().assertIsDisplayed() + MailProvider.entries.forEach { provider -> + composeTestRule.onNodeWithText(provider.displayName).performScrollTo().assertIsDisplayed() + } + composeTestRule.onNodeWithText(string(R.string.account_setup_other)).performScrollTo().assertIsDisplayed() + } + + @Test + fun tappingAppPasswordProvider_invokesOnPickProvider() { + var picked: MailProvider? = null + setContent(onPickProvider = { picked = it }) + + composeTestRule.onNodeWithText(MailProvider.GMAIL.displayName).performScrollTo().performClick() + + composeTestRule.waitUntil(5_000) { picked == MailProvider.GMAIL } + } + + @Test + fun tappingOther_invokesOnManualSetup() { + var manualRequested = false + setContent(onManualSetup = { manualRequested = true }) + + composeTestRule.onNodeWithText(string(R.string.account_setup_other)).performScrollTo().performClick() + + composeTestRule.waitUntil(5_000) { manualRequested } + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/drafts/DraftsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/drafts/DraftsScreenTest.kt new file mode 100644 index 0000000..47a08f6 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/drafts/DraftsScreenTest.kt @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.drafts + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.domain.model.Draft +import org.libremail.ui.FakeMailRepository +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end UI test for [DraftsScreen] + [DraftsViewModel] over an in-memory [FakeMailRepository]: + * the empty state, the subject/recipient/body rendering (with the blank-field fallbacks), opening a + * draft, and deleting one (which removes the row and records the deletion in the repository). + */ +@RunWith(AndroidJUnit4::class) +class DraftsScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + private fun draft(id: String, to: String, subject: String, body: String) = Draft( + id = id, + accountId = "imap:a", + to = to, + cc = "", + subject = subject, + body = body, + updatedAt = 1_000L, + ) + + private fun setContent(repo: FakeMailRepository, onOpenDraft: (String) -> Unit = {}) { + val viewModel = DraftsViewModel(repo) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + DraftsScreen(onBack = {}, onOpenDraft = onOpenDraft, viewModel = viewModel) + } + } + } + + private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + + @Test + fun emptyDrafts_showsEmptyState() { + setContent(FakeMailRepository()) + + composeTestRule.onNodeWithText(string(R.string.drafts_empty)).assertIsDisplayed() + } + + @Test + fun populatedDraft_showsSubjectRecipientAndBody() { + setContent( + FakeMailRepository( + drafts = listOf(draft("d1", "alice@example.org", "Lunch plans", "See you at noon")), + ), + ) + waitForText("Lunch plans") + + composeTestRule.onNodeWithText("Lunch plans").assertIsDisplayed() + composeTestRule.onNodeWithText("alice@example.org").assertIsDisplayed() + composeTestRule.onNodeWithText("See you at noon").assertIsDisplayed() + } + + @Test + fun blankDraft_usesNoSubjectAndNoRecipientFallbacks() { + setContent(FakeMailRepository(drafts = listOf(draft("d1", to = "", subject = "", body = "")))) + waitForText(string(R.string.draft_no_subject)) + + composeTestRule.onNodeWithText(string(R.string.draft_no_subject)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.draft_no_recipient)).assertIsDisplayed() + } + + @Test + fun tappingRow_invokesOnOpenDraft() { + var opened: String? = null + setContent( + FakeMailRepository(drafts = listOf(draft("d1", "alice@example.org", "Lunch plans", "body"))), + onOpenDraft = { opened = it }, + ) + waitForText("Lunch plans") + + composeTestRule.onNodeWithText("Lunch plans").performClick() + + composeTestRule.waitUntil(5_000) { opened == "d1" } + } + + @Test + fun tappingDelete_removesRow_andRecordsDeletion() { + val repo = FakeMailRepository( + drafts = listOf( + draft("d1", "alice@example.org", "Lunch plans", "body"), + draft("d2", "bob@example.org", "Second draft", "body"), + ), + ) + setContent(repo) + waitForText("Lunch plans") + + // Delete the first draft: the fake removes it from the observed list, so its row disappears. + composeTestRule.onAllNodesWithContentDescription(string(R.string.draft_delete))[0].performClick() + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText("Lunch plans").fetchSemanticsNodes().isEmpty() + } + assertEquals(listOf("d1"), repo.deletedDraftIds) + composeTestRule.onNodeWithText("Second draft").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/outbox/OutboxScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/outbox/OutboxScreenTest.kt new file mode 100644 index 0000000..7f1bd3b --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/outbox/OutboxScreenTest.kt @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.outbox + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.domain.model.OutboxMessage +import org.libremail.ui.FakeMailRepository +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end UI test for [OutboxScreen] + [OutboxViewModel] over an in-memory [FakeMailRepository]: + * the empty state, queued-vs-failed row rendering, and the cancel/retry actions round-tripping + * through the repository (cancel removes the row; retry calls back into the repo). + */ +@RunWith(AndroidJUnit4::class) +class OutboxScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + private fun queued(id: String, subject: String, to: String) = + OutboxMessage(id = id, to = to, subject = subject, body = "body", createdAt = 1_000L, lastError = null) + + private fun failed(id: String, subject: String, to: String) = + OutboxMessage(id = id, to = to, subject = subject, body = "body", createdAt = 2_000L, lastError = "SMTP 550") + + private fun setContent(repo: FakeMailRepository) { + val viewModel = OutboxViewModel(repo) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + OutboxScreen(onBack = {}, viewModel = viewModel) + } + } + } + + private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + + @Test + fun emptyOutbox_showsEmptyState_andNoRetryAction() { + setContent(FakeMailRepository()) + + composeTestRule.onNodeWithText(string(R.string.outbox_empty)).assertIsDisplayed() + // Retry only appears when there is something to retry. + composeTestRule.onNodeWithText(string(R.string.outbox_retry)).assertDoesNotExist() + } + + @Test + fun populatedOutbox_showsQueuedAndFailedRows_andRetryAction() { + setContent( + FakeMailRepository( + outbox = listOf( + queued("q", "Queued mail", "alice@example.org"), + failed("f", "Failed mail", "bob@example.org"), + ), + ), + ) + waitForText("Queued mail") + + composeTestRule.onNodeWithText("Queued mail").assertIsDisplayed() + composeTestRule.onNodeWithText("Failed mail").assertIsDisplayed() + // The queued row shows the "queued" status; the failed row shows the "failed" status. + composeTestRule.onNodeWithText(string(R.string.outbox_status_queued)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outbox_status_failed)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outbox_retry)).assertIsDisplayed() + } + + @Test + fun tappingRetry_callsRepositoryRetry() { + val repo = FakeMailRepository(outbox = listOf(failed("f", "Failed mail", "bob@example.org"))) + setContent(repo) + waitForText("Failed mail") + + composeTestRule.onNodeWithText(string(R.string.outbox_retry)).performClick() + + composeTestRule.waitUntil(5_000) { repo.retryOutboxCount == 1 } + } + + @Test + fun tappingCancel_removesRow_andRecordsCancellation() { + val repo = FakeMailRepository( + outbox = listOf( + queued("q", "Queued mail", "alice@example.org"), + queued("q2", "Second mail", "carol@example.org"), + ), + ) + setContent(repo) + waitForText("Queued mail") + + // Cancel the first row: the fake removes it from the observed outbox, so the row disappears. + composeTestRule.onAllNodesWithContentDescription(string(R.string.outbox_cancel))[0].performClick() + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText("Queued mail").fetchSemanticsNodes().isEmpty() + } + assertEquals(listOf("q"), repo.canceledOutboxIds) + composeTestRule.onNodeWithText("Second mail").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/reporting/ProblemReportsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/reporting/ProblemReportsScreenTest.kt new file mode 100644 index 0000000..d420600 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/reporting/ProblemReportsScreenTest.kt @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.data.settings.SettingsRepository +import org.libremail.reporting.AppVersionProvider +import org.libremail.reporting.DebugReport +import org.libremail.reporting.DiagnosticsCollector +import org.libremail.reporting.ReportKind +import org.libremail.reporting.ReportStore +import org.libremail.reporting.RingLogBuffer +import org.libremail.ui.theme.LibreMailTheme +import java.io.File + +/** + * End-to-end UI test for [ProblemReportsScreen] + [ProblemReportsViewModel] backed by a real + * file-backed [ReportStore] (temp dir) and a real [DiagnosticsCollector]: the always-present create + * button and auto-delete notice, the empty state, the crash/manual row rendering, opening a report, + * and the create-report flow which saves a manual report and immediately opens it for review. + */ +@RunWith(AndroidJUnit4::class) +class ProblemReportsScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val context: Context = + InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + private val storeDir = File(context.cacheDir, "problem-reports-test") + + @Before + fun setUp() { + storeDir.deleteRecursively() + } + + @After + fun tearDown() { + storeDir.deleteRecursively() + } + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + private fun report(id: String, kind: ReportKind) = DebugReport( + id = id, + createdAtMillis = 1_000L, + kind = kind, + appVersionName = "1.0", + appVersionCode = 1L, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Test", + deviceModel = "Model", + stackTrace = if (kind == ReportKind.CRASH) "boom" else null, + settings = emptyMap(), + logs = emptyList(), + ) + + private fun setContent(reports: List, onOpenReport: (String) -> Unit = {}) { + val store = ReportStore(storeDir) + reports.forEach(store::save) + val collector = DiagnosticsCollector( + AppVersionProvider(context), + SettingsRepository(context), + RingLogBuffer(), + ) + val viewModel = ProblemReportsViewModel(store, collector) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + ProblemReportsScreen(onBack = {}, onOpenReport = onOpenReport, viewModel = viewModel) + } + } + } + + private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + + @Test + fun noReports_showsEmptyState_withCreateButtonAndAutoDeleteNotice() { + setContent(emptyList()) + + composeTestRule.onNodeWithText(string(R.string.reports_empty)).assertIsDisplayed() + // The create control and the retention notice are always visible, even with no reports. + composeTestRule.onNodeWithText(string(R.string.reports_create)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.report_auto_delete_notice)).assertIsDisplayed() + } + + @Test + fun storedReports_showCrashAndManualKindLabels() { + setContent(listOf(report("r-crash", ReportKind.CRASH), report("r-manual", ReportKind.MANUAL))) + waitForText(string(R.string.report_kind_crash)) + + composeTestRule.onNodeWithText(string(R.string.report_kind_crash)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.report_kind_manual)).assertIsDisplayed() + } + + @Test + fun tappingReportRow_invokesOnOpenReport() { + var opened: String? = null + setContent(listOf(report("r-crash", ReportKind.CRASH)), onOpenReport = { opened = it }) + waitForText(string(R.string.report_kind_crash)) + + composeTestRule.onNodeWithText(string(R.string.report_kind_crash)).performClick() + + composeTestRule.waitUntil(5_000) { opened == "r-crash" } + } + + @Test + fun tappingCreate_savesManualReport_andOpensItForReview() { + var opened: String? = null + setContent(emptyList(), onOpenReport = { opened = it }) + + composeTestRule.onNodeWithText(string(R.string.reports_create)).performClick() + + // Creating a manual report emits its id so the screen opens it straight into review. + composeTestRule.waitUntil(5_000) { opened != null } + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/SignaturesScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/SignaturesScreenTest.kt new file mode 100644 index 0000000..0a28ae2 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/SignaturesScreenTest.kt @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import android.content.Context +import androidx.activity.ComponentActivity +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.isSelected +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.SavedStateHandle +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.data.local.AccountDatabase +import org.libremail.data.local.entity.AccountEntity +import org.libremail.data.local.entity.ServerConfigEmbedded +import org.libremail.data.settings.SignatureRepository +import org.libremail.ui.navigation.Routes +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end UI test for [SignaturesScreen] + [SignaturesViewModel] over a real in-memory Room DB + * and [SignatureRepository]: the empty state, name + default-badge rendering, making another + * signature the default (round-tripping through the DB via the radio), and deleting a signature + * (its row disappears as the repository re-emits the account's list). + */ +@RunWith(AndroidJUnit4::class) +class SignaturesScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val context: Context = + InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + private val accountId = "imap:a" + + private lateinit var db: AccountDatabase + private lateinit var repository: SignatureRepository + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build() + repository = SignatureRepository(db.signatureDao()) + // Signatures foreign-key to an account row, so the parent account must exist first. + runBlocking { + db.accountDao().upsert( + AccountEntity( + id = accountId, + email = "a@example.org", + displayName = "A", + authType = "PASSWORD_IMAP", + imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"), + smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"), + ), + ) + } + } + + @After + fun tearDown() = db.close() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + /** Seeds signatures (the first becomes the account's default) then renders the screen. */ + private fun setContent(vararg names: String) { + runBlocking { names.forEach { repository.create(accountId, it, "

$it body

") } } + val viewModel = SignaturesViewModel( + SavedStateHandle(mapOf(Routes.SIGNATURES_ARG_ACCOUNT to accountId)), + repository, + ) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + SignaturesScreen(onBack = {}, onEdit = {}, onAdd = {}, viewModel = viewModel) + } + } + } + + private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + + @Test + fun noSignatures_showsEmptyState() { + setContent() + + composeTestRule.onNodeWithText(string(R.string.signatures_empty)).assertIsDisplayed() + } + + @Test + fun signatures_renderNames_withExactlyOneDefaultBadge() { + setContent("Work", "Personal") + waitForText("Work") + + composeTestRule.onNodeWithText("Work").assertIsDisplayed() + composeTestRule.onNodeWithText("Personal").assertIsDisplayed() + // The first signature created is the account's sole default, so exactly one badge shows. + composeTestRule.onAllNodesWithText(string(R.string.signature_default_badge)).assertCountEquals(1) + } + + @Test + fun tappingRadioOnNonDefault_makesItTheDefault() { + setContent("Work", "Personal") + waitForText("Work") + + // "Work" (created first) is the default, so its radio is selected and "Personal"'s is not. + composeTestRule.onAllNodes(isSelectable() and isSelected()).assertCountEquals(1) + composeTestRule.onNode(isSelectable() and isNotSelected()).performClick() + + // The tap round-trips through the repository/DB: "Personal" is now the account's default. + composeTestRule.waitUntil(5_000) { + runBlocking { repository.getDefault(accountId)?.name } == "Personal" + } + } + + @Test + fun tappingDelete_removesTheRow() { + setContent("Work", "Personal") + waitForText("Work") + + // Delete the first (default) signature; the repository re-emits, dropping its row. + composeTestRule.onAllNodesWithContentDescription(string(R.string.signature_delete))[0].performClick() + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText("Work").fetchSemanticsNodes().isEmpty() + } + composeTestRule.onNodeWithText("Personal").assertIsDisplayed() + } +}