From 03c09917da17e5c570c5e44ed0700d82ec05302b Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 25 Aug 2026 21:00:06 +0200 Subject: [PATCH] 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 --- .../main/ui/overview/cards/DualPodsCard.kt | 1 + .../capod/monitor/core/battery/CaseCharges.kt | 21 ++++++++++++-- app/src/main/res/values/strings.xml | 2 ++ .../ui/overview/cards/CaseChargesLineTest.kt | 12 ++++++-- .../monitor/core/battery/CaseChargesTest.kt | 28 ++++++++++++++++++- 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 7d4cc079..0fc6d07a 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -513,6 +513,7 @@ private fun CaseChargesLine( val text = when (charges.display) { 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.UNCERTAIN -> stringResource(R.string.battery_case_charges_uncertain) CaseCharges.Display.AT_LEAST -> pluralStringResource( R.plurals.battery_case_charges_at_least, charges.count, diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/CaseCharges.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/CaseCharges.kt index 95f0822b..b272dcfe 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/CaseCharges.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/CaseCharges.kt @@ -14,6 +14,9 @@ data class CaseCharges( /** Nothing left to give — distinct from [LESS_THAN_ONE], which still implies some charge. */ EMPTY, LESS_THAN_ONE, + + /** No number at all: an open-ended interval that has not yet reached a full charge. */ + UNCERTAIN, AT_LEAST, 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". * + * 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 * 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 } - val count = floor(lowest).toInt() + val floored = floor(lowest).toInt() + val display = when { 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 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) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87112615..d0c8a415 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -287,6 +287,8 @@ Less than one more full charge for both earbuds Case is empty, no charges left for your earbuds + + Not sure how many full charges are left for both earbuds Enough for a full charge Not enough for a full charge May not be enough for a full charge 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 index e0df3396..ce8dd8c7 100644 --- 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 @@ -92,11 +92,19 @@ class CaseChargesLineTest : BaseComposeRobolectricTest() { } @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)) + 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( - 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, ) } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/battery/CaseChargesTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/battery/CaseChargesTest.kt index 1a763afe..4b305f26 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/battery/CaseChargesTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/battery/CaseChargesTest.kt @@ -107,10 +107,36 @@ class CaseChargesTest : BaseTest() { @Test fun `below a full charge renders as 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 } + @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 fun `an empty case renders as empty`() { charges(exact, 0f, percent)!!.display shouldBe CaseCharges.Display.EMPTY