From 9bbfa2108af14c303d8db41f4d42f3cdb584d35c Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 19:49:59 -0500 Subject: [PATCH] test(compose): Robolectric JVM tests for rich-text format controls (#376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the instrumented ColorSwatchRow / FontPicker / FontSizePicker / ParagraphAlignmentControl tests to Robolectric JVM Compose tests (v2 createComposeRule, @GraphicsMode NATIVE, @Config sdk=36) in the `test` source set, and drop their four globs from `jacocoNonJvmTestableSurface` so they count toward the JVM coverage metric. The instrumented tests stay. Also fix a latent gap in the #375 infra: the JaCoCo agent skips classes with no code-source location, which is exactly how Robolectric loads the classes-under-test through its sandbox classloader — so Robolectric-only Compose coverage recorded as zero (the PoC AddAnotherAccountScreen included). `isIncludeNoLocationClasses = true` on the Test tasks makes that coverage register; scoped bundle line coverage rises ~0.80 -> ~0.82. Floor left at 0.79 (#386 re-ratchets). Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 21 +++- .../compose/format/ColorSwatchRowJvmTest.kt | 99 +++++++++++++++++++ .../ui/compose/format/FontPickerJvmTest.kt | 99 +++++++++++++++++++ .../compose/format/FontSizePickerJvmTest.kt | 99 +++++++++++++++++++ .../ParagraphAlignmentControlJvmTest.kt | 80 +++++++++++++++ 5 files changed, 394 insertions(+), 4 deletions(-) create mode 100644 app/src/test/kotlin/org/libremail/ui/compose/format/ColorSwatchRowJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/compose/format/FontPickerJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/compose/format/FontSizePickerJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/compose/format/ParagraphAlignmentControlJvmTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 57cea52..ed610be 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later +import org.gradle.testing.jacoco.plugins.JacocoTaskExtension import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification import org.gradle.testing.jacoco.tasks.JacocoReport import java.util.Properties @@ -212,6 +213,20 @@ jacoco { toolVersion = libs.versions.jacoco.get() } +// The Robolectric-backed JVM Compose UI tests (#373) load the classes-under-test through +// Robolectric's sandbox classloader, which presents them to the JaCoCo agent WITHOUT a code-source +// location. JaCoCo skips no-location classes by default, so on-the-fly coverage for every composable +// exercised only by a Robolectric test would silently record as zero — the file would be removed +// from `jacocoNonJvmTestableSurface` yet contribute nothing but missed lines, dragging the bundle +// ratio DOWN instead of up. `isIncludeNoLocationClasses = true` makes the agent keep that coverage; +// `jdk.internal.*` is excluded because instrumenting those JDK classes breaks under JDK 17+. +tasks.withType().configureEach { + configure { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } +} + // Unit-test coverage (issue #192). Two tasks share ONE scoping so they can never measure different // surfaces: `jacocoTestReport` (XML+HTML under build/reports/jacoco/jacocoTestReport/) and // `jacocoTestCoverageVerification` (the no-regression gate, further down). Both read the exec data @@ -302,10 +317,8 @@ val jacocoNonJvmTestableSurface = listOf( "**/AppPasswordSetupScreen*", "**/ManualSetupScreen*", "**/ComposeScreen*", - "**/ColorSwatch*", - "**/FontPicker*", - "**/FontSizePicker*", - "**/ParagraphAlignmentControl*", + // ColorSwatch(Row), FontPicker, FontSizePicker & ParagraphAlignmentControl converted to + // Robolectric JVM Compose tests (#376) — now JVM-covered. "**/DraftsScreen*", "**/LockScreen*", "**/AppLockGateHost*", diff --git a/app/src/test/kotlin/org/libremail/ui/compose/format/ColorSwatchRowJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/format/ColorSwatchRowJvmTest.kt new file mode 100644 index 0000000..ee73fec --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/compose/format/ColorSwatchRowJvmTest.kt @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.compose.format + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric JVM port of the instrumented `ColorSwatchRowTest` (#376, umbrella #373): drives the + * shared font-color / highlight swatch row on the JVM under [RobolectricTestRunner] via the v2 + * `createComposeRule()` — no emulator — so [ColorSwatchRow] and its [ColorSwatch] model count toward + * JaCoCo's JVM-testable surface. Every swatch is a TalkBack-labeled, selectable button, so nodes are + * addressed by their content description. The instrumented `ColorSwatchRowTest` stays as the + * on-device E2E. See [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class ColorSwatchRowJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + private val red = ColorSwatch(argb = 0xFFD32F2F.toInt(), label = "Red") + private val blue = ColorSwatch(argb = 0xFF1976D2.toInt(), label = "Blue") + + private fun setContent(selectedArgb: Int?, onSelect: (Int?) -> Unit = {}) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + ColorSwatchRow(swatches = listOf(red, blue), selectedArgb = selectedArgb, onSelect = onSelect) + } + } + } + + @Test + fun showsNoneEntry_andEverySwatch() { + setContent(selectedArgb = null) + + composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(red.label).assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(blue.label).assertIsDisplayed() + } + + @Test + fun tappingASwatch_reportsItsArgb() { + var picked: Int? = -1 + setContent(selectedArgb = null) { picked = it } + + composeTestRule.onNodeWithContentDescription(blue.label).performClick() + + assertEquals(blue.argb, picked) + } + + @Test + fun tappingNoneEntry_reportsNull() { + var picked: Int? = red.argb + setContent(selectedArgb = red.argb) { picked = it } + + composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).performClick() + + assertEquals(null, picked) + } + + @Test + fun selectedSwatch_isMarkedSelected_andOthersAreNot() { + setContent(selectedArgb = red.argb) + + composeTestRule.onNodeWithContentDescription(red.label).assertIsSelected() + composeTestRule.onNodeWithContentDescription(blue.label).assertIsNotSelected() + // With a color selected, the leading "no color" entry is not the selected one. + composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).assertIsNotSelected() + } + + @Test + fun noSelection_marksTheNoneEntrySelected() { + setContent(selectedArgb = null) + + composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).assertIsSelected() + composeTestRule.onNodeWithContentDescription(red.label).assertIsNotSelected() + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/compose/format/FontPickerJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/format/FontPickerJvmTest.kt new file mode 100644 index 0000000..5ba5e77 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/compose/format/FontPickerJvmTest.kt @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.compose.format + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric JVM port of the instrumented `FontPickerTest` (#376, umbrella #373): drives the + * formatting toolbar's font-family dropdown on the JVM under [RobolectricTestRunner] via the v2 + * `createComposeRule()` — no emulator — so [FontPicker] counts toward JaCoCo's JVM-testable surface. + * Exercises the anchor label for the null (Default) and selected states plus opening the menu and + * picking a font / the Default entry, which drives the null-vs-CSS-stack `onSelect` branch. The + * instrumented `FontPickerTest` stays as the on-device E2E. See + * [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class FontPickerJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + private fun setContent(selectedCss: String?, onSelect: (String?) -> Unit = {}) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + FontPicker(selectedCss = selectedCss, onSelect = onSelect) + } + } + } + + @Test + fun noFontSelected_buttonShowsDefaultLabel() { + setContent(selectedCss = null) + + composeTestRule.onNodeWithText(string(R.string.format_font_default)).assertIsDisplayed() + } + + @Test + fun aFontSelected_buttonShowsItsDisplayName() { + val inter = FontRegistry.choices.first { it.name == "Inter" } + setContent(selectedCss = inter.css) + + composeTestRule.onNodeWithText("Inter").assertIsDisplayed() + } + + @Test + fun tappingTheButton_opensAMenuListingEveryRegistryFont() { + setContent(selectedCss = null) + + composeTestRule.onNodeWithText(string(R.string.format_font_default)).performClick() + + FontRegistry.choices.forEach { choice -> + composeTestRule.onNodeWithText(choice.name).assertIsDisplayed() + } + } + + @Test + fun pickingAFontFromTheMenu_reportsItsCssStack() { + var picked: String? = "unset" + val lora = FontRegistry.choices.first { it.name == "Lora" } + setContent(selectedCss = null) { picked = it } + + composeTestRule.onNodeWithText(string(R.string.format_font_default)).performClick() + composeTestRule.onNodeWithText("Lora").performClick() + + assertEquals(lora.css, picked) + } + + @Test + fun pickingDefaultFromTheMenu_clearsBySelectingNull() { + var picked: String? = "unset" + val inter = FontRegistry.choices.first { it.name == "Inter" } + // The button reads "Inter" here, so the menu's own "Default" entry is the only such match. + setContent(selectedCss = inter.css) { picked = it } + + composeTestRule.onNodeWithText("Inter").performClick() + composeTestRule.onNodeWithText(string(R.string.format_font_default)).performClick() + + assertEquals(null, picked) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/compose/format/FontSizePickerJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/format/FontSizePickerJvmTest.kt new file mode 100644 index 0000000..c7207df --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/compose/format/FontSizePickerJvmTest.kt @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.compose.format + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric JVM port of the instrumented `FontSizePickerTest` (#376, umbrella #373): drives the + * formatting toolbar's font-size dropdown on the JVM under [RobolectricTestRunner] via the v2 + * `createComposeRule()` — no emulator — so [FontSizePicker] counts toward JaCoCo's JVM-testable + * surface. Exercises the anchor label for the null (Default) and selected states plus opening the + * menu and picking a preset / the Default entry, which drives the null-vs-point-size `onSelect` + * branch. The instrumented `FontSizePickerTest` stays as the on-device E2E. See + * [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class FontSizePickerJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + private fun string(resId: Int, vararg args: Any): String = context.getString(resId, *args) + + private fun setContent(selectedPt: Int?, onSelect: (Int?) -> Unit = {}) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + FontSizePicker(selectedPt = selectedPt, onSelect = onSelect) + } + } + } + + @Test + fun noSizeSelected_buttonShowsDefaultLabel() { + setContent(selectedPt = null) + + composeTestRule.onNodeWithText(string(R.string.format_size_default)).assertIsDisplayed() + } + + @Test + fun aSizeSelected_buttonShowsItsPointValue() { + setContent(selectedPt = 18) + + composeTestRule.onNodeWithText(string(R.string.format_size_pt, 18)).assertIsDisplayed() + } + + @Test + fun tappingTheButton_opensAMenuListingDefaultAndEveryPreset() { + setContent(selectedPt = null) + + // Before the menu opens, "Default" only labels the anchor button itself - a unique match. + composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick() + + FONT_SIZE_PRESETS_PT.forEach { pt -> + composeTestRule.onNodeWithText(string(R.string.format_size_pt, pt)).assertIsDisplayed() + } + } + + @Test + fun pickingAPresetFromTheMenu_reportsItsPointSize() { + var picked: Int? = -1 + setContent(selectedPt = null) { picked = it } + + composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick() + composeTestRule.onNodeWithText(string(R.string.format_size_pt, 14)).performClick() + + assertEquals(14, picked) + } + + @Test + fun pickingDefaultFromTheMenu_clearsBySelectingNull() { + var picked: Int? = 12 + setContent(selectedPt = 12) { picked = it } + + // The button reads "12 pt" here, so the menu's own "Default" entry is the only such match. + composeTestRule.onNodeWithText(string(R.string.format_size_pt, 12)).performClick() + composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick() + + assertEquals(null, picked) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/compose/format/ParagraphAlignmentControlJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/format/ParagraphAlignmentControlJvmTest.kt new file mode 100644 index 0000000..6de1ff4 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/compose/format/ParagraphAlignmentControlJvmTest.kt @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.compose.format + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.richtext.RichAlign +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric JVM port of the instrumented `ParagraphAlignmentControlTest` (#376, umbrella #373): + * drives the three-state paragraph-alignment control on the JVM under [RobolectricTestRunner] via + * the v2 `createComposeRule()` — no emulator — so [ParagraphAlignmentControl] counts toward JaCoCo's + * JVM-testable surface. Each button is a bare glyph, so nodes are addressed by the shared glyph + * constants. The instrumented `ParagraphAlignmentControlTest` stays as the on-device E2E. See + * [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class ParagraphAlignmentControlJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun setContent(selected: RichAlign?, onSelect: (RichAlign) -> Unit = {}) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + ParagraphAlignmentControl(selected = selected, onSelect = onSelect) + } + } + } + + @Test + fun showsAllThreeAlignmentGlyphs() { + setContent(selected = RichAlign.START) + + composeTestRule.onNodeWithText(ALIGN_START_GLYPH).assertIsDisplayed() + composeTestRule.onNodeWithText(ALIGN_CENTER_GLYPH).assertIsDisplayed() + composeTestRule.onNodeWithText(ALIGN_END_GLYPH).assertIsDisplayed() + } + + @Test + fun tappingCenter_reportsCenter() { + var picked: RichAlign? = null + setContent(selected = RichAlign.START) { picked = it } + + composeTestRule.onNodeWithText(ALIGN_CENTER_GLYPH).performClick() + + assertEquals(RichAlign.CENTER, picked) + } + + @Test + fun tappingEnd_reportsEnd() { + var picked: RichAlign? = null + setContent(selected = RichAlign.START) { picked = it } + + composeTestRule.onNodeWithText(ALIGN_END_GLYPH).performClick() + + assertEquals(RichAlign.END, picked) + } + + @Test + fun tappingStart_reportsStart() { + var picked: RichAlign? = null + setContent(selected = RichAlign.CENTER) { picked = it } + + composeTestRule.onNodeWithText(ALIGN_START_GLYPH).performClick() + + assertEquals(RichAlign.START, picked) + } +} -- 2.47.3