test(compose): Robolectric JVM tests for rich-text format controls (#376)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 19:49:59 -05:00
co-authored by Claude Opus 4.8
parent 6213ee8901
commit 9bbfa2108a
5 changed files with 394 additions and 4 deletions
@@ -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)
}
}