Merge pull request #394 from JMR-dev/test-376-robolectric-richtext

test(compose): Robolectric JVM tests for rich-text format controls (#376)
This commit was merged in pull request #394.
This commit is contained in:
Jason Ross
2026-07-06 20:09:40 -05:00
committed by GitHub
5 changed files with 394 additions and 4 deletions
+17 -4
View File
@@ -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<Test>().configureEach {
configure<JacocoTaskExtension> {
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*",
@@ -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()
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}