Merge branch 'main' into fix-workmanager-update-policy
This commit is contained in:
@@ -53,6 +53,34 @@ class LibreMailDatabaseTest {
|
||||
isStarred = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun observeUnreadCountsAggregatesUnreadSyncedRowsPerAccountAndFolder() = runBlocking {
|
||||
val messageDao = db.messageDao()
|
||||
messageDao.insertNew(
|
||||
listOf(
|
||||
// acct / INBOX: two unread + one read -> counts 2.
|
||||
message("acct:INBOX:1").copy(folder = "INBOX", isRead = false),
|
||||
message("acct:INBOX:2").copy(folder = "INBOX", isRead = false),
|
||||
message("acct:INBOX:3").copy(folder = "INBOX", isRead = true),
|
||||
// acct / Archive: one unread -> counts 1.
|
||||
message("acct:Archive:1").copy(folder = "Archive", isRead = false),
|
||||
// An unread server-search hit (inInbox = false) must never inflate a badge.
|
||||
message("acct:INBOX:search").copy(folder = "INBOX", isRead = false, inInbox = false),
|
||||
// A second account's unread inbox row is counted under its own accountId.
|
||||
message("acct2:INBOX:1").copy(accountId = "acct2", folder = "INBOX", isRead = false),
|
||||
),
|
||||
)
|
||||
|
||||
val counts = messageDao.observeUnreadCounts().first()
|
||||
.associate { (it.accountId to it.folder) to it.unreadCount }
|
||||
|
||||
assertEquals(2, counts[("acct" to "INBOX")])
|
||||
assertEquals(1, counts[("acct" to "Archive")])
|
||||
assertEquals(1, counts[("acct2" to "INBOX")])
|
||||
// Fully-read folders and search-only rows produce no group at all.
|
||||
assertEquals(3, counts.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingMessageCascadesToItsAttachments() = runBlocking {
|
||||
val messageDao = db.messageDao()
|
||||
|
||||
@@ -21,12 +21,12 @@ import org.libremail.data.local.entity.MessageEntity
|
||||
*
|
||||
* These queries *define* the device-only retention floor — the pruner deletes below it
|
||||
* ([MessageDao.syncedIdsBeyondCountInFolder] / [MessageDao.syncedIdsOlderThan]) and the backfiller
|
||||
* stops above it ([MessageDao.lowestSyncedUid] / [MessageDao.countSynced] /
|
||||
* [MessageDao.oldestSyncedTimestamp]) — so the whole "backfill and prune never fight over the same
|
||||
* rows" guarantee rests on their SQL. The [org.libremail.data.sync.MailPruner] /
|
||||
* [org.libremail.data.sync.MailBackfiller] unit tests mock the DAO, so the `ORDER BY … DESC LIMIT`
|
||||
* newest-N selection, the strict age cutoff, and the windowed reconcile that spares backfilled history
|
||||
* are exercised here against a real database instead.
|
||||
* pages and stops around it ([MessageDao.lowestSyncedUid] / [MessageDao.countSynced]) — so the whole
|
||||
* "backfill and prune never fight over the same rows" guarantee rests on their SQL. The
|
||||
* [org.libremail.data.sync.MailPruner] / [org.libremail.data.sync.MailBackfiller] unit tests mock
|
||||
* the DAO, so the `ORDER BY … DESC LIMIT` newest-N selection, the strict age cutoff, and the
|
||||
* windowed reconcile that spares backfilled history are exercised here against a real database
|
||||
* instead.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageDaoRetentionTest {
|
||||
@@ -153,6 +153,9 @@ class MessageDaoRetentionTest {
|
||||
/**
|
||||
* The backfiller's floor probes reflect only an account's synced rows in the given folder, and are
|
||||
* null/zero for a folder with nothing cached (so the backfiller then starts from `Long.MAX_VALUE`).
|
||||
* The paging boundary additionally skips `uid <= 0` placeholder rows (#95): a row migrated before
|
||||
* the `uid` column existed (backfilled to 0) or one whose UID the server failed to resolve (-1)
|
||||
* must not collapse MIN(uid) to a bound the backfiller treats as "folder fully paged".
|
||||
*/
|
||||
@Test
|
||||
fun floorProbesReflectOnlySyncedRowsInTheFolder() = runBlocking {
|
||||
@@ -160,18 +163,22 @@ class MessageDaoRetentionTest {
|
||||
listOf(
|
||||
message("a", uid = 30, timestampMillis = 300),
|
||||
message("d", uid = 10, timestampMillis = 100),
|
||||
message("legacy", uid = 0, timestampMillis = 40), // pre-uid-column migration row (#95)
|
||||
message("unresolved", uid = -1, timestampMillis = 30), // UIDFolder.getUID failure (#95)
|
||||
message("search", uid = 1, timestampMillis = 1, inInbox = false), // excluded
|
||||
message("archive", uid = 5, timestampMillis = 50, folder = "Archive"), // different folder
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(10L, dao.lowestSyncedUid("acct", "INBOX"))
|
||||
assertEquals(2, dao.countSynced("acct", "INBOX"))
|
||||
assertEquals(100L, dao.oldestSyncedTimestamp("acct", "INBOX"))
|
||||
assertEquals(4, dao.countSynced("acct", "INBOX"))
|
||||
|
||||
assertNull(dao.lowestSyncedUid("acct", "Nonexistent"))
|
||||
assertEquals(0, dao.countSynced("acct", "Nonexistent"))
|
||||
assertNull(dao.oldestSyncedTimestamp("acct", "Nonexistent"))
|
||||
|
||||
// A folder holding ONLY placeholder rows has no usable boundary: null (start from the top).
|
||||
dao.insertNew(listOf(message("only-legacy", uid = 0, folder = "Imported")))
|
||||
assertNull(dao.lowestSyncedUid("acct", "Imported"))
|
||||
}
|
||||
|
||||
/** Backfill / prune enumerate their targets via [syncedFolders]: distinct synced folders, per account. */
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.libremail.domain.model.Message
|
||||
import org.libremail.domain.model.OutboxMessage
|
||||
import org.libremail.domain.model.OutgoingMessage
|
||||
import org.libremail.domain.model.ReplyMode
|
||||
import org.libremail.domain.model.UnreadCount
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import java.io.File
|
||||
@@ -72,6 +73,7 @@ class FakeMailRepository(
|
||||
private val folders: List<Folder> = emptyList(),
|
||||
private val attachments: List<Attachment> = emptyList(),
|
||||
private val downloadedParts: Set<Int> = emptySet(),
|
||||
private val unreadCounts: List<UnreadCount> = emptyList(),
|
||||
) : MailRepository {
|
||||
|
||||
val sentMessages = mutableListOf<OutgoingMessage>()
|
||||
@@ -93,6 +95,8 @@ class FakeMailRepository(
|
||||
},
|
||||
)
|
||||
|
||||
override fun observeUnreadCounts(): Flow<List<UnreadCount>> = flowOf(unreadCounts)
|
||||
|
||||
override suspend fun refreshFolders(accountId: String): Result<Unit> = Result.success(Unit)
|
||||
|
||||
override suspend fun getMessage(id: String): Message? = messages.firstOrNull { it.id == id }
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -42,16 +43,20 @@ class FolderDrawerTest {
|
||||
folders = listOf(
|
||||
folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX),
|
||||
folder("imap:a", "[Gmail]/Sent Mail", "Sent Mail", FolderRole.SENT),
|
||||
folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE),
|
||||
// Give ARCHIVE a server name that differs from its friendly label so the assertion
|
||||
// below actually discriminates a role-to-label regression (displayName != friendly).
|
||||
folder("imap:a", "[Gmail]/All Mail", "All Mail", FolderRole.ARCHIVE),
|
||||
folder("imap:a", "Receipts", "Receipts", FolderRole.NORMAL),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.folder_inbox)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.folder_archive)).assertIsDisplayed()
|
||||
// Standard folders use the friendly role name ("Sent"), not the raw server name ("Sent Mail").
|
||||
// Standard folders use the friendly role name, not the raw server name — verified for both
|
||||
// Sent ("Sent Mail" -> "Sent") and Archive ("All Mail" -> "Archive").
|
||||
composeTestRule.onNodeWithText(string(R.string.folder_sent)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Sent Mail").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(string(R.string.folder_archive)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("All Mail").assertDoesNotExist()
|
||||
// Normal folders keep their server name.
|
||||
composeTestRule.onNodeWithText("Receipts").assertIsDisplayed()
|
||||
// A single account shows no account switcher / "All Inboxes" entry.
|
||||
@@ -141,10 +146,34 @@ class FolderDrawerTest {
|
||||
assertTrue(unifiedTapped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun folderWithUnreadMail_showsCountBadge_andReadFolderShowsNone() {
|
||||
setContent(
|
||||
accounts = listOf(alice),
|
||||
drawerAccount = alice,
|
||||
folders = listOf(
|
||||
folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX),
|
||||
folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE),
|
||||
),
|
||||
folderUnreadCounts = mapOf("INBOX" to 3),
|
||||
)
|
||||
|
||||
// The inbox badge announces its exact count for screen readers.
|
||||
val threeUnread = composeTestRule.activity.resources
|
||||
.getQuantityString(R.plurals.folder_unread_count_description, 3, 3)
|
||||
composeTestRule.onNodeWithContentDescription(threeUnread).assertIsDisplayed()
|
||||
// Archive has no unread mail, so no badge is rendered for it.
|
||||
val oneUnread = composeTestRule.activity.resources
|
||||
.getQuantityString(R.plurals.folder_unread_count_description, 1, 1)
|
||||
composeTestRule.onNodeWithContentDescription(oneUnread).assertDoesNotExist()
|
||||
}
|
||||
|
||||
private fun setContent(
|
||||
accounts: List<Account>,
|
||||
drawerAccount: Account?,
|
||||
folders: List<Folder>,
|
||||
folderUnreadCounts: Map<String, Int> = emptyMap(),
|
||||
accountsWithUnread: Set<String> = emptySet(),
|
||||
selectedAccountId: String? = null,
|
||||
selectedFolder: String = "INBOX",
|
||||
onSelectUnifiedInbox: () -> Unit = {},
|
||||
@@ -158,6 +187,8 @@ class FolderDrawerTest {
|
||||
accounts = accounts,
|
||||
drawerAccount = drawerAccount,
|
||||
folders = folders,
|
||||
folderUnreadCounts = folderUnreadCounts,
|
||||
accountsWithUnread = accountsWithUnread,
|
||||
selectedAccountId = selectedAccountId,
|
||||
selectedFolder = selectedFolder,
|
||||
onSelectUnifiedInbox = onSelectUnifiedInbox,
|
||||
|
||||
Reference in New Issue
Block a user