Refactor packages

This commit is contained in:
darken
2026-04-17 13:02:14 +02:00
committed by Matthias Urhahn
parent f141ce3f2a
commit 74e48e79eb
16 changed files with 568 additions and 270 deletions
@@ -1,11 +1,13 @@
package eu.darken.capod.pods.core.apple.aap
package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
import kotlin.reflect.KClass
class AapAncControllerTest : BaseTest() {
@@ -17,7 +19,7 @@ class AapAncControllerTest : BaseTest() {
AapSetting.AncMode.Value.ADAPTIVE,
)
private fun podStateWith(vararg settings: Pair<kotlin.reflect.KClass<out AapSetting>, AapSetting>): AapPodState =
private fun podStateWith(vararg settings: Pair<KClass<out AapSetting>, AapSetting>): AapPodState =
AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = settings.toMap(),
@@ -149,4 +151,4 @@ class AapAncControllerTest : BaseTest() {
decision.podState.setting<AapSetting.AllowOffOption>()!!.enabled shouldBe false
decision.timerActions shouldBe listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference))
}
}
}
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core.apple.aap
package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
@@ -15,7 +15,7 @@ import testhelpers.BaseTest
* The helper must:
* - Work on real captured 0x1D payloads (matches production decoder on ASCII slots).
* - Decode non-ASCII UTF-8 (emoji, non-Latin script) the production parser drops these.
* - Always emit segments for any payload shape, even when [DefaultAapDeviceProfile.decodeDeviceInfo]
* - Always emit segments for any payload shape, even when [eu.darken.capod.pods.core.apple.aap.protocol.DefaultAapDeviceProfile.decodeDeviceInfo]
* would return null (malformed / unknown-shaped packets).
*/
class AapDeviceInfoDiagnosticsTest : BaseTest() {
@@ -78,8 +78,8 @@ class AapDeviceInfoDiagnosticsTest : BaseTest() {
// so any emoji / non-Latin engraving is silently skipped. The diagnostic helper must not.
val engraving = "My AirPods 🌈"
val payload = hex("02 DF 00 04 00") +
engraving.toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
"A3048".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
engraving.toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
"A3048".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
val segments = AapDeviceInfoDiagnostics.describeSegments(payload)
@@ -94,8 +94,8 @@ class AapDeviceInfoDiagnosticsTest : BaseTest() {
// diagnostic dump must still surface whatever segments it finds so maintainers can inspect
// unexpected-shape packets from an engraved device.
val payload = hex("02 DF 00 04 00") +
"Name".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
"Model".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
"Name".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
"Model".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
val fullMessageBytes = hex("04 00 04 00 1D 00") + payload
val message = AapMessage.parse(fullMessageBytes)!!
@@ -106,4 +106,4 @@ class AapDeviceInfoDiagnosticsTest : BaseTest() {
segments[0].utf8 shouldBe "Name"
segments[1].utf8 shouldBe "Model"
}
}
}
@@ -1,11 +1,11 @@
package eu.darken.capod.pods.core.apple.aap
package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.common.TimeSource
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.nulls.shouldBeNull
@@ -131,22 +131,28 @@ class AapSessionEngineTest : BaseTest() {
engine.start(this as TestScope)
engine.onHandshakeSent()
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
))
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.ON,
supported = supportedModes,
))
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.TRANSPARENCY,
supported = supportedModes,
))
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.TRANSPARENCY,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
engine.reset()
@@ -243,67 +249,68 @@ class AapSessionEngineTest : BaseTest() {
}
@Test
fun `flush with ANC plus later setting keeps ANC recent and verifies ANC first`() = runTest(UnconfinedTestDispatcher()) {
val ancSetting = AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.ADAPTIVE),
)
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
fun `flush with ANC plus later setting keeps ANC recent and verifies ANC first`() =
runTest(UnconfinedTestDispatcher()) {
val ancSetting = AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = listOf(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 = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
)
engine.processMessage(dummyMessage())
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting
engine.processMessage(dummyMessage())
nextSetting =
AapSetting.ConversationalAwareness::class as KClass<out AapSetting> to
AapSetting.ConversationalAwareness(enabled = false)
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
val sendRaw: suspend (AapCommand) -> Unit = { sentCommands += it }
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), sendRaw)
engine.send(AapCommand.SetConversationalAwareness(true), sendRaw)
engine.state.value.pendingSettingsCount shouldBe 2
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
nextSetting = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
)
engine.processMessage(dummyMessage())
runCurrent()
sentCommands.size shouldBe 2
sentCommands[0] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
sentCommands[1] shouldBe AapCommand.SetConversationalAwareness(true)
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to
ancSetting.copy(current = AapSetting.AncMode.Value.ON)
engine.processMessage(dummyMessage())
// This would still be ADAPTIVE if the mixed flush path lost the ANC send marker and debounced.
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
advanceTimeBy(1100L)
sentCommands.size shouldBe 3
sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
}
val engine = AapSessionEngine(profile, timeSource)
engine.startReady(this as TestScope)
nextSetting = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
)
engine.processMessage(dummyMessage())
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting
engine.processMessage(dummyMessage())
nextSetting =
AapSetting.ConversationalAwareness::class as KClass<out AapSetting> to
AapSetting.ConversationalAwareness(enabled = false)
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
val sendRaw: suspend (AapCommand) -> Unit = { sentCommands += it }
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), sendRaw)
engine.send(AapCommand.SetConversationalAwareness(true), sendRaw)
engine.state.value.pendingSettingsCount shouldBe 2
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
nextSetting = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
)
engine.processMessage(dummyMessage())
runCurrent()
sentCommands.size shouldBe 2
sentCommands[0] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
sentCommands[1] shouldBe AapCommand.SetConversationalAwareness(true)
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to
ancSetting.copy(current = AapSetting.AncMode.Value.ON)
engine.processMessage(dummyMessage())
// This would still be ADAPTIVE if the mixed flush path lost the ANC send marker and debounced.
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
advanceTimeBy(1100L)
sentCommands.size shouldBe 3
sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
}
}
// ── Message processing — state merge ────────────────────
@@ -402,43 +409,50 @@ class AapSessionEngineTest : BaseTest() {
inner class InferenceTests {
@Test
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 { decodeSetting(any()) } answers { nextSetting }
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 { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
engine.start(this as TestScope)
engine.onHandshakeSent()
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()
}
val engine = AapSessionEngine(profile, timeSource)
engine.start(this as TestScope)
engine.onHandshakeSent()
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()) {
@@ -455,16 +469,20 @@ class AapSessionEngineTest : BaseTest() {
engine.start(this as TestScope)
engine.onHandshakeSent()
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
))
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,
))
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
@@ -473,43 +491,50 @@ class AapSessionEngineTest : BaseTest() {
}
@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 }
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())
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
}
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())
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()) {
@@ -525,16 +550,20 @@ class AapSessionEngineTest : BaseTest() {
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,
))
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,
))
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
nextSetting = settingPair(AapSetting.AllowOffOption(enabled = true))
@@ -543,10 +572,12 @@ class AapSessionEngineTest : BaseTest() {
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,
))
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
advanceTimeBy(2100L)
@@ -561,47 +592,54 @@ class AapSessionEngineTest : BaseTest() {
@Test
fun `matching ANC echo clears pending mode without optimistic current overwrite`() = 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 }
fun `matching ANC echo clears pending mode without optimistic current overwrite`() =
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.ON,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it }
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
engine.state.value.pendingAncMode.shouldBeNull()
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
sentCommands shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE))
}
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.ON,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it }
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
engine.state.value.pendingAncMode.shouldBeNull()
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
sentCommands shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE))
}
// ── ANC Debounce ────────────────────────────────────────
@@ -645,7 +683,9 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
every { decodeBattery(any()) } returns null
every { decodePrivateKeyResponse(any()) } returns null
every { decodeDeviceInfo(any()) } returns null
every { decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(current = AapSetting.AncMode.Value.TRANSPARENCY))
every { decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(
current = AapSetting.AncMode.Value.TRANSPARENCY
))
}
val engine = AapSessionEngine(profile, timeSource)
engine.start(this as TestScope)
@@ -658,7 +698,9 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
// Second message: unsolicited change — should be debounced
every { profile.decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(current = AapSetting.AncMode.Value.TRANSPARENCY))
every { profile.decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(
current = AapSetting.AncMode.Value.TRANSPARENCY
))
engine.processMessage(dummyMessage())
// Not yet applied (debounced)
@@ -731,7 +773,10 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
@Test
fun `0x0017 does not run through decode pipeline`() = runTest(UnconfinedTestDispatcher()) {
val profile = mockProfile {
every { decodeStemPress(any()) } returns StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.LEFT)
every { decodeStemPress(any()) } returns StemPressEvent(
StemPressEvent.PressType.SINGLE,
StemPressEvent.Bud.LEFT
)
}
val engine = createEngine(profile)
engine.startReady(this as TestScope)
@@ -750,4 +795,4 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
collector.cancel()
}
}
}
}
@@ -1,6 +1,7 @@
package eu.darken.capod.pods.core.apple.aap
package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.common.TimeSource
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@@ -52,7 +53,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
fun `enqueue ANC returns pendingAncMode in snapshot`() {
val coord = createCoordinator()
val result = coord.enqueue(emptyList(), AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), stateWithSetting())
val result =
coord.enqueue(emptyList(), AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), stateWithSetting())
result.optimisticState.shouldBeNull()
result.snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
@@ -96,7 +98,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
)
val first = coord.enqueue(emptyList(), AapCommand.SetAdaptiveAudioNoise(70), state)
val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state)
val second =
coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state)
second.snapshot.count shouldBe 2
}
@@ -133,7 +136,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
)
val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state)
val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state)
val second =
coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state)
val result = coord.flush(second.pendingCommands)
result.commands shouldHaveSize 2
@@ -150,7 +154,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
)
val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state)
val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state)
val second =
coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state)
val third = coord.enqueue(second.pendingCommands, AapCommand.SetAllowOffOption(true), state)
val result = coord.flush(third.pendingCommands)
@@ -296,4 +301,4 @@ class AapSettingsCoordinatorTest : BaseTest() {
check(mismatched) shouldBe false
}
}
}
}
@@ -1,6 +1,5 @@
package eu.darken.capod.pods.core.apple.aap
package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.pods.core.apple.aap.HidTracker.HidFrameType
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
@@ -23,11 +22,11 @@ class HidTrackerTest : BaseTest() {
fun `service directory frame from Pro 3 capture`() {
val payload = hexToBytes(
"FE 00 00 06 41 50 00 00 00 80 00 00 41 4F 50 00 00 80 00 00 " +
"52 54 50 00 00 80 00 00 42 54 4D 00 00 80 00 00 " +
"44 53 50 31 00 80 00 00 44 53 50 32 00 80 00 00"
"52 54 50 00 00 80 00 00 42 54 4D 00 00 80 00 00 " +
"44 53 50 31 00 80 00 00 44 53 50 32 00 80 00 00"
)
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.ServiceDirectory>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.ServiceDirectory>()
result.services.shouldContainExactly("AP", "AOP", "RTP", "BTM", "DSP1", "DSP2")
}
@@ -36,7 +35,7 @@ class HidTrackerTest : BaseTest() {
val fill = ByteArray(65) { 0xFF.toByte() }
val payload = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Descriptor>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.Descriptor>()
result.phase shouldBe 0x81
result.fill shouldBe 0xFF
}
@@ -46,7 +45,7 @@ class HidTrackerTest : BaseTest() {
val fill = ByteArray(65) { 0xEF.toByte() }
val payload = hexToBytes("00 04 00 00 44 00 01 C3 02") + fill
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Descriptor>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.Descriptor>()
result.phase shouldBe 0x02
result.fill shouldBe 0xEF
}
@@ -55,7 +54,7 @@ class HidTrackerTest : BaseTest() {
fun `terminator frame`() {
val payload = hexToBytes("00 04 00 00 01 00 FF")
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Terminator>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.Terminator>()
result.payloadSize shouldBe 7
}
@@ -63,20 +62,20 @@ class HidTrackerTest : BaseTest() {
fun `short frame ending in FF but not 7 bytes is Other`() {
val payload = hexToBytes("00 04 00 FF")
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Other>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
}
@Test
fun `empty payload is Other`() {
val result = HidTracker.classify(ByteArray(0))
result.shouldBeInstanceOf<HidFrameType.Other>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
}
@Test
fun `random payload is Other`() {
val payload = hexToBytes("AB CD EF 01 02 03 04 05 06 07 08")
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Other>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
}
}
@@ -211,4 +210,4 @@ class HidTrackerTest : BaseTest() {
logs.shouldBeEmpty()
}
}
}
}