mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user