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() + } +}