Merge main into build-290-jacoco-scope
This commit is contained in:
@@ -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("�", HtmlToText.convert("�"))
|
||||
assertEquals("�", HtmlToText.convert("�")) // 0xD800, a lone high surrogate
|
||||
assertEquals("�", HtmlToText.convert("�"))
|
||||
assertEquals("�", HtmlToText.convert("�")) // > U+10FFFF (decimal)
|
||||
assertEquals("�", HtmlToText.convert("�")) // > 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("😀"))
|
||||
assertEquals("😀", HtmlToText.convert("😀"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `named dash entities are decoded`() {
|
||||
assertEquals("a—b–c", HtmlToText.convert("a—b–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 unambiguous.
|
||||
assertEquals("<|>|\"|'|'| |&", unescape("<|>|"|'|'| |&"))
|
||||
}
|
||||
|
||||
// --- 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")
|
||||
|
||||
Reference in New Issue
Block a user