Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt
This commit is contained in:
2026-07-03 21:18:02 -05:00
6 changed files with 617 additions and 5 deletions
@@ -81,6 +81,8 @@ class FakeMailRepository(
private val attachments: List<Attachment> = emptyList(),
private val downloadedParts: Set<Int> = emptySet(),
private val unreadCounts: List<UnreadCount> = emptyList(),
drafts: List<Draft> = emptyList(),
outbox: List<OutboxMessage> = emptyList(),
// When set, every paged query returns this instead of a static page over [messages]. Lets a UI test
// drive an explicit LoadState (e.g. refresh = Loading) through collectAsLazyPagingItems to exercise
// the empty-state gate (issue #219).
@@ -96,6 +98,14 @@ class FakeMailRepository(
val expungedIds = mutableListOf<List<String>>()
val movedToFolder = mutableListOf<Pair<List<String>, String>>()
val replyDrafts = mutableListOf<Pair<String, ReplyMode>>()
val canceledOutboxIds = mutableListOf<String>()
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<PagingData<Message>> =
pagedOverride ?: flowOf(PagingData.from(messages.filter { it.folder == folder && it.inInbox }))
@@ -190,9 +200,9 @@ class FakeMailRepository(
return sendResult
}
override fun observeDrafts(): Flow<List<Draft>> = flowOf(emptyList())
override fun observeDrafts(): Flow<List<Draft>> = 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
@@ -200,13 +210,19 @@ class FakeMailRepository(
override suspend fun deleteDraft(id: String) {
deletedDraftIds += id
draftsFlow.value = draftsFlow.value.filterNot { it.id == id }
}
override fun observeOutbox(): Flow<List<OutboxMessage>> = flowOf(emptyList())
override fun observeOutbox(): Flow<List<OutboxMessage>> = 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) {}
@@ -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<ComponentActivity>()
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 }
}
}
@@ -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<ComponentActivity>()
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()
}
}
@@ -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<ComponentActivity>()
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()
}
}
@@ -0,0 +1,137 @@
// 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 io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
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.domain.repository.AccountRepository
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<ComponentActivity>()
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<DebugReport>, onOpenReport: (String) -> Unit = {}) {
val store = ReportStore(storeDir)
reports.forEach(store::save)
val collector = DiagnosticsCollector(
AppVersionProvider(context),
SettingsRepository(context),
mockk<AccountRepository> { every { observeAccounts() } returns flowOf(emptyList()) },
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 }
}
}
@@ -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<ComponentActivity>()
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, "<p>$it body</p>") } }
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()
}
}