feat(reaction): Add charged notification

This commit is contained in:
darken
2026-06-10 18:13:51 +02:00
committed by Matthias Urhahn
parent 380397321c
commit 2804543b9d
17 changed files with 1707 additions and 0 deletions
@@ -0,0 +1,46 @@
package eu.darken.capod.profiles.core
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class AppleDeviceProfileSerializationTest : BaseTest() {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `profiles stored before the charged reaction decode with defaults`() {
val legacyJson = """
{
"id": "test-id",
"label": "My Pods"
}
""".trimIndent()
val profile = json.decodeFromString<AppleDeviceProfile>(legacyJson)
profile.notifyWhenCharged shouldBe false
profile.chargedThreshold shouldBe ReactionConfig.DEFAULT_CHARGED_THRESHOLD
profile.chargedSlotScope shouldBe ChargedSlotScope.PODS_AND_CASE
profile.reactionConfig.chargedSlotScope shouldBe ChargedSlotScope.PODS_AND_CASE
}
@Test
fun `charged reaction settings round-trip`() {
val profile = AppleDeviceProfile(
label = "My Pods",
notifyWhenCharged = true,
chargedThreshold = 80,
chargedSlotScope = ChargedSlotScope.PODS,
)
val decoded = json.decodeFromString<AppleDeviceProfile>(json.encodeToString(profile))
decoded.notifyWhenCharged shouldBe true
decoded.chargedThreshold shouldBe 80
decoded.chargedSlotScope shouldBe ChargedSlotScope.PODS
decoded.reactionConfig shouldBe profile.reactionConfig
}
}
@@ -0,0 +1,175 @@
package eu.darken.capod.reaction.core.charged
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.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class ChargedReactionTest : BaseTest() {
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
private lateinit var deviceMonitor: DeviceMonitor
private lateinit var profilesRepo: DeviceProfilesRepo
@BeforeEach
fun setup() {
profilesFlow = MutableStateFlow(emptyList())
devicesFlow = MutableStateFlow(emptyList())
deviceMonitor = mockk(relaxed = true) { every { devices } returns devicesFlow }
profilesRepo = mockk(relaxed = true) { every { profiles } returns profilesFlow }
}
private fun reaction() = ChargedReaction(deviceMonitor, profilesRepo)
private fun profile(
id: String = "p1",
model: PodModel = PodModel.AIRPODS_PRO2_USBC,
notify: Boolean = true,
threshold: Int = 80,
scope: ChargedSlotScope = ChargedSlotScope.PODS_AND_CASE,
) = AppleDeviceProfile(
id = id,
label = "Test",
model = model,
notifyWhenCharged = notify,
chargedThreshold = threshold,
chargedSlotScope = scope,
)
private fun aap(vararg slots: Pair<AapPodState.BatteryType, Pair<Float, AapPodState.ChargingState>>) =
AapPodState(
connectionState = AapPodState.ConnectionState.READY,
batteries = slots.associate { (type, v) ->
type to AapPodState.Battery(type, v.first, v.second)
},
)
private fun device(
id: String = "p1",
model: PodModel = PodModel.AIRPODS_PRO2_USBC,
aap: AapPodState,
) = PodDevice(profileId = id, ble = null, aap = aap, profileModel = model)
private fun bothPodsCharging(percent: Float, charging: AapPodState.ChargingState = AapPodState.ChargingState.CHARGING) =
aap(
AapPodState.BatteryType.LEFT to (percent to charging),
AapPodState.BatteryType.RIGHT to (percent to charging),
)
@Test
fun `fires once when all session slots reach the threshold`() = runTest(UnconfinedTestDispatcher()) {
val events = mutableListOf<ChargedReaction.Event>()
profilesFlow.value = listOf(profile())
devicesFlow.value = listOf(device(aap = bothPodsCharging(0.7f)))
val job = reaction().monitor().onEach { events.add(it) }.launchIn(this)
runCurrent()
devicesFlow.value = listOf(device(aap = bothPodsCharging(0.9f)))
runCurrent()
events.filterIsInstance<ChargedReaction.Event.ShowNotification>().size shouldBe 1
job.cancel()
}
@Test
fun `disabling the toggle cancels the notification`() = runTest(UnconfinedTestDispatcher()) {
val events = mutableListOf<ChargedReaction.Event>()
profilesFlow.value = listOf(profile())
devicesFlow.value = listOf(device(aap = bothPodsCharging(0.9f)))
val job = reaction().monitor().onEach { events.add(it) }.launchIn(this)
runCurrent()
events.filterIsInstance<ChargedReaction.Event.ShowNotification>().size shouldBe 1
profilesFlow.value = listOf(profile(notify = false))
runCurrent()
events.filterIsInstance<ChargedReaction.Event.CancelNotification>().size shouldBe 1
job.cancel()
}
@Test
fun `scope change resets a fired session and cancels`() = runTest(UnconfinedTestDispatcher()) {
val events = mutableListOf<ChargedReaction.Event>()
profilesFlow.value = listOf(profile(scope = ChargedSlotScope.PODS_AND_CASE))
devicesFlow.value = listOf(device(aap = bothPodsCharging(0.9f)))
val job = reaction().monitor().onEach { events.add(it) }.launchIn(this)
runCurrent()
events.filterIsInstance<ChargedReaction.Event.ShowNotification>().size shouldBe 1
// Switch to CASE scope: the device has no case slot, so after the reset nothing
// qualifies and the notification must come down (and not re-fire).
profilesFlow.value = listOf(profile(scope = ChargedSlotScope.CASE))
runCurrent()
events.filterIsInstance<ChargedReaction.Event.CancelNotification>().size shouldBe 1
// No re-fire under the new scope (device has no case slot).
events.filterIsInstance<ChargedReaction.Event.ShowNotification>().size shouldBe 1
job.cancel()
}
@Test
fun `deleted profile cancels its notification`() = runTest(UnconfinedTestDispatcher()) {
val events = mutableListOf<ChargedReaction.Event>()
profilesFlow.value = listOf(profile())
devicesFlow.value = listOf(device(aap = bothPodsCharging(0.9f)))
val job = reaction().monitor().onEach { events.add(it) }.launchIn(this)
runCurrent()
events.filterIsInstance<ChargedReaction.Event.ShowNotification>().size shouldBe 1
profilesFlow.value = emptyList()
runCurrent()
events.filterIsInstance<ChargedReaction.Event.CancelNotification>().size shouldBe 1
job.cancel()
}
@Test
fun `caseless model normalizes a stored CASE scope to pods and still fires`() =
runTest(UnconfinedTestDispatcher()) {
val events = mutableListOf<ChargedReaction.Event>()
// AirPods Max has no case; a restored CASE scope must degrade to PODS or the
// headset slot would be filtered out and the session could never complete.
profilesFlow.value = listOf(
profile(model = PodModel.AIRPODS_MAX, scope = ChargedSlotScope.CASE),
)
devicesFlow.value = listOf(
device(
model = PodModel.AIRPODS_MAX,
aap = aap(AapPodState.BatteryType.SINGLE to (0.9f to AapPodState.ChargingState.CHARGING)),
),
)
val job = reaction().monitor().onEach { events.add(it) }.launchIn(this)
runCurrent()
events.filterIsInstance<ChargedReaction.Event.ShowNotification>().size shouldBe 1
job.cancel()
}
@Test
fun `non-enabled profiles never emit`() = runTest(UnconfinedTestDispatcher()) {
val events = mutableListOf<ChargedReaction.Event>()
profilesFlow.value = listOf(profile(notify = false))
devicesFlow.value = listOf(device(aap = bothPodsCharging(0.9f)))
val job = reaction().monitor().onEach { events.add(it) }.launchIn(this)
runCurrent()
events.size shouldBe 0
job.cancel()
}
}
@@ -0,0 +1,136 @@
package eu.darken.capod.reaction.core.charged
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Slot
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class ChargingActivityMapperTest : BaseTest() {
private fun aapWith(
primary: AapSetting.EarDetection.PodPlacement,
secondary: AapSetting.EarDetection.PodPlacement,
primaryPod: AapSetting.PrimaryPod.Pod = AapSetting.PrimaryPod.Pod.LEFT,
) = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = primary,
secondaryPod = secondary,
),
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(primaryPod),
),
)
@Test
fun `BLE-only in-ear is ignored as phantom for in-case pods`() {
// Pods report in-ear over BLE but there's no AAP session — the normal in-case charging
// state. Worn must be unknown so phantom/flapping BLE bits can't dismiss the notification.
val device = PodDevice(
profileId = "p",
ble = MockPodDataProvider.airPodsGen1Wearing(),
aap = null,
)
device.chargingActivity().wornSlots shouldBe null
}
@Test
fun `AAP ear detection is trusted`() {
val device = PodDevice(
profileId = "p",
ble = MockPodDataProvider.airPodsGen1Wearing(),
aap = aapWith(
primary = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondary = AapSetting.EarDetection.PodPlacement.IN_CASE,
),
)
// Both pods worn over BLE, but AAP says only the primary (LEFT) is in-ear — AAP wins.
device.chargingActivity().wornSlots shouldBe setOf(Slot.LEFT)
}
@Test
fun `AAP reporting nothing worn yields an empty set, not null`() {
val device = PodDevice(
profileId = "p",
ble = MockPodDataProvider.airPodsGen1Wearing(),
aap = aapWith(
primary = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondary = AapSetting.EarDetection.PodPlacement.IN_CASE,
),
)
device.chargingActivity().wornSlots shouldBe emptySet<Slot>()
}
private fun chargingAap(vararg slots: AapPodState.BatteryType) = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
batteries = slots.associateWith {
AapPodState.Battery(it, 0.9f, AapPodState.ChargingState.CHARGING)
},
)
@Test
fun `PODS scope excludes the case slot`() {
val device = PodDevice(
profileId = "p",
ble = null,
aap = chargingAap(
AapPodState.BatteryType.LEFT,
AapPodState.BatteryType.RIGHT,
AapPodState.BatteryType.CASE,
),
)
device.liveChargingSlots(ChargedSlotScope.PODS).keys shouldBe setOf(Slot.LEFT, Slot.RIGHT)
}
@Test
fun `CASE scope keeps only the case slot`() {
val device = PodDevice(
profileId = "p",
ble = null,
aap = chargingAap(
AapPodState.BatteryType.LEFT,
AapPodState.BatteryType.RIGHT,
AapPodState.BatteryType.CASE,
),
)
device.liveChargingSlots(ChargedSlotScope.CASE).keys shouldBe setOf(Slot.CASE)
}
@Test
fun `PODS_AND_CASE scope keeps every charging slot`() {
val device = PodDevice(
profileId = "p",
ble = null,
aap = chargingAap(
AapPodState.BatteryType.LEFT,
AapPodState.BatteryType.RIGHT,
AapPodState.BatteryType.CASE,
),
)
device.liveChargingSlots(ChargedSlotScope.PODS_AND_CASE).keys shouldBe
setOf(Slot.LEFT, Slot.RIGHT, Slot.CASE)
}
@Test
fun `headset counts as a pod slot under PODS scope`() {
val device = PodDevice(
profileId = "p",
ble = null,
aap = chargingAap(AapPodState.BatteryType.SINGLE),
)
device.liveChargingSlots(ChargedSlotScope.PODS).keys shouldBe setOf(Slot.HEADSET)
// ...and CASE scope yields nothing for a headset device.
device.liveChargingSlots(ChargedSlotScope.CASE).keys shouldBe emptySet<Slot>()
}
}
@@ -0,0 +1,540 @@
package eu.darken.capod.reaction.core.charged
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Activity
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Input
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Lid
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Output
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Phase
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Slot
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.SlotData
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class ChargingSessionStateMachineTest : BaseTest() {
private val machine = ChargingSessionStateMachine()
private fun live(threshold: Float = 1.0f, vararg slots: Pair<Slot, SlotData>) =
Input.LiveUpdate(slots = slots.toMap(), threshold = threshold)
private fun charging(battery: Float) = SlotData(battery = battery, isCharging = true)
private fun idle(battery: Float) = SlotData(battery = battery, isCharging = false)
@Test
fun `no charging slots keeps machine idle`() {
machine.process(live(1.0f, Slot.LEFT to idle(0.5f))) shouldBe Output.NONE
machine.phase shouldBe Phase.IDLE
}
@Test
fun `dual pods and case charge to full fires once`() {
machine.process(
live(1.0f, Slot.LEFT to charging(0.5f), Slot.RIGHT to charging(0.6f), Slot.CASE to charging(0.7f))
) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
machine.process(
live(1.0f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(1.0f), Slot.CASE to charging(1.0f))
) shouldBe Output.NONE
machine.process(
live(1.0f, Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f), Slot.CASE to charging(1.0f))
) shouldBe Output.SHOW
machine.phase shouldBe Phase.FIRED
// Same data again — no duplicate fire.
machine.process(
live(1.0f, Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f), Slot.CASE to charging(1.0f))
) shouldBe Output.NONE
}
@Test
fun `slot stopping to charge at threshold counts as complete not unplug`() {
machine.process(live(1.0f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f)))
// Firmware flips isCharging off once a pod hits 100%.
machine.process(live(1.0f, Slot.LEFT to idle(1.0f), Slot.RIGHT to charging(0.9f))) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
machine.process(live(1.0f, Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f))) shouldBe Output.SHOW
}
@Test
fun `slot stopping to charge below threshold resets the session`() {
machine.process(live(1.0f, Slot.LEFT to charging(0.5f)))
machine.process(live(1.0f, Slot.LEFT to idle(0.6f))) shouldBe Output.NONE
machine.phase shouldBe Phase.IDLE
}
@Test
fun `notification cancels when battery discharges below threshold after firing`() {
machine.process(live(1.0f, Slot.HEADSET to charging(0.99f)))
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
// Charging flag dropping at full is NOT an unplug signal — notification stays.
machine.process(live(1.0f, Slot.HEADSET to idle(1.0f))) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
// Battery dropping below threshold proves the device is off power and in use.
machine.process(live(1.0f, Slot.HEADSET to idle(0.97f))) shouldBe Output.CANCEL
machine.phase shouldBe Phase.IDLE
}
@Test
fun `new charging session after firing cancels and re-arms`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
machine.process(live(1.0f, Slot.HEADSET to charging(0.4f))) shouldBe Output.CANCEL
machine.phase shouldBe Phase.IDLE
machine.process(live(1.0f, Slot.HEADSET to charging(0.4f))) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `idle non-session slot below threshold does not cancel after firing`() {
// Regression: pods charging in an open case whose own battery sits below the threshold
// but isn't charging. The case carries no signal and must not flap SHOW/CANCEL.
machine.process(
live(0.6f, Slot.LEFT to charging(0.7f), Slot.RIGHT to charging(0.7f), Slot.CASE to idle(0.45f))
) shouldBe Output.SHOW
machine.process(
live(0.6f, Slot.LEFT to charging(0.7f), Slot.RIGHT to charging(0.7f), Slot.CASE to idle(0.45f))
) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
}
@Test
fun `case starting to charge below threshold after firing starts a new session`() {
machine.process(live(1.0f, Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f))) shouldBe Output.SHOW
machine.process(
live(1.0f, Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f), Slot.CASE to charging(0.5f))
) shouldBe Output.CANCEL
machine.process(
live(1.0f, Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f), Slot.CASE to charging(1.0f))
) shouldBe Output.SHOW
}
@Test
fun `re-plug near full after firing cancels then fires on the next update`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
// Re-plugged while slightly drained: the old notification is cancelled first…
machine.process(live(1.0f, Slot.HEADSET to charging(0.95f))) shouldBe Output.CANCEL
// …and the new session starts (and can fire) on the following update.
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `custom threshold uses at-least semantics across coarse jumps`() {
// Public BLE reports in 10% steps; 0.7 → 0.9 may skip the 0.8 threshold entirely.
machine.process(live(0.8f, Slot.LEFT to charging(0.7f), Slot.RIGHT to charging(0.7f)))
machine.process(live(0.8f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f))) shouldBe Output.SHOW
}
@Test
fun `slot joining mid-session must also reach threshold`() {
machine.process(live(1.0f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f)))
machine.process(
live(
1.0f,
Slot.LEFT to charging(1.0f),
Slot.RIGHT to charging(1.0f),
Slot.CASE to charging(0.5f),
)
) shouldBe Output.NONE
machine.process(
live(1.0f, Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f), Slot.CASE to charging(1.0f))
) shouldBe Output.SHOW
}
@Test
fun `stale data suspends and resuming continues the session`() {
machine.process(live(1.0f, Slot.HEADSET to charging(0.8f)))
machine.process(Input.StaleUpdate) shouldBe Output.NONE
machine.phase shouldBe Phase.SUSPENDED
machine.process(Input.StaleUpdate) shouldBe Output.NONE
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `suspend after firing keeps notification and cancels on discharged resume`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
machine.process(Input.StaleUpdate) shouldBe Output.NONE
machine.process(live(1.0f, Slot.HEADSET to idle(0.8f))) shouldBe Output.CANCEL
machine.phase shouldBe Phase.IDLE
}
@Test
fun `battery regression during suspension starts a fresh session`() {
machine.process(live(0.8f, Slot.HEADSET to charging(0.75f)))
machine.process(Input.StaleUpdate)
// Device was unplugged and used while we were blind: battery well below the highwater.
machine.process(live(0.8f, Slot.HEADSET to charging(0.4f))) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
// The old 0.75 highwater is gone — 0.4 must climb to 0.8 again before firing.
machine.process(live(0.8f, Slot.HEADSET to charging(0.7f))) shouldBe Output.NONE
machine.process(live(0.8f, Slot.HEADSET to charging(0.8f))) shouldBe Output.SHOW
}
@Test
fun `single step BLE flicker does not count as regression`() {
machine.process(live(1.0f, Slot.HEADSET to charging(0.8f)))
machine.process(Input.StaleUpdate)
// Exactly one public-BLE step (0.1) below the mark — tolerated, session continues.
machine.process(live(1.0f, Slot.HEADSET to charging(0.7f))) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `session slot missing from an update neither completes nor unplugs`() {
machine.process(live(1.0f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f)))
// Right slot vanishes (e.g. AAP only pushed a partial battery update).
machine.process(live(1.0f, Slot.LEFT to charging(1.0f))) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
machine.process(live(1.0f, Slot.LEFT to idle(1.0f), Slot.RIGHT to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `reset before firing is silent`() {
machine.process(live(1.0f, Slot.HEADSET to charging(0.5f)))
machine.process(Input.Reset) shouldBe Output.NONE
machine.phase shouldBe Phase.IDLE
}
@Test
fun `reset after firing cancels the notification`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
machine.process(Input.Reset) shouldBe Output.CANCEL
}
@Test
fun `reset while suspended from fired cancels the notification`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
machine.process(Input.StaleUpdate)
machine.process(Input.Reset) shouldBe Output.CANCEL
}
@Test
fun `reset while suspended from charging is silent`() {
machine.process(live(1.0f, Slot.HEADSET to charging(0.5f)))
machine.process(Input.StaleUpdate)
machine.process(Input.Reset) shouldBe Output.NONE
}
@Test
fun `already full slot fires immediately when charging starts`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `wearing a pod after firing dismisses without re-firing`() {
machine.process(live(1.0f, Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f))) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = setOf(Slot.LEFT)),
)
) shouldBe Output.CANCEL
machine.phase shouldBe Phase.DISMISSED
// Still full, pod back in the charging case — latched, no second notification.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f)),
threshold = 1.0f,
)
) shouldBe Output.NONE
machine.phase shouldBe Phase.DISMISSED
}
@Test
fun `lid movement after firing dismisses`() {
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.OPEN),
)
) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.OPEN),
)
) shouldBe Output.NONE
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.CLOSED),
)
) shouldBe Output.CANCEL
machine.phase shouldBe Phase.DISMISSED
}
@Test
fun `lid unknown at fire becomes baseline instead of dismissing`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
// First definite lid sighting after fire establishes the baseline silently…
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to idle(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.OPEN),
)
) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
// …and only a subsequent change dismisses.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to idle(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.CLOSED),
)
) shouldBe Output.CANCEL
}
@Test
fun `pod taken out of case after firing dismisses`() {
// BLE-only path: removing a pod doesn't produce an in-ear or AAP DISCONNECTED signal,
// but the out-of-case pod broadcasts lid state NOT_IN_CASE — that change is activity.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.OPEN),
)
) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to idle(1.0f), Slot.RIGHT to charging(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.NOT_IN_CASE),
)
) shouldBe Output.CANCEL
machine.phase shouldBe Phase.DISMISSED
}
@Test
fun `session slot going disconnected after firing dismisses`() {
machine.process(live(1.0f, Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f))) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(disconnectedSlots = setOf(Slot.LEFT)),
)
) shouldBe Output.CANCEL
machine.phase shouldBe Phase.DISMISSED
}
@Test
fun `worn while charging at fire time does not insta-dismiss`() {
// AirPods Max can be worn while cable-charging: worn when the notification fires and
// staying worn is not activity — taking them off is.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to charging(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = setOf(Slot.HEADSET)),
)
) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to charging(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = setOf(Slot.HEADSET)),
)
) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to idle(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = emptySet()),
)
) shouldBe Output.CANCEL
machine.phase shouldBe Phase.DISMISSED
}
@Test
fun `steady one-pod wear with lid flapping never dismisses but second pod does`() {
// The hardware scenario: LEFT worn, RIGHT charging in the open case. Both pods
// broadcast, dedup alternates their frames, so the lid flaps OPEN/NOT_IN_CASE and
// the worn set stays {LEFT}. None of that is new activity after the fire.
fun frame(lid: Lid, battery: Float) = Input.LiveUpdate(
slots = mapOf(Slot.RIGHT to charging(battery)),
threshold = 0.8f,
activity = Activity(wornSlots = setOf(Slot.LEFT), lid = lid),
)
machine.process(frame(Lid.OPEN, 0.6f)) shouldBe Output.NONE
machine.process(frame(Lid.NOT_IN_CASE, 0.7f)) shouldBe Output.NONE
machine.process(frame(Lid.OPEN, 0.7f)) shouldBe Output.NONE
machine.process(frame(Lid.NOT_IN_CASE, 0.8f)) shouldBe Output.SHOW
// Post-fire flapping continues — both lid values are in the union baseline.
machine.process(frame(Lid.OPEN, 0.8f)) shouldBe Output.NONE
machine.process(frame(Lid.NOT_IN_CASE, 0.8f)) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
// Wearing the freshly charged second pod is new activity.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.RIGHT to idle(0.8f)),
threshold = 0.8f,
activity = Activity(wornSlots = setOf(Slot.LEFT, Slot.RIGHT), lid = Lid.NOT_IN_CASE),
)
) shouldBe Output.CANCEL
machine.phase shouldBe Phase.DISMISSED
}
@Test
fun `instant fire after a reset keeps the lid alternation in its baseline`() {
// Step 3 hardware path: left worn, right charging, user lowers the threshold to an
// already-reached value. The lid alternates OPEN/NOT_IN_CASE the whole time; the reset
// must not throw away that window or the next (instant) fire flaps itself away.
fun frame(lid: Lid, charging: Boolean) = Input.LiveUpdate(
slots = mapOf(Slot.RIGHT to SlotData(0.9f, charging)),
threshold = 0.8f,
activity = Activity(wornSlots = setOf(Slot.LEFT), lid = lid),
)
// Ambient alternation observed before the settings change.
machine.process(frame(Lid.OPEN, true))
machine.process(frame(Lid.NOT_IN_CASE, true))
machine.process(frame(Lid.OPEN, true))
machine.process(Input.Reset) shouldBe Output.NONE
// Instant fire on the next frame after the reset.
machine.process(frame(Lid.NOT_IN_CASE, true)) shouldBe Output.SHOW
// The alternate lid value must already be in the baseline — no false dismiss.
machine.process(frame(Lid.OPEN, true)) shouldBe Output.NONE
machine.process(frame(Lid.NOT_IN_CASE, true)) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
}
@Test
fun `lid closing still dismisses despite flap-tolerant baseline`() {
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f)),
threshold = 1.0f,
activity = Activity(lid = Lid.OPEN),
)
) shouldBe Output.NONE
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.OPEN),
)
) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.CLOSED),
)
) shouldBe Output.CANCEL
}
@Test
fun `case-only session ignores worn pods but dismisses on lid movement`() {
// Scope=CASE feeds only the case slot; wearing pods says nothing about the case charge.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.CASE to charging(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = emptySet(), lid = Lid.OPEN),
)
) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.CASE to charging(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = setOf(Slot.LEFT, Slot.RIGHT), lid = Lid.OPEN),
)
) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.CASE to charging(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.CLOSED),
)
) shouldBe Output.CANCEL
}
@Test
fun `slot disconnected in the firing frame is baseline not activity`() {
machine.process(live(1.0f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f)))
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(disconnectedSlots = setOf(Slot.LEFT)),
)
) shouldBe Output.SHOW
// LEFT was already out at fire time — only RIGHT leaving the case is new activity.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(disconnectedSlots = setOf(Slot.LEFT)),
)
) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(disconnectedSlots = setOf(Slot.LEFT, Slot.RIGHT)),
)
) shouldBe Output.CANCEL
}
@Test
fun `dismissed re-arms through a new below-threshold charge`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to idle(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = setOf(Slot.HEADSET)),
)
) shouldBe Output.CANCEL
// Drained and plugged back in → fresh session that can fire again.
machine.process(live(1.0f, Slot.HEADSET to charging(0.5f))) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `dismissed survives staleness and resets silently`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to idle(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = setOf(Slot.HEADSET)),
)
) shouldBe Output.CANCEL
machine.process(Input.StaleUpdate) shouldBe Output.NONE
machine.process(live(1.0f, Slot.HEADSET to idle(1.0f))) shouldBe Output.NONE
machine.phase shouldBe Phase.DISMISSED
machine.process(Input.Reset) shouldBe Output.NONE
}
@Test
fun `non-charging slots do not join the session`() {
// Case sits at 40% but isn't charging — only the pods gate the notification.
machine.process(
live(1.0f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f), Slot.CASE to idle(0.4f))
)
machine.process(
live(1.0f, Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f), Slot.CASE to idle(0.4f))
) shouldBe Output.SHOW
}
}