diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt index 596b076b..4b064392 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt @@ -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") } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt index f9daf6cd..aaab6c41 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt @@ -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() == 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" diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt index e527a868..e954fa96 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt @@ -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() } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt new file mode 100644 index 00000000..9db06782 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt @@ -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 { + 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 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 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() + 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, 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 to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + ) + engine.processMessage(dummyMessage()) + + nextSetting = AapSetting.AncMode::class as KClass to ancSetting + engine.processMessage(dummyMessage()) + + nextSetting = + AapSetting.ConversationalAwareness::class as KClass to + AapSetting.ConversationalAwareness(enabled = false) + engine.processMessage(dummyMessage()) + + val sentCommands = mutableListOf() + 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 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 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()!!.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()!!.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()?.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()!!.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 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 to ancSetting) + engine.processMessage(dummyMessage(commandType = 0x0002)) // triggers READY + first ANC + + engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ON + + // Second message: unsolicited change — should be debounced + every { profile.decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass to ancSetting.copy(current = AapSetting.AncMode.Value.TRANSPARENCY)) + engine.processMessage(dummyMessage()) + + // Not yet applied (debounced) + engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ON + + // After 1500ms debounce + advanceTimeBy(1600L) + engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY + } + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt index 28327f2e..66ab9a65 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt @@ -154,6 +154,25 @@ class AapSettingsCoordinatorTest : BaseTest() { commands[1].shouldBeInstanceOf() } + @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() + commands[1].shouldBeInstanceOf() + commands[2].shouldBeInstanceOf() + } + @Test fun `flush clears queue`() { val coord = createCoordinator()