test(overview): Cover the battery level states and the case charges line

Colour is not exposed through the semantics tree, and captureToImage() times
out under Robolectric, so the rendered result is pinned in two halves: which
tier each of the five slots reports, and which colour a tier resolves to per
theme mode. The theme test drives an in-app dark override on a light host,
which is the case a plain isSystemInDarkTheme() read would get wrong.

The case charges line is covered for presence, the three adequacy states, both
special renderings, and under RTL, a narrow card and a doubled font scale.
This commit is contained in:
darken
2026-08-25 22:33:42 +02:00
committed by Matthias Urhahn
parent 11d6ddf3fa
commit 59e2a16a39
3 changed files with 368 additions and 0 deletions
@@ -0,0 +1,88 @@
package eu.darken.capod.common.theming
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.Color
import eu.darken.capod.monitor.core.battery.BatteryTier
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import org.junit.Test
import testhelpers.compose.BaseComposeRobolectricTest
/**
* Colour is not part of the semantics tree and `captureToImage()` does not work under Robolectric,
* so what a rendered card looks like is checked in two halves: this class pins the colour each tier
* resolves to, and the card tests pin which tier each slot reports.
*/
class BatteryColorsThemeTest : BaseComposeRobolectricTest() {
private class Resolved {
var warnFill: Color = Color.Unspecified
var criticalFill: Color = Color.Unspecified
var goodFill: Color = Color.Unspecified
var unknownFill: Color = Color.Unspecified
var warnText: Color? = null
var criticalText: Color? = null
var goodText: Color? = null
var unknownText: Color? = null
var error: Color = Color.Unspecified
var primary: Color = Color.Unspecified
var surfaceVariant: Color = Color.Unspecified
}
private fun resolve(mode: ThemeMode): Resolved {
val resolved = Resolved()
composeRule.setContent {
CapodTheme(state = ThemeState(mode = mode)) {
resolved.warnFill = BatteryTier.WARN.fillColor()
resolved.criticalFill = BatteryTier.CRITICAL.fillColor()
resolved.goodFill = BatteryTier.GOOD.fillColor()
resolved.unknownFill = BatteryTier.UNKNOWN.fillColor()
resolved.warnText = BatteryTier.WARN.textColorOrNull()
resolved.criticalText = BatteryTier.CRITICAL.textColorOrNull()
resolved.goodText = BatteryTier.GOOD.textColorOrNull()
resolved.unknownText = BatteryTier.UNKNOWN.textColorOrNull()
resolved.error = MaterialTheme.colorScheme.error
resolved.primary = MaterialTheme.colorScheme.primary
resolved.surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
}
}
composeRule.waitForIdle()
return resolved
}
@Test
fun `the light theme uses the light tokens`() {
val resolved = resolve(ThemeMode.LIGHT)
resolved.warnFill shouldBe BatteryColors.Light.warnFill
resolved.warnText shouldBe BatteryColors.Light.warnText
}
@Test
fun `an in-app dark override uses the dark tokens`() {
// The host system is in light mode here, which is exactly the case a plain
// isSystemInDarkTheme() read inside the mapper would get wrong.
val resolved = resolve(ThemeMode.DARK)
resolved.warnFill shouldBe BatteryColors.Dark.warnFill
resolved.warnText shouldBe BatteryColors.Dark.warnText
}
@Test
fun `the other tiers stay on the palette`() {
val resolved = resolve(ThemeMode.LIGHT)
resolved.criticalFill shouldBe resolved.error
resolved.criticalText shouldBe resolved.error
resolved.goodFill shouldBe resolved.primary
resolved.unknownFill shouldBe resolved.surfaceVariant
}
@Test
fun `only a warning or worse claims the text colour`() {
val resolved = resolve(ThemeMode.LIGHT)
resolved.goodText.shouldBeNull()
resolved.unknownText.shouldBeNull()
}
}
@@ -0,0 +1,128 @@
package eu.darken.capod.main.ui.overview.cards
import android.content.Context
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.onNodeWithText
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.R
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MOCK_NOW
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import org.junit.Test
import testhelpers.compose.BaseComposeRobolectricTest
/**
* Every slot on the overview reports its level as a state description, which is both the screen
* reader's only access to the threshold and the observable half of the colour: the tier a slot
* announces here is the same tier it hands to the colour mapper covered by BatteryColorsThemeTest.
*/
class BatteryLevelStateTest : BaseComposeRobolectricTest() {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
private val low: String
get() = context.getString(R.string.battery_state_low_cd)
private val critical: String
get() = context.getString(R.string.battery_state_critical_cd)
/** Left pod low, right pod critical, case low — three distinct percentages, three tiers. */
private fun mixedDualPods(): PodDevice = MockPodDataProvider.dualPodBatteries(
left = 0.22f,
right = 0.05f,
case = 0.20f,
)
private fun setDualPods(device: PodDevice, collapsed: Boolean) {
composeRule.setContent {
PreviewWrapper {
DualPodsCard(
device = device,
showDebug = false,
now = MOCK_NOW,
isCollapsed = collapsed,
onToggleCollapse = {},
)
}
}
}
private fun assertState(text: String, expected: String) {
composeRule.onNodeWithText(text)
.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, expected))
}
@Test
fun `the expanded pod gauges report their level`() {
setDualPods(mixedDualPods(), collapsed = false)
assertState("22%", low)
assertState("5%", critical)
}
@Test
fun `the expanded case row reports its level`() {
setDualPods(mixedDualPods(), collapsed = false)
assertState("20%", low)
}
@Test
fun `the collapsed pod rings report their level`() {
setDualPods(mixedDualPods(), collapsed = true)
assertState("22%", low)
assertState("5%", critical)
}
@Test
fun `the collapsed case cluster reports its level`() {
setDualPods(mixedDualPods(), collapsed = true)
assertState("20%", low)
}
@Test
fun `the single pod gauge reports a low level`() {
composeRule.setContent {
PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodBattery(percent = 0.20f),
showDebug = false,
now = MOCK_NOW,
)
}
}
assertState("20%", low)
}
@Test
fun `the single pod gauge reports a critical level`() {
composeRule.setContent {
PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodBattery(percent = 0.08f),
showDebug = false,
now = MOCK_NOW,
)
}
}
assertState("8%", critical)
}
@Test
fun `a healthy level makes no claim`() {
setDualPods(MockPodDataProvider.dualPodBatteries(left = 0.80f, right = 0.45f, case = 0.60f), collapsed = false)
composeRule.onNodeWithText("80%")
.assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.StateDescription).not())
composeRule.onNodeWithText("60%")
.assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.StateDescription).not())
}
}
@@ -0,0 +1,152 @@
package eu.darken.capod.main.ui.overview.cards
import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.R
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MOCK_NOW
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import org.junit.Test
import testhelpers.compose.BaseComposeRobolectricTest
/**
* The mock case battery resolves at decile granularity, so a 4.0-charge spec (AirPods Pro 2) puts
* 30% wholly above a full charge, 10% wholly below it, and 20% astride it.
*/
class CaseChargesLineTest : BaseComposeRobolectricTest() {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
private fun charges(count: Int): String =
context.resources.getQuantityString(R.plurals.battery_case_charges_approx, count, count)
private fun device(case: Float, model: PodModel = PodModel.AIRPODS_PRO2): PodDevice =
MockPodDataProvider.dualPodBatteries(case = case, model = model)
private fun setCard(
device: PodDevice,
wrapper: @Composable (@Composable () -> Unit) -> Unit = { it() },
) {
composeRule.setContent {
PreviewWrapper {
wrapper {
DualPodsCard(
device = device,
showDebug = false,
now = MOCK_NOW,
)
}
}
}
}
private fun assertAdequacy(line: String, expected: Int) {
composeRule.onNodeWithText(line).assert(
SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, context.getString(expected))
)
}
@Test
fun `a model with published case figures gets a line`() {
setCard(device(case = 0.60f))
// 4.0 x 0.60 = 2.4
composeRule.onNodeWithText(charges(2)).assertIsDisplayed()
}
@Test
fun `a model without published case figures gets no line`() {
setCard(device(case = 0.60f, model = PodModel.POWERBEATS_PRO))
composeRule.onAllNodesWithText(charges(2)).assertCountEquals(0)
composeRule.onAllNodesWithText(charges(1)).assertCountEquals(0)
composeRule.onAllNodesWithText(context.getString(R.string.battery_case_charges_less_than_one))
.assertCountEquals(0)
}
@Test
fun `a full charge in hand reads as enough`() {
setCard(device(case = 0.30f))
assertAdequacy(charges(1), R.string.battery_case_charges_state_enough_cd)
}
@Test
fun `a reading astride a full charge claims nothing`() {
setCard(device(case = 0.20f))
assertAdequacy(
context.getString(R.string.battery_case_charges_less_than_one),
R.string.battery_case_charges_state_uncertain_cd,
)
}
@Test
fun `a reading below a full charge reads as not enough`() {
setCard(device(case = 0.10f))
assertAdequacy(
context.getString(R.string.battery_case_charges_less_than_one),
R.string.battery_case_charges_state_not_enough_cd,
)
}
@Test
fun `an empty case says so instead of counting`() {
setCard(device(case = 0f))
composeRule.onNodeWithText(context.getString(R.string.battery_case_charges_empty)).assertIsDisplayed()
composeRule.onAllNodesWithText(context.getString(R.string.battery_case_charges_less_than_one))
.assertCountEquals(0)
}
@Test
fun `the line survives a right-to-left layout`() {
setCard(device(case = 0.60f)) { card ->
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { card() }
}
composeRule.onNodeWithText(charges(2)).assertIsDisplayed()
composeRule.onNodeWithText("60%").assertIsDisplayed()
}
@Test
fun `the line survives a narrow card`() {
setCard(device(case = 0.60f)) { card ->
Box(modifier = Modifier.width(240.dp)) { card() }
}
composeRule.onNodeWithText(charges(2)).assertIsDisplayed()
composeRule.onNodeWithText("60%").assertIsDisplayed()
}
@Test
fun `the line survives a large font scale`() {
setCard(device(case = 0.60f)) { card ->
CompositionLocalProvider(LocalDensity provides Density(density = 1f, fontScale = 2f)) { card() }
}
composeRule.onNodeWithText(charges(2)).assertIsDisplayed()
composeRule.onNodeWithText("60%").assertIsDisplayed()
}
}