fix(overview): Stop denying a charge the reading still allows

The visible count came from the pessimistic end of the interval alone, so
an interval straddling a full charge said "Less than one more full charge"
while the colour stayed neutral and TalkBack said "may not be enough". An
uncertain interval now rounds to a single charge instead, and an
open-ended spec that has not reached one charge names no number at all.

Fixes review finding F2
This commit is contained in:
darken
2026-08-25 22:33:42 +02:00
committed by Matthias Urhahn
parent 70830cf2ca
commit 03c09917da
5 changed files with 59 additions and 5 deletions
@@ -513,6 +513,7 @@ private fun CaseChargesLine(
val text = when (charges.display) { val text = when (charges.display) {
CaseCharges.Display.EMPTY -> stringResource(R.string.battery_case_charges_empty) CaseCharges.Display.EMPTY -> stringResource(R.string.battery_case_charges_empty)
CaseCharges.Display.LESS_THAN_ONE -> stringResource(R.string.battery_case_charges_less_than_one) CaseCharges.Display.LESS_THAN_ONE -> stringResource(R.string.battery_case_charges_less_than_one)
CaseCharges.Display.UNCERTAIN -> stringResource(R.string.battery_case_charges_uncertain)
CaseCharges.Display.AT_LEAST -> pluralStringResource( CaseCharges.Display.AT_LEAST -> pluralStringResource(
R.plurals.battery_case_charges_at_least, R.plurals.battery_case_charges_at_least,
charges.count, charges.count,
@@ -14,6 +14,9 @@ data class CaseCharges(
/** Nothing left to give — distinct from [LESS_THAN_ONE], which still implies some charge. */ /** Nothing left to give — distinct from [LESS_THAN_ONE], which still implies some charge. */
EMPTY, EMPTY,
LESS_THAN_ONE, LESS_THAN_ONE,
/** No number at all: an open-ended interval that has not yet reached a full charge. */
UNCERTAIN,
AT_LEAST, AT_LEAST,
APPROXIMATE, APPROXIMATE,
} }
@@ -32,6 +35,10 @@ data class CaseCharges(
* *
* A spec that is itself a lower bound has no upper end, so it can never claim "not enough". * A spec that is itself a lower bound has no upper end, so it can never claim "not enough".
* *
* The wording follows the whole interval, not its pessimistic end: an uncertain one rounds to a
* single charge, or names no number at all ([CaseCharges.Display.UNCERTAIN]) when the spec is a
* lower bound and there is no upper end to round from.
*
* Null when the model publishes no case figures or the reading is missing or nonsense — the caller * Null when the model publishes no case figures or the reading is missing or nonsense — the caller
* shows no line at all rather than a neutral one. * shows no line at all rather than a neutral one.
*/ */
@@ -50,13 +57,23 @@ fun caseCharges(spec: PodModel.CaseSpec?, reading: BatteryReading?): CaseCharges
else -> CaseCharges.Adequacy.UNCERTAIN else -> CaseCharges.Adequacy.UNCERTAIN
} }
val count = floor(lowest).toInt() val floored = floor(lowest).toInt()
val display = when { val display = when {
reading.percent == 0f -> CaseCharges.Display.EMPTY reading.percent == 0f -> CaseCharges.Display.EMPTY
count < 1 -> CaseCharges.Display.LESS_THAN_ONE adequacy == CaseCharges.Adequacy.NOT_ENOUGH -> CaseCharges.Display.LESS_THAN_ONE
adequacy == CaseCharges.Adequacy.UNCERTAIN && spec.isLowerBound -> CaseCharges.Display.UNCERTAIN
adequacy == CaseCharges.Adequacy.UNCERTAIN -> CaseCharges.Display.APPROXIMATE
spec.isLowerBound -> CaseCharges.Display.AT_LEAST spec.isLowerBound -> CaseCharges.Display.AT_LEAST
else -> CaseCharges.Display.APPROXIMATE else -> CaseCharges.Display.APPROXIMATE
} }
val count = when {
reading.percent == 0f -> 0
adequacy == CaseCharges.Adequacy.NOT_ENOUGH -> 0
adequacy == CaseCharges.Adequacy.UNCERTAIN && !spec.isLowerBound -> 1
else -> floored
}
return CaseCharges(count = count, display = display, adequacy = adequacy) return CaseCharges(count = count, display = display, adequacy = adequacy)
} }
+2
View File
@@ -287,6 +287,8 @@
</plurals> </plurals>
<string name="battery_case_charges_less_than_one">Less than one more full charge for both earbuds</string> <string name="battery_case_charges_less_than_one">Less than one more full charge for both earbuds</string>
<string name="battery_case_charges_empty">Case is empty, no charges left for your earbuds</string> <string name="battery_case_charges_empty">Case is empty, no charges left for your earbuds</string>
<!-- Shown when the case reading is too coarse to name any number of charges. Must not mention a count. -->
<string name="battery_case_charges_uncertain">Not sure how many full charges are left for both earbuds</string>
<string name="battery_case_charges_state_enough_cd">Enough for a full charge</string> <string name="battery_case_charges_state_enough_cd">Enough for a full charge</string>
<string name="battery_case_charges_state_not_enough_cd">Not enough for a full charge</string> <string name="battery_case_charges_state_not_enough_cd">Not enough for a full charge</string>
<string name="battery_case_charges_state_uncertain_cd">May not be enough for a full charge</string> <string name="battery_case_charges_state_uncertain_cd">May not be enough for a full charge</string>
@@ -92,11 +92,19 @@ class CaseChargesLineTest : BaseComposeRobolectricTest() {
} }
@Test @Test
fun `a reading astride a full charge claims nothing`() { fun `a reading astride a full charge rounds to one`() {
setCard(device(case = 0.20f)) setCard(device(case = 0.20f))
assertAdequacy(charges(1), R.string.battery_case_charges_state_uncertain_cd)
}
@Test
fun `an open ended spec below a full charge names no number`() {
// AirPods (Gen 1) publish a lower bound, so 3.8 x 0.10 has no upper end to round to
setCard(device(case = 0.10f, model = PodModel.AIRPODS_GEN1))
assertAdequacy( assertAdequacy(
context.getString(R.string.battery_case_charges_less_than_one), context.getString(R.string.battery_case_charges_uncertain),
R.string.battery_case_charges_state_uncertain_cd, R.string.battery_case_charges_state_uncertain_cd,
) )
} }
@@ -107,10 +107,36 @@ class CaseChargesTest : BaseTest() {
@Test @Test
fun `below a full charge renders as less than one`() { fun `below a full charge renders as less than one`() {
charges(exact, 0.20f, percent)!!.display shouldBe CaseCharges.Display.LESS_THAN_ONE charges(exact, 0.20f, percent)!!.display shouldBe CaseCharges.Display.LESS_THAN_ONE
charges(lowerBound, 0.20f, percent)!!.display shouldBe CaseCharges.Display.LESS_THAN_ONE
charges(exact, 0.01f, percent)!!.display shouldBe CaseCharges.Display.LESS_THAN_ONE charges(exact, 0.01f, percent)!!.display shouldBe CaseCharges.Display.LESS_THAN_ONE
} }
@Test
fun `an interval straddling one charge rounds to one instead of claiming less`() {
// 4.0 x 0.20 = 0.8, 4.0 x 0.30 = 1.2 — the text must not deny what the interval allows
val straddle = charges(exact, 0.20f, decile)!!
straddle.display shouldBe CaseCharges.Display.APPROXIMATE
straddle.count shouldBe 1
}
@Test
fun `an uncertain lower bound spec names no number`() {
// 3.8 x 0.10 = 0.38 with no upper end, so neither "less than one" nor "~1" is supportable
val vague = charges(lowerBound, 0.10f, decile)!!
vague.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN
vague.display shouldBe CaseCharges.Display.UNCERTAIN
charges(lowerBound, 0.20f, percent)!!.display shouldBe CaseCharges.Display.UNCERTAIN
}
@Test
fun `a lower bound spec that reaches enough still counts`() {
val enough = charges(lowerBound, 0.50f, decile)!!
enough.adequacy shouldBe CaseCharges.Adequacy.ENOUGH
enough.display shouldBe CaseCharges.Display.AT_LEAST
// 3.8 x 0.50 = 1.9
enough.count shouldBe 1
}
@Test @Test
fun `an empty case renders as empty`() { fun `an empty case renders as empty`() {
charges(exact, 0f, percent)!!.display shouldBe CaseCharges.Display.EMPTY charges(exact, 0f, percent)!!.display shouldBe CaseCharges.Display.EMPTY