fix(battery): Seed charge ETA from quick-charge specs, hide projection while charging

- While charging, the gauge showed the bare runtime projection when no charge
  rate existed yet — "1% · 4m" next to a charging chip reads as a four-minute
  charge. The line now shows the "until charged" ETA or nothing
- Seed the charge rate from Apple's published quick-charge claims ("5 minutes
  in the case provides around 1 hour of listening"), normalized against the
  rated listening hours, so the ETA is present from the very first charge; a
  live fit still takes over within minutes
This commit is contained in:
darken
2026-07-02 17:32:29 +02:00
committed by Matthias Urhahn
parent 4488af0ab7
commit ba3e253569
6 changed files with 70 additions and 23 deletions
@@ -267,6 +267,16 @@ class BatteryEstimatorTest : BaseTest() {
left.minutesUntilCharged shouldBe 28
}
@Test
fun `the quick-charge rating seeds an ETA on the very first charge`() = runTest(UnconfinedTestDispatcher()) {
// Nothing measured, nothing stored — Apple's "5 minutes = ~1 hour of listening" claim
// (2.0/hr for a Pro 2) answers at once: 50% missing at 2.0/hr == 15 min.
val result = collectEstimate(
estimator(listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2))))
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesUntilCharged shouldBe 15
}
@Test
fun `a stored charge rate seeds time-until-charged immediately`() = runTest(UnconfinedTestDispatcher()) {
// First charging emission, no live fit possible yet -> the persisted rate answers at once.
@@ -155,6 +155,19 @@ class ModelFeaturesTest : BaseTest() {
}
}
@Test
fun `every battery spec carries a plausible quick-charge rate`() {
// Derived from Apple's published quick-charge claims; must sit inside the band the live
// fit is validated against, or the seed could never be corrected by a measurement.
PodModel.entries.forEach { model ->
val spec = model.batterySpec ?: return@forEach
withClue(model.name) {
val rate = spec.chargeFractionPerHour
(rate != null && rate in 0.25f..4.0f) shouldBe true
}
}
}
private fun modelsWith(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
.filter { predicate(it.features) }
.toSet()