From 59e2a16a39a2e8bc4adf5215c9c999c53375d715 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 25 Aug 2026 20:40:29 +0200 Subject: [PATCH] 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. --- .../common/theming/BatteryColorsThemeTest.kt | 88 ++++++++++ .../overview/cards/BatteryLevelStateTest.kt | 128 +++++++++++++++ .../ui/overview/cards/CaseChargesLineTest.kt | 152 ++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 app/src/test/java/eu/darken/capod/common/theming/BatteryColorsThemeTest.kt create mode 100644 app/src/test/java/eu/darken/capod/main/ui/overview/cards/BatteryLevelStateTest.kt create mode 100644 app/src/test/java/eu/darken/capod/main/ui/overview/cards/CaseChargesLineTest.kt diff --git a/app/src/test/java/eu/darken/capod/common/theming/BatteryColorsThemeTest.kt b/app/src/test/java/eu/darken/capod/common/theming/BatteryColorsThemeTest.kt new file mode 100644 index 00000000..91714326 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/theming/BatteryColorsThemeTest.kt @@ -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() + } +} diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/cards/BatteryLevelStateTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/cards/BatteryLevelStateTest.kt new file mode 100644 index 00000000..fd038d6f --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/cards/BatteryLevelStateTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/cards/CaseChargesLineTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/cards/CaseChargesLineTest.kt new file mode 100644 index 00000000..e0df3396 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/cards/CaseChargesLineTest.kt @@ -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() + } +}