Merge main into build-290-jacoco-scope

This commit is contained in:
Jason Ross
2026-07-04 01:56:45 -05:00
committed by GitHub
11 changed files with 853 additions and 0 deletions
@@ -5,6 +5,7 @@ import org.junit.Test
import org.libremail.domain.model.Signature
import org.libremail.richtext.RichTextHtml
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SignatureBlockTest {
@@ -36,4 +37,22 @@ class SignatureBlockTest {
assertTrue(SignatureBlock.of(null).isEmpty)
assertTrue(SignatureBlock.of(signature("")).isEmpty)
}
@Test
fun `isEmpty reflects each representation independently`() {
assertTrue(SignatureBlock("", "").isEmpty)
assertFalse(SignatureBlock("x", "").isEmpty) // non-empty plaintext
assertFalse(SignatureBlock("", "<p>x</p>").isEmpty) // blank plaintext but non-empty html
assertFalse(SignatureBlock("x", "<p>x</p>").isEmpty)
}
@Test
fun `a signature whose text renders blank but whose html is not is still kept`() {
// "<br>" renders to blank plaintext, but the html itself isn't blank, so the block is NOT
// collapsed to EMPTY — it keeps the delimiter and the original html.
val block = SignatureBlock.of(signature("<br>"))
assertFalse(block.isEmpty)
assertTrue(block.plain.startsWith("\n\n-- \n"))
assertTrue(block.html.endsWith("<br>"), block.html)
}
}
@@ -182,6 +182,15 @@ class AccountRepositoryImplTest {
coVerify { backfillProgressDao.deleteForAccount("acct") }
}
@Test
fun `reorderAccounts forwards the new order to the dao (issue 240)`() = runTest {
coEvery { accountDao.reorder(any()) } just Runs
repository.reorderAccounts(listOf("c", "a", "b"))
coVerify { accountDao.reorder(listOf("c", "a", "b")) }
}
@Test
fun `resetBackfillProgress for one account clears only that account and re-kicks backfill`() = runTest {
repository.resetBackfillProgress("acct")
@@ -30,6 +30,7 @@ import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.FolderUnreadCount
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.MessageRouting
@@ -578,6 +579,59 @@ class MailRepositoryImplCoverageTest {
coVerify(exactly = 0) { imapClient.deleteMessage(any(), any(), any()) } // nothing pushed server-side
}
// --- role-folder resolution: a same-role folder that is not selectable ----------------------
@Test
fun `archive ignores a same-role folder that is not selectable and fails with no target`() = runTest {
val id = "acct:INBOX:30"
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
// The only ARCHIVE-role folder is a non-selectable container (e.g. Gmail's "[Gmail]" parent),
// so it can't be a move target; with no selectable archive folder, the archive fails.
coEvery { folderDao.getForAccountOnce("acct") } returns listOf(
FolderEntity(
accountId = "acct",
fullName = "[Gmail]",
displayName = "[Gmail]",
role = "ARCHIVE",
selectable = false,
sortOrder = 0,
),
)
coEvery { imapClient.listFolders(any()) } returns emptyList()
assertTrue(repository.archive(listOf(id)).isFailure)
coVerify(exactly = 0) { imapClient.moveMessages(any(), any(), any(), any()) }
}
// --- cancelOutboxMessage ---------------------------------------------------------------------
@Test
fun `cancelOutboxMessage deletes the row and its staging dir, releasing attachment grants`() = runTest {
val cache = Files.createTempDirectory("outbox").toFile()
every { context.cacheDir } returns cache
File(cache, "outbox/o1").mkdirs()
coEvery { outboxDao.getById("o1") } returns outboxEntity("o1").copy(
attachments = """[{"uri":"content://pick/a","name":"a.txt"}]""",
)
repository.cancelOutboxMessage("o1")
coVerify { outboxDao.delete("o1") }
assertFalse(File(cache, "outbox/o1").exists())
}
@Test
fun `cancelOutboxMessage tolerates an already-removed row`() = runTest {
every { context.cacheDir } returns Files.createTempDirectory("outbox").toFile()
coEvery { outboxDao.getById("gone") } returns null
repository.cancelOutboxMessage("gone")
coVerify { outboxDao.delete("gone") }
}
// --- fixtures -------------------------------------------------------------------------------
private fun messageEntity(
@@ -54,4 +54,27 @@ class HtmlToTextTest {
assertTrue(converted.contains("Hello there"), converted)
assertFalse(converted.contains("Hello there"), converted)
}
@Test
fun `out-of-range and surrogate numeric references are left verbatim`() {
// 0, a lone surrogate, and code points above U+10FFFF are not valid scalar values, so both
// the decimal and hex forms are kept as-is (exercises every branch of the range/surrogate guard).
assertEquals("&#0;", HtmlToText.convert("&#0;"))
assertEquals("&#55296;", HtmlToText.convert("&#55296;")) // 0xD800, a lone high surrogate
assertEquals("&#xD800;", HtmlToText.convert("&#xD800;"))
assertEquals("&#9999999;", HtmlToText.convert("&#9999999;")) // > U+10FFFF (decimal)
assertEquals("&#xffffff;", HtmlToText.convert("&#xffffff;")) // > U+10FFFF (hex)
}
@Test
fun `supplementary-plane numeric references decode to their character`() {
// U+1F600 GRINNING FACE — a valid supplementary-plane scalar value in decimal and hex.
assertEquals("😀", HtmlToText.convert("&#128512;"))
assertEquals("😀", HtmlToText.convert("&#x1F600;"))
}
@Test
fun `named dash entities are decoded`() {
assertEquals("a—b–c", HtmlToText.convert("a&mdash;b&ndash;c"))
}
}
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import android.util.Log
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import org.junit.After
import org.junit.Before
import org.junit.Test
/**
* Complements [AppLogTest] with the "no buffer installed yet" branch (issue #289): before [AppLog]
* is wired to a [RingLogBuffer] — e.g. very early startup — every level must still forward to Logcat
* without touching the (absent) buffer. `android.util.Log` is a no-op stub in JVM tests, so it is
* statically mocked; the singleton's buffer is reset to null via reflection so this exercises the
* null-buffer path rather than whatever a prior test installed.
*/
class AppLogUninstalledTest {
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
AppLog::class.java.getDeclaredField("buffer").apply { isAccessible = true }.set(AppLog, null)
}
@After
fun tearDown() = unmockkAll()
@Test
fun `logging before a buffer is installed only forwards to Logcat`() {
AppLog.d("Tag", "debug")
AppLog.i("Tag", "info")
AppLog.w("Tag", "warn")
AppLog.e("Tag", "error")
AppLog.e("Tag", "error with cause", IllegalStateException("boom"))
verify { Log.d("Tag", "debug") }
verify { Log.i("Tag", "info") }
verify { Log.w("Tag", "warn") }
verify { Log.e("Tag", "error", null) }
verify { Log.e("Tag", "error with cause", any<IllegalStateException>()) }
}
}
@@ -108,6 +108,47 @@ class DiagnosticsCollectorTest {
assertTrue(report.accounts.none { it.contains("@") || it.contains("example") || it.contains(".com") })
}
@Test
fun `provider labels bucket every known imap host and default to Other`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings())
every { accountRepository.observeAccounts() } returns flowOf(
listOf(
account("1@x", AuthType.PASSWORD_IMAP, "imap.gmail.com"),
account("2@x", AuthType.PASSWORD_IMAP, "imap.googlemail.com"),
account("3@x", AuthType.PASSWORD_IMAP, "imap.mail.yahoo.com"),
account("4@x", AuthType.PASSWORD_IMAP, "p01-imap.mail.icloud.com"),
account("5@x", AuthType.PASSWORD_IMAP, "imap.mail.me.com"),
account("6@x", AuthType.PASSWORD_IMAP, "imap.mac.com"),
account("7@x", AuthType.PASSWORD_IMAP, "imap-mail.outlook.com"),
account("8@x", AuthType.PASSWORD_IMAP, "smtp.office365.com"),
account("9@x", AuthType.PASSWORD_IMAP, "imap.mail.hotmail.com"),
account("10@x", AuthType.PASSWORD_IMAP, "imap.live.com"),
account("11@x", AuthType.PASSWORD_IMAP, "imap.aol.com"),
account("12@x", AuthType.PASSWORD_IMAP, "mail.custom.example"),
),
)
val report = collector.collectManual()
assertEquals(
listOf(
"Gmail (PASSWORD_IMAP)",
"Gmail (PASSWORD_IMAP)",
"Yahoo (PASSWORD_IMAP)",
"iCloud (PASSWORD_IMAP)",
"iCloud (PASSWORD_IMAP)",
"iCloud (PASSWORD_IMAP)",
"Outlook (PASSWORD_IMAP)",
"Outlook (PASSWORD_IMAP)",
"Outlook (PASSWORD_IMAP)",
"Outlook (PASSWORD_IMAP)",
"AOL (PASSWORD_IMAP)",
"Other (PASSWORD_IMAP)",
),
report.accounts,
)
}
private fun account(email: String, authType: AuthType, imapHost: String) = Account(
id = "id:$email",
email = email,
@@ -255,4 +255,150 @@ class RichTextEditingTest {
assertEquals(result.content.text, restored.text)
assertEquals(result.content.images, restored.images)
}
@Test
fun `insertImage leaves runs entirely before the insertion point unshifted`() {
val base = RichTextContent(
"abc",
spans = listOf(RichSpan(0, 1, RichStyle.Bold)),
links = listOf(RichLink(0, 1, "http://x")),
alignments = listOf(RichAlignment(0, 1, RichAlign.CENTER)),
)
val result = RichTextEditing.insertImage(base, 2, "cid", "n")
// pos = 2: a span/link/alignment ending at or before it keeps its original offsets.
assertEquals(RichSpan(0, 1, RichStyle.Bold), result.content.spans.single())
assertEquals(RichLink(0, 1, "http://x"), result.content.links.single())
assertEquals(RichAlignment(0, 1, RichAlign.CENTER), result.content.alignments.single())
}
// --- removeLink ---
@Test
fun `removeLink is a no-op for an empty or inverted range`() {
val linked = RichTextContent("hello", links = listOf(RichLink(0, 5, "http://x")))
assertEquals(linked.links, RichTextEditing.removeLink(linked, 3, 3).links)
assertEquals(linked.links, RichTextEditing.removeLink(linked, 4, 2).links)
}
@Test
fun `removeLink drops only the links overlapping the range`() {
val content = RichTextContent(
"0123456789",
links = listOf(RichLink(0, 2, "a"), RichLink(3, 6, "b"), RichLink(7, 9, "c")),
)
// Over [4,7): "a" ends before it, "c" starts at/after its end — both kept; "b" overlaps and goes.
val result = RichTextEditing.removeLink(content, 4, 7)
assertEquals(listOf(RichLink(0, 2, "a"), RichLink(7, 9, "c")), result.links)
}
// --- applyLink edge cases ---
@Test
fun `applyLink ignores a blank url or an empty range`() {
val base = RichTextContent("hello", links = listOf(RichLink(0, 5, "http://x")))
assertEquals(base, RichTextEditing.applyLink(base, 1, 3, " "))
assertEquals(base, RichTextEditing.applyLink(base, 2, 2, "http://y"))
}
@Test
fun `applyLink keeps links wholly outside the range and replaces overlapping ones`() {
val content = RichTextContent(
"0123456789",
links = listOf(RichLink(0, 2, "a"), RichLink(3, 6, "b"), RichLink(7, 9, "c")),
)
val result = RichTextEditing.applyLink(content, 4, 7, "http://new")
assertEquals(
listOf(RichLink(0, 2, "a"), RichLink(4, 7, "http://new"), RichLink(7, 9, "c")),
result.links.sortedBy { it.start },
)
}
// --- styleAt / isStyled caret edges ---
@Test
fun `styleAt at a caret finds the run it sits in, and null outside every run`() {
val content = RichTextContent("abcd", spans = listOf(RichSpan(1, 3, RichStyle.FontSize(12))))
assertEquals(RichStyle.FontSize(12), RichTextEditing.styleAt<RichStyle.FontSize>(content, 2, 2))
// Caret before every run: no candidate starts at/before it.
assertNull(RichTextEditing.styleAt<RichStyle.FontSize>(content, 0, 0))
// Caret past every run: the candidate starts before it but ends before it too.
assertNull(RichTextEditing.styleAt<RichStyle.FontSize>(content, 4, 4))
}
@Test
fun `isStyled is false for a caret (empty range)`() {
val content = RichTextContent("ab", spans = listOf(RichSpan(0, 2, RichStyle.Bold)))
assertFalse(RichTextEditing.isStyled(content, 1, 1, RichStyle.Bold))
}
@Test
fun `isStyled is false when the style covers only part of the range`() {
// The span runs out before the end of the query, so the fully-styled check falls through false.
val content = RichTextContent("abcd", spans = listOf(RichSpan(0, 2, RichStyle.Bold)))
assertFalse(RichTextEditing.isStyled(content, 0, 4, RichStyle.Bold))
}
// --- toggleStyle: same-kind runs outside the selection ---
@Test
fun `toggleStyle leaves same-kind runs entirely outside the selection untouched`() {
val base = RichTextContent(
"abcdef",
spans = listOf(RichSpan(0, 1, RichStyle.FontColor(1)), RichSpan(5, 6, RichStyle.FontColor(1))),
)
val result = RichTextEditing.toggleStyle(base, 2, 4, RichStyle.FontColor(2))
assertEquals(
setOf(
RichSpan(0, 1, RichStyle.FontColor(1)),
RichSpan(2, 4, RichStyle.FontColor(2)),
RichSpan(5, 6, RichStyle.FontColor(1)),
),
result.spans.toSet(),
)
}
// --- block markers: quote / ordered detection and removal ---
@Test
fun `hasBlock detects quote and ordered markers and rejects a mixed selection`() {
assertTrue(RichTextEditing.hasBlock(RichTextContent("> a\n> b"), 0, 7, BlockMarker.QUOTE))
assertTrue(RichTextEditing.hasBlock(RichTextContent("1. a\n2. b"), 0, 9, BlockMarker.ORDERED))
// One line lacks the marker, so `all { … }` short-circuits to false.
assertFalse(RichTextEditing.hasBlock(RichTextContent("• a\nplain"), 0, 9, BlockMarker.BULLET))
}
@Test
fun `toggleBlock removes quote and ordered markers, measuring each prefix length`() {
val quote = RichTextEditing.toggleBlock(RichTextContent("> a\n> b"), 0, 7, BlockMarker.QUOTE)
assertEquals("a\nb", quote.content.text)
val ordered = RichTextEditing.toggleBlock(RichTextContent("1. a\n2. b"), 0, 9, BlockMarker.ORDERED)
assertEquals("a\nb", ordered.content.text)
}
@Test
fun `toggleBlock adding a marker shifts a link past the inserted prefix`() {
val base = RichTextContent("x", links = listOf(RichLink(0, 1, "http://x")))
val result = RichTextEditing.toggleBlock(base, 0, 0, BlockMarker.BULLET)
assertEquals("• x", result.content.text)
assertEquals(listOf(RichLink(2, 3, "http://x")), result.content.links)
}
@Test
fun `toggleBlock removing a marker drops channels that lived entirely in the marker prefix`() {
// Every channel covers just the "• " prefix (0,2); removing the bullet deletes it, so each
// maps to an empty range and is dropped (the remap* helpers' null branch).
val base = RichTextContent(
text = "• x",
spans = listOf(RichSpan(0, 2, RichStyle.Bold)),
links = listOf(RichLink(0, 2, "http://x")),
alignments = listOf(RichAlignment(0, 2, RichAlign.CENTER)),
images = listOf(RichImage(0, 2, "c", "n")),
)
val result = RichTextEditing.toggleBlock(base, 0, base.text.length, BlockMarker.BULLET)
assertEquals("x", result.content.text)
assertTrue(result.content.spans.isEmpty())
assertTrue(result.content.links.isEmpty())
assertTrue(result.content.alignments.isEmpty())
assertTrue(result.content.images.isEmpty())
}
}
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.richtext
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Branch-level coverage for [RichTextHtmlParser] and its CSS/attribute helpers (issue #289). The
* happy-path round-trips live in [RichTextHtmlTest]; this suite drives the parser's edge cases —
* malformed tags, stray/empty/unclosed tags, and every arm of the small CSS parsers — directly on
* the internal helpers and through [RichTextHtml.fromHtml].
*/
class RichTextHtmlParserTest {
// --- parseCssColor ---
@Test
fun `parseCssColor accepts short and long hex and rejects everything else`() {
assertEquals(0xFFFF0000.toInt(), parseCssColor("#f00"))
assertEquals(0xFFFF0000.toInt(), parseCssColor("#ff0000"))
assertEquals(0xFFAABBCC.toInt(), parseCssColor("#abc"))
// Not a hex literal at all.
assertNull(parseCssColor("rgb(1,2,3)"))
assertNull(parseCssColor("red"))
// A '#' with an unsupported digit count.
assertNull(parseCssColor("#12"))
assertNull(parseCssColor("#12345"))
// Right length, but not valid hex.
assertNull(parseCssColor("#gggggg"))
}
// --- parseFontSizePt ---
@Test
fun `parseFontSizePt reads pt directly and converts px, rejecting non-positive or malformed`() {
assertEquals(14, parseFontSizePt("14pt"))
assertEquals(12, parseFontSizePt("16px")) // 16 * 3/4
assertEquals(2, parseFontSizePt("1.5pt")) // rounds to nearest int
// A zero (or rounding-to-zero) size is dropped.
assertNull(parseFontSizePt("0px"))
assertNull(parseFontSizePt("0pt"))
// No recognizable "<number><unit>".
assertNull(parseFontSizePt("large"))
assertNull(parseFontSizePt("12")) // missing unit
assertNull(parseFontSizePt("12em")) // unsupported unit
}
// --- parseInlineStyles / styleForCss ---
@Test
fun `parseInlineStyles maps each known property and skips unknown or malformed ones`() {
assertTrue(parseInlineStyles(null).isEmpty())
assertEquals(
listOf<RichStyle>(RichStyle.FontFamily("Arial, sans-serif")),
parseInlineStyles("font-family:Arial, sans-serif"),
)
assertEquals(listOf<RichStyle>(RichStyle.FontSize(14)), parseInlineStyles("font-size:14pt"))
assertEquals(listOf<RichStyle>(RichStyle.FontColor(0xFFFF0000.toInt())), parseInlineStyles("color:#f00"))
assertEquals(
listOf<RichStyle>(RichStyle.Highlight(0xFF00FF00.toInt())),
parseInlineStyles("background-color:#0f0"),
)
// Recognized property, unparseable value -> dropped (font-size / color / background-color arms).
assertTrue(parseInlineStyles("font-size:huge").isEmpty())
assertTrue(parseInlineStyles("color:notacolor").isEmpty())
assertTrue(parseInlineStyles("background-color:zzz").isEmpty())
// Unknown property -> the `else` arm.
assertTrue(parseInlineStyles("letter-spacing:2px").isEmpty())
}
// --- parseBaseStyle ---
@Test
fun `parseBaseStyle reads font-family and font-size and ignores other declarations`() {
assertEquals(RichBaseStyle("Georgia, serif", 12), parseBaseStyle("font-family:Georgia, serif;font-size:12pt"))
// An unparseable font-size leaves the size null; an unknown prop is skipped entirely.
assertEquals(RichBaseStyle("Arial", null), parseBaseStyle("font-family:Arial;font-size:nope;color:red"))
assertEquals(RichBaseStyle(null, null), parseBaseStyle("color:red"))
}
// --- parseTextAlign / richAlignFor ---
@Test
fun `parseTextAlign resolves the alignment synonyms and rejects unknown values`() {
assertNull(parseTextAlign(null))
assertEquals(RichAlign.START, parseTextAlign("text-align:left"))
assertEquals(RichAlign.START, parseTextAlign("text-align:start"))
assertEquals(RichAlign.CENTER, parseTextAlign("text-align:center"))
assertEquals(RichAlign.END, parseTextAlign("text-align:right"))
assertEquals(RichAlign.END, parseTextAlign("text-align:end"))
// A recognized property with an unknown value, and an unrelated property, both yield null.
assertNull(parseTextAlign("text-align:justify"))
assertNull(parseTextAlign("color:red"))
}
// --- extractHref / extractStyleAttr / unescape ---
@Test
fun `extractHref reads double- and single-quoted values and defaults to empty`() {
assertEquals("http://x", extractHref("href=\"http://x\""))
assertEquals("http://y", extractHref("href='http://y'"))
assertEquals("", extractHref("name=\"anchor\""))
}
@Test
fun `extractStyleAttr returns the style value or null when absent`() {
assertEquals("color:red", extractStyleAttr("span style=\"color:red\""))
assertNull(extractStyleAttr("span class=\"x\""))
}
@Test
fun `unescape decodes every recognized entity`() {
// '|' separators keep the space-yielding &nbsp; unambiguous.
assertEquals("<|>|\"|'|'| |&", unescape("&lt;|&gt;|&quot;|&#39;|&apos;|&nbsp;|&amp;"))
}
// --- parse()-level edge cases ---
@Test
fun `an unterminated tag stops parsing at the stray angle bracket`() {
// The '<' with no closing '>' breaks the parse loop; the text seen so far is kept.
assertEquals("ab", RichTextHtml.fromHtml("ab<b").text)
}
@Test
fun `a stray closing tag with no matching open is ignored`() {
assertEquals("plain", RichTextHtml.fromHtml("</b>plain").text)
assertTrue(RichTextHtml.fromHtml("</b>plain").spans.isEmpty())
}
@Test
fun `an empty tag pair records no span`() {
val restored = RichTextHtml.fromHtml("<b></b>text")
assertEquals("text", restored.text)
assertTrue(restored.spans.isEmpty())
}
@Test
fun `a stray closing anchor with no open anchor adds no link`() {
val restored = RichTextHtml.fromHtml("</a>hi")
assertEquals("hi", restored.text)
assertTrue(restored.links.isEmpty())
}
@Test
fun `unclosed inline styles and an unclosed anchor are closed at the end of input`() {
val bold = RichTextHtml.fromHtml("<b>bold")
assertEquals("bold", bold.text)
assertEquals(listOf(RichSpan(0, 4, RichStyle.Bold)), bold.spans)
val link = RichTextHtml.fromHtml("<a href=\"http://x\">link")
assertEquals(listOf(RichLink(0, 4, "http://x")), link.links)
}
@Test
fun `an img is dropped when it has no src or a non-cid src, and a closing img tag is a no-op`() {
assertTrue(RichTextHtml.fromHtml("<img alt=\"x\">").images.isEmpty())
assertTrue(RichTextHtml.fromHtml("<img src=\"http://x/pic.png\">").images.isEmpty())
assertTrue(RichTextHtml.fromHtml("a</img>b").images.isEmpty())
}
@Test
fun `a list item outside any list still gets a bullet prefix`() {
assertEquals("• stray", RichTextHtml.fromHtml("<li>stray</li>").text)
}
@Test
fun `an unknown block tag is ignored but the text it wraps is kept`() {
assertEquals("kept", RichTextHtml.fromHtml("<section>kept</section>").text)
}
@Test
fun `a div after content acts as a block break, not the base-style wrapper`() {
val restored = RichTextHtml.fromHtml("<p>a</p><div>b</div>")
assertEquals("a\nb", restored.text)
assertNull(restored.baseStyle)
}
@Test
fun `an empty aligned paragraph anchors no alignment range`() {
val restored = RichTextHtml.fromHtml("<p style=\"text-align:center\"></p><p>body</p>")
assertEquals("body", restored.text)
assertTrue(restored.alignments.isEmpty())
}
@Test
fun `spans, links, and alignments that fall entirely in trimmed trailing newlines are dropped`() {
// The trailing <br> becomes a '\n' that finish() trims, leaving the styled/linked/aligned range
// past the end of the text, so each channel clamps to empty and is dropped.
assertTrue(RichTextHtml.fromHtml("a<b><br></b>").spans.isEmpty())
assertTrue(RichTextHtml.fromHtml("a<a href=\"http://x\"><br></a>").links.isEmpty())
}
}
@@ -307,6 +307,167 @@ class ComposeViewModelExtraTest {
coVerify { mailRepository.deleteDraft("d1") }
}
// --- nav-argument edges ----------------------------------------------------------------------
@Test
fun `a blank draft argument is treated as a brand-new composition`() = runTest(dispatcher) {
val mailRepository = mockk<MailRepository>(relaxed = true)
val vm = viewModel(
savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_DRAFT to " ")),
mailRepository = mailRepository,
)
advanceUntilIdle()
// The blank id fails the isNotBlank filter, so no draft is loaded and From defaults normally.
coVerify(exactly = 0) { mailRepository.getDraft(any()) }
assertEquals("imap:a", vm.state.value.fromAccountId)
}
@Test
fun `a blank from argument falls back to the first available account`() = runTest(dispatcher) {
val vm = viewModel(savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_FROM to " ")))
advanceUntilIdle()
assertEquals("imap:a", vm.state.value.fromAccountId)
}
@Test
fun `a resumed draft with no account keeps the existing from-account`() = runTest(dispatcher) {
val mailRepository = mockk<MailRepository>(relaxed = true)
coEvery { mailRepository.getDraft("d1") } returns org.libremail.domain.model.Draft(
id = "d1",
accountId = null, // no account stored on the draft
to = "x@example.org",
cc = "",
subject = "Hi",
body = "Body",
updatedAt = 0L,
)
val vm = viewModel(
savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_DRAFT to "d1", Routes.COMPOSE_ARG_FROM to "imap:a")),
mailRepository = mailRepository,
)
advanceUntilIdle()
// draft.accountId is null, so the from-account seeded from the nav arg is preserved.
assertEquals("imap:a", vm.state.value.fromAccountId)
}
@Test
fun `a new composition waits for the first non-empty account list`() = runTest(dispatcher) {
val accountsFlow = MutableStateFlow<List<Account>>(emptyList())
val accountRepository = mockk<AccountRepository>()
every { accountRepository.observeAccounts() } returns accountsFlow
val accountSettingsRepository = mockk<AccountSettingsRepository>()
coEvery { accountSettingsRepository.get(any()) } answers { AccountSettings(firstArg()) }
val signatureRepository = mockk<SignatureRepository>(relaxed = true)
coEvery { signatureRepository.getDefault(any()) } returns null
val settingsRepository = mockk<SettingsRepository>()
every { settingsRepository.settings } returns flowOf(AppSettings())
val vm = ComposeViewModel(
savedStateHandle = SavedStateHandle(),
mailRepository = mockk(relaxed = true),
accountRepository = accountRepository,
contactsRepository = mockk(relaxed = true),
accountSettingsRepository = accountSettingsRepository,
signatureRepository = signatureRepository,
settingsRepository = settingsRepository,
)
advanceUntilIdle()
assertNull(vm.state.value.fromAccountId) // still waiting: the first emission was empty
accountsFlow.value = listOf(alice)
advanceUntilIdle()
assertEquals("imap:a", vm.state.value.fromAccountId)
}
// --- body/attachment reconciliation & contact-search cancellation ----------------------------
@Test
fun `onBodyChange keeps a regular (non-inline) attachment`() = runTest(dispatcher) {
val vm = viewModel()
advanceUntilIdle()
vm.addAttachments(listOf(OutgoingAttachment("content://a", "a.pdf")))
vm.onBodyChange("no images at all", null)
// A non-inline attachment is never pruned by a body edit (the `!isInline` branch).
assertTrue(vm.state.value.attachments.any { it.uri == "content://a" })
}
@Test
fun `a second recipient edit cancels the previous contact search`() = runTest(dispatcher) {
val contacts = mockk<ContactsRepository>()
coEvery { contacts.search(any()) } returns listOf(ContactSuggestion("Bob", "bob@example.org"))
val vm = viewModel(contactsRepository = contacts)
advanceUntilIdle()
vm.onContactsPermission(granted = true)
vm.onToChange("bo") // starts a search job
vm.onToChange("bob") // cancels the in-flight job, starts a new one
advanceUntilIdle()
assertEquals(listOf(ContactSuggestion("Bob", "bob@example.org")), vm.state.value.suggestions)
}
// --- autosave content detection --------------------------------------------------------------
@Test
fun `autosave persists a draft that has only one non-empty field`() = runTest(dispatcher) {
val setters = listOf<(ComposeViewModel) -> Unit>(
{ it.onCcChange("c@example.org") },
{ it.onBccChange("b@example.org") },
{ it.onSubjectChange("Subject") },
{ it.onBodyChange("Body", null) },
{ it.addAttachments(listOf(OutgoingAttachment("content://a", "a.pdf"))) },
)
setters.forEach { setField ->
val mail = mockk<MailRepository>(relaxed = true)
val vm = viewModel(mailRepository = mail)
advanceUntilIdle()
setField(vm)
vm.flushDraft()
advanceUntilIdle()
coVerify(atLeast = 1) { mail.saveDraft(any()) }
}
}
@Test
fun `autosave on an empty new composition neither saves nor deletes`() = runTest(dispatcher) {
val mail = mockk<MailRepository>(relaxed = true)
val vm = viewModel(mailRepository = mail)
advanceUntilIdle()
vm.flushDraft()
advanceUntilIdle()
coVerify(exactly = 0) { mail.saveDraft(any()) }
coVerify(exactly = 0) { mail.deleteDraft(any()) }
}
// --- post-send guards ------------------------------------------------------------------------
@Test
fun `a successful send finishes the screen and a later exit is a no-op`() = runTest(dispatcher) {
val mail = mockk<MailRepository>(relaxed = true)
coEvery { mail.sendMessage(any()) } returns Result.success(Unit)
val vm = viewModel(mailRepository = mail)
advanceUntilIdle()
vm.onToChange("bob@example.org")
vm.send()
advanceUntilIdle()
assertFalse(vm.state.value.sending)
// Already navigated after the send, so onExit returns immediately without a new draft write.
vm.onExit()
advanceUntilIdle()
coVerify(exactly = 1) { mail.sendMessage(any()) }
}
@Test
fun `ComposeUiState and PendingInlineImage carry value semantics`() {
val state = ComposeUiState(
@@ -155,6 +155,31 @@ class ComposeViewModelTest {
assertEquals("\n\n-- \nBest, Bob", vm.state.value.body)
}
@Test
fun `switching accounts rebuilds the html signature when the body no longer ends with the old block`() =
runTest(testDispatcher) {
val vm = viewModel(
accounts = listOf(alice, bob),
signatures = mapOf(
"imap:a" to signature("imap:a", "Cheers, <b>Alice</b>"),
"imap:b" to signature("imap:b", "Best, Bob"),
),
)
// Alice's rich signature seeded an HTML body ending with her block.
assertTrue(vm.state.value.bodyHtml?.contains("<b>Alice</b>") == true)
// The user rewrites the body so its HTML no longer ends with Alice's block.
vm.onBodyChange("rewritten body", "<p>rewritten body</p>")
// Switching to Bob can't cleanly strip the old block, so the HTML is rebuilt from plaintext —
// which carries no formatting, so the body drops back to plaintext-only.
vm.selectFrom("imap:b")
advanceUntilIdle()
assertEquals("rewritten body\n\n-- \nBest, Bob", vm.state.value.body)
assertNull(vm.state.value.bodyHtml)
}
@Test
fun `uses the persisted default account when no explicit from-account is given`() = runTest(testDispatcher) {
val vm = viewModel(accounts = listOf(alice, bob), defaultAccountId = bob.id)
@@ -162,6 +187,21 @@ class ComposeViewModelTest {
assertEquals(bob.id, vm.state.value.fromAccountId)
}
@Test
fun `seeds the remembered font onto a new message that already has an html signature`() = runTest(testDispatcher) {
val vm = viewModel(
signatures = mapOf("imap:a" to signature("imap:a", "Cheers, <b>Alice</b>")),
lastFontCss = "Georgia, serif",
lastFontSizePt = 14,
)
// The rich signature already made an HTML body, so seeding the remembered font wraps that
// existing HTML (the bodyHtml != null branch) rather than rebuilding it from plaintext.
val html = vm.state.value.bodyHtml
assertTrue(html != null && html.contains("font-family:Georgia, serif"), "html=$html")
assertTrue(html!!.contains("<b>Alice</b>"), "html=$html")
}
@Test
fun `falls back to the first account when the persisted default no longer exists`() = runTest(testDispatcher) {
// Simulates a default that outlived its account (deletion, or a Backup restore onto a device
@@ -261,6 +261,122 @@ class MailboxViewModelExtraTest {
assertTrue(vm.hasAccounts.value)
}
@Test
fun `a blank account nav argument is ignored, opening the unified inbox`() = runTest(dispatcher) {
val f = fixture(initialAccountId = " ")
backgroundScope.launch { f.vm.accounts.collect {} }
runCurrent()
// The blank arg fails the isNotBlank filter, so no account filter is seeded.
assertNull(f.vm.selectedAccountId.value)
}
@Test
fun `folderUnreadCounts is empty when there is no drawer account`() = runTest(dispatcher) {
val f = fixture(accounts = emptyList())
backgroundScope.launch { f.vm.folderUnreadCounts.collect {} }
runCurrent()
assertTrue(f.vm.folderUnreadCounts.value.isEmpty())
}
@Test
fun `currentFolderRole is null when the selected folder is not in the drawer list`() = runTest(dispatcher) {
val f = fixture()
backgroundScope.launch { f.vm.currentFolderRole.collect {} }
f.vm.selectFolder("imap:a", "Work") // no "Work" folder is listed
advanceUntilIdle()
assertNull(f.vm.currentFolderRole.value)
}
@Test
fun `a selection action with no selection is a no-op`() = runTest(dispatcher) {
val f = fixture()
// No selection, so runOnSelection returns before launching anything.
f.vm.archiveSelected()
f.vm.moveSelected("Archive")
advanceUntilIdle()
coVerify(exactly = 0) { f.repo.archive(any()) }
coVerify(exactly = 0) { f.repo.moveToFolder(any(), any()) }
}
@Test
fun `a selection failure with no message surfaces the generic fallback text`() = runTest(dispatcher) {
val f = fixture()
coEvery { f.repo.archive(any()) } returns Result.failure(RuntimeException()) // null message
f.vm.startSelection("imap:a:INBOX:1", "imap:a")
f.vm.archiveSelected()
advanceUntilIdle()
assertEquals("Action failed", f.vm.error.value)
}
@Test
fun `onDrawerOpened does nothing when there is no drawer account`() = runTest(dispatcher) {
val f = fixture(accounts = emptyList())
backgroundScope.launch { f.vm.drawerAccount.collect {} }
runCurrent()
f.vm.onDrawerOpened()
advanceUntilIdle()
coVerify(exactly = 0) { f.repo.refreshFolders(any()) }
}
@Test
fun `requestDelete in a non-trash non-spam folder confirms a non-permanent delete`() = runTest(dispatcher) {
val f = fixture()
f.vm.startSelection("imap:a:INBOX:1", "imap:a")
// currentFolderRole stays null (INBOX default), so the delete is the reversible trash kind.
f.vm.requestDelete()
assertEquals(PendingAction.Delete(1, permanent = false), f.vm.pendingConfirm.value)
}
@Test
fun `refreshing the unified view of a non-inbox folder syncs everything`() = runTest(dispatcher) {
val f = fixture()
// Point at a concrete folder, then drop back to the unified account view without resetting it.
f.vm.selectFolder("imap:a", "Work")
f.vm.selectAccount(null)
runCurrent()
f.vm.refresh()
advanceUntilIdle()
// account == null but folder != INBOX -> the final else branch: sync every account.
coVerify { f.syncer.syncAll() }
}
@Test
fun `confirmPending with nothing pending is a no-op`() = runTest(dispatcher) {
val f = fixture()
f.vm.confirmPending() // the null `pending` arm
advanceUntilIdle()
assertNull(f.vm.pendingConfirm.value)
coVerify(exactly = 0) { f.repo.reportSpam(any()) }
coVerify(exactly = 0) { f.repo.trash(any()) }
coVerify(exactly = 0) { f.repo.expunge(any()) }
}
@Test
fun `a failed refresh with no message surfaces the generic fallback text`() = runTest(dispatcher) {
val f = fixture()
coEvery { f.syncer.syncAll() } returns Result.failure(IllegalStateException()) // null message
f.vm.refresh()
advanceUntilIdle()
assertEquals("Sync failed", f.vm.error.value)
}
@Test
fun `mailbox events and pending actions carry value semantics`() {
val open = MailboxEvent.OpenCompose("draft-1")