From b165f72e3a5e4d842defdc4a2565b5b33a3f8f8d Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 4 Jul 2026 01:36:31 -0500 Subject: [PATCH] test(coverage): high-value branch-coverage additions Add focused JVM unit tests that close real (non-coroutine) branch gaps in pure logic already >=95% line-covered (issue #289): - richtext/RichTextEditing: removeLink, applyLink/styleAt/isStyled edges, quote/ordered marker detection+removal, remap* null branches. - richtext/RichTextHtmlParser: new suite driving parseCssColor/parseFontSizePt/ parseInlineStyles/parseBaseStyle/parseTextAlign/extractHref/unescape and the parser's malformed/stray/unclosed-tag edges. - ui/compose/ComposeViewModel + ui/mailbox/MailboxViewModel: nav-arg blanks, signature-swap rebuild, autosave content detection, refresh/selection edges. - data/repository/MailRepositoryImpl: non-selectable role folder, cancelOutboxMessage. - data/repository/AccountRepositoryImpl: reorderAccounts. - mail/HtmlToText, data/SignatureBlock, reporting/AppLog, reporting/DiagnosticsCollector. Test-only; no production changes. Co-Authored-By: Claude Opus 4.8 --- .../org/libremail/data/SignatureBlockTest.kt | 19 ++ .../repository/AccountRepositoryImplTest.kt | 9 + .../MailRepositoryImplCoverageTest.kt | 54 +++++ .../org/libremail/mail/HtmlToTextTest.kt | 23 +++ .../reporting/AppLogUninstalledTest.kt | 49 +++++ .../reporting/DiagnosticsCollectorTest.kt | 41 ++++ .../libremail/richtext/RichTextEditingTest.kt | 146 +++++++++++++ .../richtext/RichTextHtmlParserTest.kt | 195 ++++++++++++++++++ .../ui/compose/ComposeViewModelExtraTest.kt | 161 +++++++++++++++ .../ui/compose/ComposeViewModelTest.kt | 40 ++++ .../ui/mailbox/MailboxViewModelExtraTest.kt | 116 +++++++++++ 11 files changed, 853 insertions(+) create mode 100644 app/src/test/kotlin/org/libremail/reporting/AppLogUninstalledTest.kt create mode 100644 app/src/test/kotlin/org/libremail/richtext/RichTextHtmlParserTest.kt diff --git a/app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt b/app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt index b3c7119..97ddbaf 100644 --- a/app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt +++ b/app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt @@ -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("", "

x

").isEmpty) // blank plaintext but non-empty html + assertFalse(SignatureBlock("x", "

x

").isEmpty) + } + + @Test + fun `a signature whose text renders blank but whose html is not is still kept`() { + // "
" 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("
")) + assertFalse(block.isEmpty) + assertTrue(block.plain.startsWith("\n\n-- \n")) + assertTrue(block.html.endsWith("
"), block.html) + } } diff --git a/app/src/test/kotlin/org/libremail/data/repository/AccountRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/AccountRepositoryImplTest.kt index cbac6e1..7af587e 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/AccountRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/AccountRepositoryImplTest.kt @@ -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") diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplCoverageTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplCoverageTest.kt index c48d796..015b542 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplCoverageTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplCoverageTest.kt @@ -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( diff --git a/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt b/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt index cecf675..9350e4c 100644 --- a/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt @@ -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")) + } } diff --git a/app/src/test/kotlin/org/libremail/reporting/AppLogUninstalledTest.kt b/app/src/test/kotlin/org/libremail/reporting/AppLogUninstalledTest.kt new file mode 100644 index 0000000..0b02f01 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/AppLogUninstalledTest.kt @@ -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(), any()) } 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()) } + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt b/app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt index 43ad6d8..2c0d478 100644 --- a/app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt +++ b/app/src/test/kotlin/org/libremail/reporting/DiagnosticsCollectorTest.kt @@ -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, diff --git a/app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt b/app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt index 8d4d238..52553e4 100644 --- a/app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt +++ b/app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt @@ -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(content, 2, 2)) + // Caret before every run: no candidate starts at/before it. + assertNull(RichTextEditing.styleAt(content, 0, 0)) + // Caret past every run: the candidate starts before it but ends before it too. + assertNull(RichTextEditing.styleAt(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()) + } } diff --git a/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlParserTest.kt b/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlParserTest.kt new file mode 100644 index 0000000..7ed9f73 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlParserTest.kt @@ -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 "". + 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.FontFamily("Arial, sans-serif")), + parseInlineStyles("font-family:Arial, sans-serif"), + ) + assertEquals(listOf(RichStyle.FontSize(14)), parseInlineStyles("font-size:14pt")) + assertEquals(listOf(RichStyle.FontColor(0xFFFF0000.toInt())), parseInlineStyles("color:#f00")) + assertEquals( + listOf(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("abplain").text) + assertTrue(RichTextHtml.fromHtml("plain").spans.isEmpty()) + } + + @Test + fun `an empty tag pair records no span`() { + val restored = RichTextHtml.fromHtml("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("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("bold") + assertEquals("bold", bold.text) + assertEquals(listOf(RichSpan(0, 4, RichStyle.Bold)), bold.spans) + + val link = RichTextHtml.fromHtml("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("\"x\"").images.isEmpty()) + assertTrue(RichTextHtml.fromHtml("").images.isEmpty()) + assertTrue(RichTextHtml.fromHtml("ab").images.isEmpty()) + } + + @Test + fun `a list item outside any list still gets a bullet prefix`() { + assertEquals("• stray", RichTextHtml.fromHtml("
  • stray
  • ").text) + } + + @Test + fun `an unknown block tag is ignored but the text it wraps is kept`() { + assertEquals("kept", RichTextHtml.fromHtml("
    kept
    ").text) + } + + @Test + fun `a div after content acts as a block break, not the base-style wrapper`() { + val restored = RichTextHtml.fromHtml("

    a

    b
    ") + assertEquals("a\nb", restored.text) + assertNull(restored.baseStyle) + } + + @Test + fun `an empty aligned paragraph anchors no alignment range`() { + val restored = RichTextHtml.fromHtml("

    body

    ") + 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
    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
    ").spans.isEmpty()) + assertTrue(RichTextHtml.fromHtml("a

    ").links.isEmpty()) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelExtraTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelExtraTest.kt index e8ecaa0..30d81ac 100644 --- a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelExtraTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelExtraTest.kt @@ -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(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(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>(emptyList()) + val accountRepository = mockk() + every { accountRepository.observeAccounts() } returns accountsFlow + val accountSettingsRepository = mockk() + coEvery { accountSettingsRepository.get(any()) } answers { AccountSettings(firstArg()) } + val signatureRepository = mockk(relaxed = true) + coEvery { signatureRepository.getDefault(any()) } returns null + val settingsRepository = mockk() + 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() + 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(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(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(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( diff --git a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt index ae3aaf0..ff042f3 100644 --- a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt @@ -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, Alice"), + "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("Alice") == true) + + // The user rewrites the body so its HTML no longer ends with Alice's block. + vm.onBodyChange("rewritten body", "

    rewritten body

    ") + + // 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, Alice")), + 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("Alice"), "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 diff --git a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelExtraTest.kt b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelExtraTest.kt index e59d22a..8b692b3 100644 --- a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelExtraTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelExtraTest.kt @@ -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") -- 2.47.3