fix(device-settings): Fix ANC OFF mode visibility and inference logic

Fix Elvis operator precedence bug in visibleAncModes() where OFF passed the filter unconditionally. Move filtering logic to PodDevice.visibleAncModes extension, unifying DualPodsCard, SinglePodsCard, and DeviceSettingsScreen. Gate AllowOffOption inference on pod-in-ear + 1.5s stability to prevent false positives from in-case OFF reports.
This commit is contained in:
darken
2026-04-16 10:32:21 +02:00
committed by Matthias Urhahn
parent 789c23d24c
commit 14da81d231
8 changed files with 343 additions and 62 deletions
@@ -0,0 +1,95 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class PodDeviceAncModeTest : BaseTest() {
private val allModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
)
@Test
fun `cycle mask hides OFF when OFF is not allowed`() {
visibleAncModes(
supportedModes = allModes,
currentMode = AapSetting.AncMode.Value.ON,
cycleMask = 0x0E,
allowOffEnabled = false,
) shouldContainExactly listOf(
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
)
}
@Test
fun `allow off keeps OFF visible even when cycle mask excludes it`() {
visibleAncModes(
supportedModes = allModes,
currentMode = AapSetting.AncMode.Value.ON,
cycleMask = 0x0E,
allowOffEnabled = true,
) shouldContainExactly allModes
}
@Test
fun `current OFF stays visible even when OFF is otherwise hidden`() {
visibleAncModes(
supportedModes = allModes,
currentMode = AapSetting.AncMode.Value.OFF,
cycleMask = 0x0E,
allowOffEnabled = false,
) shouldContainExactly allModes
}
@Test
fun `null cycle mask shows all supported modes`() {
visibleAncModes(
supportedModes = allModes,
currentMode = AapSetting.AncMode.Value.ON,
cycleMask = null,
allowOffEnabled = false,
) shouldContainExactly allModes
}
@Test
fun `cycle mask with OFF bit set includes OFF`() {
visibleAncModes(
supportedModes = allModes,
currentMode = AapSetting.AncMode.Value.ON,
cycleMask = 0x0F,
allowOffEnabled = false,
) shouldContainExactly allModes
}
@Test
fun `models with listening mode cycle fall back to default mask when setting is absent`() {
resolvedAncCycleMask(
hasListeningModeCycle = true,
reportedCycleMask = null,
) shouldBe 0x0E
}
@Test
fun `models without listening mode cycle keep cycle mask null`() {
resolvedAncCycleMask(
hasListeningModeCycle = false,
reportedCycleMask = null,
) shouldBe null
}
@Test
fun `reported cycle mask wins over fallback`() {
resolvedAncCycleMask(
hasListeningModeCycle = true,
reportedCycleMask = 0x0A,
) shouldBe 0x0A
}
}
@@ -40,6 +40,10 @@ class AapSessionEngineTest : BaseTest() {
return AapMessage(raw = raw, commandType = commandType, payload = ByteArray(0))
}
@Suppress("UNCHECKED_CAST")
private fun settingPair(setting: AapSetting): Pair<KClass<out AapSetting>, AapSetting> =
setting::class as KClass<out AapSetting> to setting
/** Build a profile mock with all decode methods stubbed. Does NOT mock encodeCommand (sealed class). */
private fun mockProfile(block: AapDeviceProfile.() -> Unit = {}): AapDeviceProfile = mockk {
every { decodeStemPress(any()) } returns null
@@ -356,26 +360,159 @@ class AapSessionEngineTest : BaseTest() {
inner class InferenceTests {
@Test
fun `AncMode OFF infers AllowOffOption true`() {
fun `startup OFF while no pod is in ear does not infer AllowOffOption true`() = runTest(UnconfinedTestDispatcher()) {
val supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.ADAPTIVE,
)
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
val profile = mockProfile {
every { decodeStemPress(any()) } returns null
every { decodeBattery(any()) } returns null
every { decodePrivateKeyResponse(any()) } returns null
every { decodeDeviceInfo(any()) } returns null
every { decodeSetting(any()) } returns (AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON),
))
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.start(this as TestScope)
engine.onHandshakeSent()
// First ANC mode = no debounce, applied immediately
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = supportedModes,
))
engine.processMessage(dummyMessage(commandType = 0x0002))
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
))
engine.processMessage(dummyMessage())
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
}
@Test
fun `stable in-ear OFF infers AllowOffOption true after delay`() = runTest(UnconfinedTestDispatcher()) {
val supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.ADAPTIVE,
)
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
engine.start(this as TestScope)
engine.onHandshakeSent()
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
))
engine.processMessage(dummyMessage(commandType = 0x0002))
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>()?.enabled shouldBe true
}
@Test
fun `later non-OFF ANC update cancels pending AllowOffOption true inference`() = runTest(UnconfinedTestDispatcher()) {
val supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.ADAPTIVE,
)
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
engine.start(this as TestScope)
engine.onHandshakeSent()
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
))
engine.processMessage(dummyMessage(commandType = 0x0002))
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
engine.state.value.setting<AapSetting.AllowOffOption>()?.enabled shouldBe true
advanceTimeBy(500L)
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
}
@Test
fun `rejected OFF command infers AllowOffOption false`() = runTest(UnconfinedTestDispatcher()) {
val supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.ADAPTIVE,
)
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
engine.startReady(this as TestScope)
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
))
engine.processMessage(dummyMessage())
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
nextSetting = settingPair(AapSetting.AllowOffOption(enabled = true))
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { sentCommands += it }
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
advanceTimeBy(2100L)
engine.state.value.setting<AapSetting.AllowOffOption>()?.enabled shouldBe false
sentCommands shouldBe listOf(
AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF),
AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF),
)
}
}