mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(aap): Fix flush ANC debounce, HANDSHAKING→READY regression, and flush ordering
Track lastAncSentAt separately so non-ANC commands during flush don't break the ANC echo debounce window. Prioritize ANC for post-flush verification. Move HANDSHAKING→READY transition to top of processMessage so decoded battery, stem press, and device info messages also trigger it. Sort flush: AllowOffOption before AncMode before others, preventing device rejection when enabling OFF mode.
This commit is contained in:
@@ -38,8 +38,7 @@ internal class AapConnection(
|
||||
private val device: BluetoothDevice,
|
||||
private val profile: AapDeviceProfile,
|
||||
private val socketFactory: L2capSocketFactory,
|
||||
private val timeSource: TimeSource,
|
||||
private val psm: Int = 0x1001,
|
||||
timeSource: TimeSource,
|
||||
) {
|
||||
|
||||
private val engine = AapSessionEngine(profile, timeSource)
|
||||
@@ -65,7 +64,7 @@ internal class AapConnection(
|
||||
engine.start(scope)
|
||||
|
||||
try {
|
||||
val sock = socketFactory.createSocket(device, psm)
|
||||
val sock = socketFactory.createSocket(device, PSM)
|
||||
sock.connect()
|
||||
socket = sock
|
||||
log(TAG, INFO) { "Connected to ${device.address}" }
|
||||
@@ -168,6 +167,7 @@ internal class AapConnection(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PSM = 0x1001
|
||||
private val TAG = logTag("AAP", "Connection")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ internal class AapSessionEngine(
|
||||
private var ancDebounceJob: Job? = null
|
||||
private var lastSentCommand: AapCommand? = null
|
||||
private var lastSentAt: Long = 0L
|
||||
/** Separate tracking for ANC sends — not overwritten by non-ANC commands during flush. */
|
||||
private var lastAncSentAt: Long = 0L
|
||||
private var handshakeResponseReceived: Boolean = false
|
||||
|
||||
/** Stored reference to the socket write callback — set on each [send] / flush call. */
|
||||
@@ -82,6 +84,7 @@ internal class AapSessionEngine(
|
||||
scope = null
|
||||
lastSentCommand = null
|
||||
lastSentAt = 0L
|
||||
lastAncSentAt = 0L
|
||||
activeSendRaw = null
|
||||
handshakeResponseReceived = false
|
||||
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
|
||||
@@ -148,7 +151,9 @@ internal class AapSessionEngine(
|
||||
private suspend fun wrappedSend(sendRaw: suspend (AapCommand) -> Unit, command: AapCommand) {
|
||||
sendRaw(command)
|
||||
lastSentCommand = command
|
||||
lastSentAt = timeSource.currentTimeMillis()
|
||||
val now = timeSource.currentTimeMillis()
|
||||
lastSentAt = now
|
||||
if (command is AapCommand.SetAncMode) lastAncSentAt = now
|
||||
}
|
||||
|
||||
private fun onVerificationOutcome(outcome: AapSettingsCoordinator.VerificationOutcome) {
|
||||
@@ -173,6 +178,16 @@ internal class AapSessionEngine(
|
||||
val hex = message.raw.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" }
|
||||
|
||||
// HANDSHAKING → READY: first non-settings-echo message means the device is talking.
|
||||
// Must happen before any early returns so decoded messages (battery, stem, etc.) also trigger it.
|
||||
if (!handshakeResponseReceived && message.commandType != 0x0009
|
||||
&& _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING
|
||||
) {
|
||||
handshakeResponseReceived = true
|
||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
|
||||
log(TAG) { "Connection READY" }
|
||||
}
|
||||
|
||||
// Issue #173 diagnostic dump
|
||||
if (message.commandType == 0x001D) {
|
||||
val segments = describeDeviceInfoSegments(message.payload)
|
||||
@@ -241,8 +256,7 @@ internal class AapSessionEngine(
|
||||
// ANC debounce
|
||||
if (value is AapSetting.AncMode) {
|
||||
val isFirstAncMode = _state.value.setting<AapSetting.AncMode>() == null
|
||||
val recentAncSend =
|
||||
lastSentCommand is AapCommand.SetAncMode && (timeSource.currentTimeMillis() - lastSentAt) <= 3000L
|
||||
val recentAncSend = lastAncSentAt > 0L && (timeSource.currentTimeMillis() - lastAncSentAt) <= 3000L
|
||||
if (isFirstAncMode || recentAncSend) {
|
||||
ancDebounceJob?.cancel()
|
||||
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
|
||||
@@ -309,7 +323,10 @@ internal class AapSessionEngine(
|
||||
break
|
||||
}
|
||||
}
|
||||
commands.lastOrNull()?.let {
|
||||
// Verify ANC mode if it was in the batch (most important for divergence detection).
|
||||
// Fall back to verifying the last command if no ANC was flushed.
|
||||
val toVerify = commands.firstOrNull { it is AapCommand.SetAncMode } ?: commands.lastOrNull()
|
||||
toVerify?.let {
|
||||
coordinator.startVerification(
|
||||
it,
|
||||
this,
|
||||
@@ -322,22 +339,9 @@ internal class AapSessionEngine(
|
||||
}
|
||||
}
|
||||
|
||||
// HANDSHAKING → READY transition
|
||||
if (!handshakeResponseReceived && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING) {
|
||||
handshakeResponseReceived = true
|
||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
|
||||
log(TAG) { "Connection READY" }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Track handshake response for non-setting messages too
|
||||
if (!handshakeResponseReceived && message.commandType != 0x0009 && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING) {
|
||||
handshakeResponseReceived = true
|
||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
|
||||
log(TAG) { "Connection READY" }
|
||||
}
|
||||
|
||||
val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) }
|
||||
val sinceSend = if (lastSentAt == 0L) -1L else timeSource.currentTimeMillis() - lastSentAt
|
||||
val lastSend = lastSentCommand?.let { it::class.simpleName } ?: "none"
|
||||
|
||||
@@ -73,7 +73,16 @@ internal class AapSettingsCoordinator(
|
||||
commands = pendingCommands.values.toList()
|
||||
pendingCommands.clear()
|
||||
}
|
||||
val sorted = commands.sortedBy { if (it is AapCommand.SetAncMode) 0 else 1 }
|
||||
// Dependency-aware ordering:
|
||||
// AllowOffOption must precede AncMode (device rejects OFF without it)
|
||||
// AncMode must precede mode-dependent settings (e.g. AdaptiveAudioNoise)
|
||||
val sorted = commands.sortedBy {
|
||||
when (it) {
|
||||
is AapCommand.SetAllowOffOption -> 0
|
||||
is AapCommand.SetAncMode -> 1
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
return sorted to snapshot()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
package eu.darken.capod.pods.core.apple.aap
|
||||
|
||||
import eu.darken.capod.common.TimeSource
|
||||
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
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import java.time.Instant
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class AapSessionEngineTest : BaseTest() {
|
||||
|
||||
private val timeSource = mockk<TimeSource> {
|
||||
every { now() } returns Instant.ofEpochMilli(1000L)
|
||||
every { currentTimeMillis() } returns 1000L
|
||||
}
|
||||
|
||||
private fun dummyMessage(commandType: Int = 0x0009): AapMessage {
|
||||
val header = byteArrayOf(0x04, 0x00, 0x02, 0x00)
|
||||
val cmdBytes = byteArrayOf((commandType and 0xFF).toByte(), ((commandType shr 8) and 0xFF).toByte())
|
||||
val raw = header + cmdBytes
|
||||
return AapMessage(raw = raw, commandType = commandType, payload = ByteArray(0))
|
||||
}
|
||||
|
||||
/** 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
|
||||
every { decodeBattery(any()) } returns null
|
||||
every { decodePrivateKeyResponse(any()) } returns null
|
||||
every { decodeDeviceInfo(any()) } returns null
|
||||
every { decodeSetting(any()) } returns null
|
||||
every { encodeHandshake() } returns ByteArray(10)
|
||||
every { encodeNotificationEnable() } returns emptyList()
|
||||
every { encodeInitExt() } returns ByteArray(10)
|
||||
every { encodePrivateKeyRequest() } returns null
|
||||
block()
|
||||
}
|
||||
|
||||
private fun createEngine(
|
||||
profile: AapDeviceProfile = mockProfile(),
|
||||
): AapSessionEngine = AapSessionEngine(profile, timeSource)
|
||||
|
||||
private fun AapSessionEngine.startReady(scope: TestScope) {
|
||||
start(scope)
|
||||
onHandshakeSent()
|
||||
// Non-0x0009 message triggers HANDSHAKING → READY
|
||||
processMessage(dummyMessage(commandType = 0x0002))
|
||||
}
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class LifecycleTests {
|
||||
|
||||
@Test
|
||||
fun `start sets CONNECTING`() {
|
||||
val engine = createEngine()
|
||||
val scope = TestScope(UnconfinedTestDispatcher())
|
||||
|
||||
engine.start(scope)
|
||||
|
||||
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.CONNECTING
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onHandshakeSent sets HANDSHAKING`() {
|
||||
val engine = createEngine()
|
||||
val scope = TestScope(UnconfinedTestDispatcher())
|
||||
|
||||
engine.start(scope)
|
||||
engine.onHandshakeSent()
|
||||
|
||||
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.HANDSHAKING
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset clears to DISCONNECTED`() {
|
||||
val engine = createEngine()
|
||||
val scope = TestScope(UnconfinedTestDispatcher())
|
||||
|
||||
engine.start(scope)
|
||||
engine.onHandshakeSent()
|
||||
engine.reset()
|
||||
|
||||
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset is idempotent`() {
|
||||
val engine = createEngine()
|
||||
|
||||
engine.reset()
|
||||
engine.reset()
|
||||
|
||||
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first non-0x0009 message transitions HANDSHAKING to READY`() {
|
||||
val engine = createEngine()
|
||||
val scope = TestScope(UnconfinedTestDispatcher())
|
||||
|
||||
engine.start(scope)
|
||||
engine.onHandshakeSent()
|
||||
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.HANDSHAKING
|
||||
|
||||
engine.processMessage(dummyMessage(commandType = 0x0002))
|
||||
|
||||
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.READY
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `0x0009 message does NOT trigger READY transition`() {
|
||||
val engine = createEngine()
|
||||
val scope = TestScope(UnconfinedTestDispatcher())
|
||||
|
||||
engine.start(scope)
|
||||
engine.onHandshakeSent()
|
||||
|
||||
engine.processMessage(dummyMessage(commandType = 0x0009))
|
||||
|
||||
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.HANDSHAKING
|
||||
}
|
||||
}
|
||||
|
||||
// ── Send path ───────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class SendPathTests {
|
||||
|
||||
@Test
|
||||
fun `send queues when no pod in ear`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val engine = createEngine()
|
||||
engine.startReady(this as TestScope)
|
||||
|
||||
// Put ear detection showing pods in case
|
||||
val earState = engine.state.value.withSetting(
|
||||
AapSetting.EarDetection::class,
|
||||
AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||
),
|
||||
)
|
||||
// We need to set the state with ear detection — do it by processing a setting message
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } returns (AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||
))
|
||||
}
|
||||
val engineWithEar = AapSessionEngine(profile, timeSource)
|
||||
engineWithEar.startReady(this as TestScope)
|
||||
|
||||
// Force ear detection into state via processMessage
|
||||
engineWithEar.processMessage(dummyMessage())
|
||||
|
||||
var sendCount = 0
|
||||
engineWithEar.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sendCount++ }
|
||||
|
||||
sendCount shouldBe 0 // Not sent, queued
|
||||
engineWithEar.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||
engineWithEar.state.value.pendingSettingsCount shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send immediate when pod in ear`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } returns (AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||
))
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.startReady(this as TestScope)
|
||||
engine.processMessage(dummyMessage()) // Set ear detection
|
||||
|
||||
var sentCommands = mutableListOf<AapCommand>()
|
||||
engine.send(AapCommand.SetConversationalAwareness(true)) { sentCommands.add(it) }
|
||||
|
||||
sentCommands.size shouldBe 1
|
||||
engine.state.value.pendingSettingsCount shouldBe 0
|
||||
}
|
||||
|
||||
@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 }
|
||||
}
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
advanceTimeBy(1100L)
|
||||
|
||||
sentCommands.size shouldBe 3
|
||||
sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message processing — state merge ────────────────────
|
||||
|
||||
@Nested
|
||||
inner class MessageProcessingTests {
|
||||
|
||||
@Test
|
||||
fun `battery decode merges into state and filters DISCONNECTED`() {
|
||||
val profile = mockProfile {
|
||||
every { decodeStemPress(any()) } returns null
|
||||
every { decodeBattery(any()) } returns mapOf(
|
||||
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.85f, AapPodState.ChargingState.NOT_CHARGING),
|
||||
AapPodState.BatteryType.CASE to AapPodState.Battery(AapPodState.BatteryType.CASE, 0f, AapPodState.ChargingState.DISCONNECTED),
|
||||
)
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
val scope = TestScope(UnconfinedTestDispatcher())
|
||||
engine.start(scope)
|
||||
engine.onHandshakeSent()
|
||||
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
engine.state.value.batteryLeft shouldBe 0.85f
|
||||
engine.state.value.batteryCase.shouldBeNull() // DISCONNECTED filtered
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decoded battery message transitions HANDSHAKING to READY`() {
|
||||
val profile = mockProfile {
|
||||
every { decodeBattery(any()) } returns mapOf(
|
||||
AapPodState.BatteryType.LEFT to AapPodState.Battery(
|
||||
AapPodState.BatteryType.LEFT,
|
||||
0.85f,
|
||||
AapPodState.ChargingState.NOT_CHARGING,
|
||||
),
|
||||
)
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
val scope = TestScope(UnconfinedTestDispatcher())
|
||||
engine.start(scope)
|
||||
engine.onHandshakeSent()
|
||||
|
||||
engine.processMessage(dummyMessage(commandType = 0x0002))
|
||||
|
||||
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.READY
|
||||
engine.state.value.batteryLeft shouldBe 0.85f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setting decode merges into state`() {
|
||||
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.ToneVolume::class to AapSetting.ToneVolume(level = 75))
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
val scope = TestScope(UnconfinedTestDispatcher())
|
||||
engine.start(scope)
|
||||
engine.onHandshakeSent()
|
||||
|
||||
engine.processMessage(dummyMessage(commandType = 0x0002)) // triggers READY + processes setting
|
||||
|
||||
engine.state.value.setting<AapSetting.ToneVolume>()!!.level shouldBe 75
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stem press emits event`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val profile = mockProfile {
|
||||
every { decodeStemPress(any()) } returns StemPressEvent(
|
||||
pressType = StemPressEvent.PressType.SINGLE,
|
||||
bud = StemPressEvent.Bud.LEFT,
|
||||
)
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.start(this as TestScope)
|
||||
|
||||
var emitted: StemPressEvent? = null
|
||||
val job = launch {
|
||||
emitted = engine.stemPressEvents.first()
|
||||
}
|
||||
|
||||
engine.processMessage(dummyMessage())
|
||||
job.join()
|
||||
|
||||
emitted.shouldNotBeNull()
|
||||
emitted!!.pressType shouldBe StemPressEvent.PressType.SINGLE
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inference ───────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class InferenceTests {
|
||||
|
||||
@Test
|
||||
fun `AncMode OFF infers AllowOffOption true`() {
|
||||
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),
|
||||
))
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
val scope = TestScope(UnconfinedTestDispatcher())
|
||||
engine.start(scope)
|
||||
engine.onHandshakeSent()
|
||||
|
||||
// First ANC mode = no debounce, applied immediately
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
engine.state.value.setting<AapSetting.AllowOffOption>()?.enabled shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
// ── ANC Debounce ────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class AncDebounceTests {
|
||||
|
||||
@Test
|
||||
fun `first ANC mode applied immediately without debounce`() {
|
||||
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.ON,
|
||||
supported = listOf(AapSetting.AncMode.Value.ON),
|
||||
))
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
val scope = TestScope(UnconfinedTestDispatcher())
|
||||
engine.start(scope)
|
||||
engine.onHandshakeSent()
|
||||
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
// Applied immediately (first ANC mode, no debounce)
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsolicited ANC mode change is debounced`() = runTest(UnconfinedTestDispatcher()) {
|
||||
// Set up with an existing ANC mode and no recent send
|
||||
every { timeSource.currentTimeMillis() } returns 10000L // Well past any send
|
||||
|
||||
val ancSetting = AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ON,
|
||||
supported = listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY),
|
||||
)
|
||||
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 as KClass<out AapSetting> to ancSetting.copy(current = AapSetting.AncMode.Value.TRANSPARENCY))
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.start(this as TestScope)
|
||||
engine.onHandshakeSent()
|
||||
|
||||
// First message: set initial ANC mode (no debounce for first)
|
||||
every { profile.decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting)
|
||||
engine.processMessage(dummyMessage(commandType = 0x0002)) // triggers READY + first ANC
|
||||
|
||||
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))
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
// Not yet applied (debounced)
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
|
||||
|
||||
// After 1500ms debounce
|
||||
advanceTimeBy(1600L)
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,25 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
||||
commands[1].shouldBeInstanceOf<AapCommand.SetToneVolume>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flush sorts AllowOffOption before AncMode before others`() {
|
||||
val coord = createCoordinator()
|
||||
val state = stateWithSetting(
|
||||
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50),
|
||||
)
|
||||
|
||||
coord.enqueue(AapCommand.SetToneVolume(80), state)
|
||||
coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state)
|
||||
coord.enqueue(AapCommand.SetAllowOffOption(true), state)
|
||||
|
||||
val (commands, _) = coord.flush()
|
||||
|
||||
commands shouldHaveSize 3
|
||||
commands[0].shouldBeInstanceOf<AapCommand.SetAllowOffOption>()
|
||||
commands[1].shouldBeInstanceOf<AapCommand.SetAncMode>()
|
||||
commands[2].shouldBeInstanceOf<AapCommand.SetToneVolume>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flush clears queue`() {
|
||||
val coord = createCoordinator()
|
||||
|
||||
Reference in New Issue
Block a user