mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
feat(battery): Make estimate per-device and seed from model specs
- Replace the global estimate toggle with a per-device toggle stored on the profile
- Seed the estimate from each model's rated battery life and show it immediately, using
the rating as a hard upper bound on displayed life while the measured rate converges
- When the ANC mode is unknown, seed from the shorter of a model's ANC-on/off ratings
- Show a projection while charging ("if used now") without ever learning from a rising battery
- Consolidate charge limit, "notify when charged", the estimate toggle and reset into one
Battery card; the charge notification now works for any live device, not only classic
audio connections
- Smooth the displayed time asymmetrically (drop fast, rise slow) so a faster-than-rated
drain stops over-promising within a couple of updates
This commit is contained in:
+43
@@ -12,6 +12,7 @@ import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
@@ -62,6 +63,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
private lateinit var upgradeRepo: UpgradeRepo
|
||||
private lateinit var bluetoothManager: BluetoothManager2
|
||||
private lateinit var profilesRepo: DeviceProfilesRepo
|
||||
private lateinit var batteryEstimator: BatteryEstimator
|
||||
private lateinit var monitorModeResolver: MonitorModeResolver
|
||||
private lateinit var nudgeCapabilityStore: NudgeCapabilityStore
|
||||
private lateinit var nudgeAvailabilityFlow: MutableStateFlow<NudgeAvailability>
|
||||
@@ -116,6 +118,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
profilesRepo = mockk(relaxed = true) {
|
||||
every { profiles } returns profilesFlow
|
||||
}
|
||||
batteryEstimator = mockk(relaxed = true)
|
||||
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
|
||||
monitorModeResolver = mockk<MonitorModeResolver>().also {
|
||||
every { it.effectiveMode } returns effectiveModeFlow
|
||||
@@ -140,6 +143,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
upgradeRepo = upgradeRepo,
|
||||
bluetoothManager = bluetoothManager,
|
||||
profilesRepo = profilesRepo,
|
||||
batteryEstimator = batteryEstimator,
|
||||
monitorModeResolver = monitorModeResolver,
|
||||
nudgeCapabilityStore = nudgeCapabilityStore,
|
||||
timeSource = timeSource,
|
||||
@@ -514,6 +518,45 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(any(), any<AapCommand.SetSleepDetection>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state reflects the profile's batteryEstimateEnabled`() = runVmTest {
|
||||
profilesFlow.value = listOf(
|
||||
AppleDeviceProfile(
|
||||
id = testAddress,
|
||||
label = "Test",
|
||||
address = testAddress,
|
||||
batteryEstimateEnabled = false,
|
||||
)
|
||||
)
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
|
||||
vm.state.first().batteryEstimateEnabled shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setBatteryEstimateEnabled updates the profile`() = runVmTest {
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
vm.setBatteryEstimateEnabled(false)
|
||||
|
||||
coVerify { profilesRepo.updateAppleProfile(testAddress, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetBatteryEstimate resets the estimator for the profile`() = runVmTest {
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
vm.resetBatteryEstimate()
|
||||
|
||||
coVerify { batteryEstimator.reset(testAddress) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setSleepDetection(false) as non-Pro still sends command`() = runVmTest {
|
||||
// Disabling must work regardless of pro status so users who enabled it
|
||||
|
||||
@@ -71,7 +71,6 @@ class OverviewViewModelTest : BaseTest() {
|
||||
private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode>
|
||||
private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean>
|
||||
private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue<Boolean>
|
||||
private lateinit var fakeBatteryEstimateEnabled: FakeDataStoreValue<Boolean>
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
@@ -87,7 +86,6 @@ class OverviewViewModelTest : BaseTest() {
|
||||
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
|
||||
fakeReactionsHintDismissed = FakeDataStoreValue(false)
|
||||
fakeHideUnmatchedDevices = FakeDataStoreValue(false)
|
||||
fakeBatteryEstimateEnabled = FakeDataStoreValue(true)
|
||||
Bugs.isDebug.value = false
|
||||
|
||||
monitorControl = mockk(relaxed = true)
|
||||
@@ -106,7 +104,6 @@ class OverviewViewModelTest : BaseTest() {
|
||||
generalSettings = mockk<GeneralSettings>().also {
|
||||
every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock
|
||||
every { it.hideUnmatchedDevices } returns fakeHideUnmatchedDevices.mock
|
||||
every { it.batteryEstimateEnabled } returns fakeBatteryEstimateEnabled.mock
|
||||
}
|
||||
|
||||
batteryEstimator = mockk<BatteryEstimator>().also {
|
||||
@@ -650,4 +647,46 @@ class OverviewViewModelTest : BaseTest() {
|
||||
latest!!.showTroubleshootSuggestion shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estimateFor is null when the device has the estimate disabled`() {
|
||||
val device = mockk<PodDevice> {
|
||||
every { batteryEstimateEnabled } returns false
|
||||
every { isLive } returns true
|
||||
every { profileId } returns "p1"
|
||||
}
|
||||
val state = estimateState(device, mapOf("p1" to sampleEstimate()))
|
||||
|
||||
state.estimateFor(device) shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estimateFor returns the estimate when enabled and live`() {
|
||||
val device = mockk<PodDevice> {
|
||||
every { batteryEstimateEnabled } returns true
|
||||
every { isLive } returns true
|
||||
every { profileId } returns "p1"
|
||||
}
|
||||
val estimate = sampleEstimate()
|
||||
val state = estimateState(device, mapOf("p1" to estimate))
|
||||
|
||||
state.estimateFor(device) shouldBe estimate
|
||||
}
|
||||
|
||||
private fun sampleEstimate() = BatteryEstimate(
|
||||
left = BatteryEstimate.Pod(minutesRemaining = 120, fractionPerHour = 0.2f, source = BatteryEstimate.Source.LIVE),
|
||||
)
|
||||
|
||||
private fun estimateState(device: PodDevice, estimates: Map<String, BatteryEstimate>) =
|
||||
OverviewViewModel.State(
|
||||
now = java.time.Instant.EPOCH,
|
||||
permissions = emptySet(),
|
||||
devices = listOf(device),
|
||||
isDebug = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = emptyList(),
|
||||
upgradeInfo = mockk(relaxed = true),
|
||||
showUnmatchedDevices = false,
|
||||
batteryEstimates = estimates,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package eu.darken.capod.monitor.core.battery
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState.Battery
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState.BatteryType
|
||||
@@ -10,6 +11,7 @@ import eu.darken.capod.pods.core.apple.aap.AapPodState.ChargingState
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -31,13 +33,21 @@ class BatteryEstimatorTest : BaseTest() {
|
||||
left: Float?,
|
||||
right: Float?,
|
||||
charging: Boolean = false,
|
||||
model: PodModel? = null,
|
||||
estimateEnabled: Boolean = true,
|
||||
): PodDevice {
|
||||
val state = if (charging) ChargingState.CHARGING else ChargingState.NOT_CHARGING
|
||||
val batteries = buildMap {
|
||||
if (left != null) put(BatteryType.LEFT, Battery(BatteryType.LEFT, left, state))
|
||||
if (right != null) put(BatteryType.RIGHT, Battery(BatteryType.RIGHT, right, state))
|
||||
}
|
||||
return PodDevice(profileId = profileId, ble = null, aap = AapPodState(batteries = batteries))
|
||||
return PodDevice(
|
||||
profileId = profileId,
|
||||
ble = null,
|
||||
aap = AapPodState(batteries = batteries),
|
||||
profileModel = model,
|
||||
batteryEstimateEnabled = estimateEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
private fun estimator(
|
||||
@@ -86,7 +96,8 @@ class BatteryEstimatorTest : BaseTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `charging device produces no estimate even with learned rate`() = runTest(UnconfinedTestDispatcher()) {
|
||||
fun `a charging device projects runtime from the learned rate`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// Docked/charging: no live drain, but we still show "what it'd last if used now" from history.
|
||||
val stored = mapOf("p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.15f), "UNKNOWN/RIGHT" to learned(0.15f))))
|
||||
val result = collectEstimate(
|
||||
estimator(
|
||||
@@ -94,7 +105,29 @@ class BatteryEstimatorTest : BaseTest() {
|
||||
stored = stored,
|
||||
)
|
||||
)
|
||||
result shouldBe emptyMap()
|
||||
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
|
||||
left.source shouldBe BatteryEstimate.Source.LEARNED
|
||||
// 0.50 / 0.15 * 60 == 200
|
||||
left.minutesRemaining shouldBe 200
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a charging device projects runtime from the model rating`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// Full AirPods Pro 2 in the case, nothing learned yet -> projects the 6h rating. 1.0 / (1/6) * 60.
|
||||
val result = collectEstimate(
|
||||
estimator(listOf(listOf(device("p1", left = 1.0f, right = 1.0f, charging = true, model = PodModel.AIRPODS_PRO2))))
|
||||
)
|
||||
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
|
||||
left.source shouldBe BatteryEstimate.Source.SPEC
|
||||
left.minutesRemaining shouldBe 360
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a charging device with no rate shows nothing`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// Unknown model, nothing learned -> no basis to project from while charging.
|
||||
collectEstimate(
|
||||
estimator(listOf(listOf(device("p1", left = 0.80f, right = 0.80f, charging = true))))
|
||||
) shouldBe emptyMap()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -109,7 +142,7 @@ class BatteryEstimatorTest : BaseTest() {
|
||||
)
|
||||
|
||||
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
|
||||
left.isLearned shouldBe true
|
||||
left.source shouldBe BatteryEstimate.Source.LEARNED
|
||||
// 0.50 / 0.15 * 60 == 200
|
||||
left.minutesRemaining shouldBe 200
|
||||
}
|
||||
@@ -122,7 +155,7 @@ class BatteryEstimatorTest : BaseTest() {
|
||||
listOf(device("p1", left = level, right = level))
|
||||
}
|
||||
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
|
||||
left.isLearned shouldBe false
|
||||
left.source shouldBe BatteryEstimate.Source.LIVE
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -134,12 +167,112 @@ class BatteryEstimatorTest : BaseTest() {
|
||||
val estimate = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull()
|
||||
val left = estimate.left.shouldNotBeNull()
|
||||
val right = estimate.right.shouldNotBeNull()
|
||||
left.isLearned shouldBe false
|
||||
right.isLearned shouldBe false
|
||||
left.source shouldBe BatteryEstimate.Source.LIVE
|
||||
right.source shouldBe BatteryEstimate.Source.LIVE
|
||||
// Faster-draining left pod must empty sooner than the right.
|
||||
(left.minutesRemaining < right.minutesRemaining) shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a model rating seeds an estimate immediately`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// One sample -> no live regression, nothing learned -> the AirPods Pro 2 rating (6h) seeds
|
||||
// the estimate at once. 1.00 / (1/6) * 60 == 360.
|
||||
val result = collectEstimate(
|
||||
estimator(listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2))))
|
||||
)
|
||||
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
|
||||
left.source shouldBe BatteryEstimate.Source.SPEC
|
||||
left.minutesRemaining shouldBe 360
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown ANC mode seeds from the shorter rating`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// AirPods 4 ANC: 4h with ANC on, 5h off. The mode isn't known yet, so the shorter 4h rating
|
||||
// is used to avoid over-promising. 1.00 / (1/4) * 60 == 240.
|
||||
val result = collectEstimate(
|
||||
estimator(listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_GEN4_ANC))))
|
||||
)
|
||||
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
|
||||
left.source shouldBe BatteryEstimate.Source.SPEC
|
||||
left.minutesRemaining shouldBe 240
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the model rating caps an over-optimistic learned rate`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// A learned 0.10/hr implies 10h at full charge, beyond the Pro 2's 6h rating. The rating is a
|
||||
// hard ceiling, so the shown estimate is capped at 6h (360), not 600.
|
||||
val stored = mapOf(
|
||||
"p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.10f), "UNKNOWN/RIGHT" to learned(0.10f)))
|
||||
)
|
||||
val result = collectEstimate(
|
||||
estimator(
|
||||
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2))),
|
||||
stored = stored,
|
||||
)
|
||||
)
|
||||
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
|
||||
left.source shouldBe BatteryEstimate.Source.LEARNED
|
||||
left.minutesRemaining shouldBe 360
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a live rate slower than the rating is capped to the rating but stays LIVE`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// Measured 15%/hr on an AirPods Pro (rated 4.5h == ~22%/hr): draining slower than Apple rates,
|
||||
// so the shown life is capped to the 4.5h rating. At 0.76 that's 0.76 / (1/4.5) * 60 == 205
|
||||
// (not the ~304 the raw 15%/hr would imply). The estimate is still measured, so source == LIVE.
|
||||
val emissions = (0 until 5).map { i ->
|
||||
val level = 0.80f - i * 0.01f
|
||||
listOf(device("p1", left = level, right = level, model = PodModel.AIRPODS_PRO))
|
||||
}
|
||||
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
|
||||
left.source shouldBe BatteryEstimate.Source.LIVE
|
||||
left.minutesRemaining shouldBe 205
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an implausibly fast live rate is rejected in favour of the rating`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// 5%/4min == 75%/hr, far beyond 4x the Pro 2 rating (~67%/hr max plausible), so the live fit
|
||||
// is discarded and the estimate falls back to the model rating.
|
||||
val emissions = (0 until 5).map { i ->
|
||||
val level = 0.80f - i * 0.05f
|
||||
listOf(device("p1", left = level, right = level, model = PodModel.AIRPODS_PRO2))
|
||||
}
|
||||
val left = collectEstimate(estimator(emissions))["p1"].shouldNotBeNull().left.shouldNotBeNull()
|
||||
left.source shouldBe BatteryEstimate.Source.SPEC
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a device with the estimate disabled is not sampled`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// A clean steady discharge that WOULD yield a live estimate — but the feature is off.
|
||||
val emissions = (0 until 5).map { i ->
|
||||
val level = 0.80f - i * 0.01f
|
||||
listOf(device("p1", left = level, right = level, estimateEnabled = false))
|
||||
}
|
||||
collectEstimate(estimator(emissions)) shouldBe emptyMap()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset deletes persisted data and drops the estimate`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val drainStore = mockk<BatteryDrainStore> {
|
||||
every { profiles } returns MutableStateFlow(
|
||||
mapOf("p1" to DrainProfile(rates = mapOf("UNKNOWN/LEFT" to learned(0.15f))))
|
||||
)
|
||||
coEvery { save(any(), any()) } returns Unit
|
||||
coEvery { delete(any()) } returns Unit
|
||||
}
|
||||
val deviceMonitor = mockk<DeviceMonitor> { every { devices } returns flowOf(emptyList()) }
|
||||
val timeSource = mockk<TimeSource> {
|
||||
every { elapsedRealtime() } returns 0L
|
||||
every { now() } returns now
|
||||
}
|
||||
val estimator = BatteryEstimator(deviceMonitor, drainStore, timeSource)
|
||||
|
||||
estimator.reset("p1")
|
||||
|
||||
coVerify { drainStore.delete("p1") }
|
||||
estimator.estimates.value.containsKey("p1") shouldBe false
|
||||
}
|
||||
|
||||
private fun learned(rate: Float) = DrainProfile.LearnedRate(
|
||||
fractionPerHour = rate,
|
||||
sampleCount = 5,
|
||||
|
||||
@@ -94,6 +94,14 @@ class DrainModelTest : BaseTest() {
|
||||
DrainModel.blendMinutes(previous = 100, next = 200, alpha = 0.3f) shouldBe 130
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blendMinutes reacts faster to drops than to rises`() {
|
||||
// Drop (less time left): fast factor so we stop over-promising quickly. 0.6*180 + 0.4*360 = 252
|
||||
DrainModel.blendMinutes(previous = 360, next = 180) shouldBe 252
|
||||
// Rise (more time / noise): gentle factor so we don't jump up. 0.25*200 + 0.75*100 = 125
|
||||
DrainModel.blendMinutes(previous = 100, next = 200) shouldBe 125
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blendRate seeds then smooths`() {
|
||||
DrainModel.blendRate(previous = null, next = 0.2f) shouldBe 0.2f
|
||||
|
||||
@@ -117,6 +117,44 @@ class ModelFeaturesTest : BaseTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `battery specs are populated for exactly the Apple models`() {
|
||||
PodModel.entries.filter { it.batterySpec != null }.toSet() shouldBe batterySpecModels
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ANC-capable models with a rating publish an ANC-on figure`() {
|
||||
PodModel.entries
|
||||
.filter { it.batterySpec != null && it.features.hasAncControl }
|
||||
.forEach { model ->
|
||||
withClue(model.name) {
|
||||
(model.batterySpec?.listeningHoursAncOn != null) shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every battery spec has at least one rating`() {
|
||||
PodModel.entries.mapNotNull { it.batterySpec }.forEach { spec ->
|
||||
withClue(spec.toString()) {
|
||||
(spec.listeningHoursAncOn != null || spec.listeningHoursAncOff != null) shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `battery ratings are single-charge pod figures within a sane range`() {
|
||||
// Guards against accidentally using Apple's "with charging case" aggregate (e.g. 30h).
|
||||
PodModel.entries.forEach { model ->
|
||||
val spec = model.batterySpec ?: return@forEach
|
||||
listOfNotNull(spec.listeningHoursAncOn, spec.listeningHoursAncOff).forEach { hours ->
|
||||
withClue("${model.name}: $hours") {
|
||||
(hours in 1f..24f) shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun modelsWith(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
|
||||
.filter { predicate(it.features) }
|
||||
.toSet()
|
||||
@@ -344,6 +382,21 @@ class ModelFeaturesTest : BaseTest() {
|
||||
PodModel.POWERBEATS_PRO2,
|
||||
)
|
||||
|
||||
private val batterySpecModels = setOf(
|
||||
PodModel.AIRPODS_GEN1,
|
||||
PodModel.AIRPODS_GEN2,
|
||||
PodModel.AIRPODS_GEN3,
|
||||
PodModel.AIRPODS_GEN4,
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.AIRPODS_MAX,
|
||||
PodModel.AIRPODS_MAX_USBC,
|
||||
PodModel.AIRPODS_MAX2,
|
||||
)
|
||||
|
||||
private val featureExpectations = listOf(
|
||||
feature("hasDualPods", { it.hasDualPods }, dualPodModels),
|
||||
feature("hasCase", { it.hasCase }, dualPodModels),
|
||||
|
||||
+23
@@ -27,6 +27,29 @@ class AppleDeviceProfileSerializationTest : BaseTest() {
|
||||
profile.reactionConfig.chargedSlotScope shouldBe ChargedSlotScope.PODS_AND_CASE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profiles stored before the battery estimate toggle default to enabled`() {
|
||||
val legacyJson = """
|
||||
{
|
||||
"id": "test-id",
|
||||
"label": "My Pods"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val profile = json.decodeFromString<AppleDeviceProfile>(legacyJson)
|
||||
|
||||
profile.batteryEstimateEnabled shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `battery estimate toggle round-trips`() {
|
||||
val profile = AppleDeviceProfile(label = "My Pods", batteryEstimateEnabled = false)
|
||||
|
||||
val decoded = json.decodeFromString<AppleDeviceProfile>(json.encodeToString(profile))
|
||||
|
||||
decoded.batteryEstimateEnabled shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `charged reaction settings round-trip`() {
|
||||
val profile = AppleDeviceProfile(
|
||||
|
||||
Reference in New Issue
Block a user