mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
feat(tile): Add ANC Quick Settings tile
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
package eu.darken.capod.main.ui.tile
|
||||
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class AncTileCycleTest : BaseTest() {
|
||||
|
||||
private val off = AapSetting.AncMode.Value.OFF
|
||||
private val on = AapSetting.AncMode.Value.ON
|
||||
private val tx = AapSetting.AncMode.Value.TRANSPARENCY
|
||||
private val ad = AapSetting.AncMode.Value.ADAPTIVE
|
||||
|
||||
@Test
|
||||
fun `empty visible list returns null`() {
|
||||
pickNextMode(visible = emptyList(), current = on, pending = null) shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single visible mode returns same mode`() {
|
||||
pickNextMode(visible = listOf(on), current = on, pending = null) shouldBe on
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `current null and non-empty visible returns first`() {
|
||||
pickNextMode(visible = listOf(off, tx, ad), current = null, pending = null) shouldBe off
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wraps around at end of list`() {
|
||||
pickNextMode(visible = listOf(off, tx, ad), current = ad, pending = null) shouldBe off
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `advances through middle of list`() {
|
||||
pickNextMode(visible = listOf(off, tx, ad), current = tx, pending = null) shouldBe ad
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending wins over current when pending is in visible`() {
|
||||
// current=off, pending=tx → next is ad (anchor on pending so rapid taps walk forward)
|
||||
pickNextMode(visible = listOf(off, tx, ad), current = off, pending = tx) shouldBe ad
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending falls through to current when pending was filtered out`() {
|
||||
// pending=off but visible no longer contains off → fall through to current=tx → next is ad
|
||||
pickNextMode(visible = listOf(tx, ad), current = tx, pending = off) shouldBe ad
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `current not in visible falls through to first`() {
|
||||
// current=on but visible doesn't contain it → start at first (off)
|
||||
pickNextMode(visible = listOf(off, tx, ad), current = on, pending = null) shouldBe off
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending not visible and current null falls back to first`() {
|
||||
pickNextMode(visible = listOf(off, tx, ad), current = null, pending = on) shouldBe off
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package eu.darken.capod.main.ui.tile
|
||||
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class AncTileSendCoordinatorTest : BaseTest() {
|
||||
|
||||
private val address = "00:11:22:33:44:55"
|
||||
private val off = AapSetting.AncMode.Value.OFF
|
||||
private val on = AapSetting.AncMode.Value.ON
|
||||
private val tx = AapSetting.AncMode.Value.TRANSPARENCY
|
||||
private val ad = AapSetting.AncMode.Value.ADAPTIVE
|
||||
private val visible = listOf(off, on, tx, ad)
|
||||
|
||||
@Test
|
||||
fun `pending target is visible immediately and survives service restart state`() = runTest {
|
||||
val coordinator = coordinator()
|
||||
|
||||
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||
|
||||
val rendered = coordinator.applyPendingTarget(active(current = off, pending = null))
|
||||
rendered.shouldBeInstanceOf<AncTileState.Active>()
|
||||
rendered.pending shouldBe tx
|
||||
pickNextMode(rendered.visible, rendered.current, rendered.pending) shouldBe ad
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacing pending send dispatches only latest target`() = runTest {
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true)
|
||||
val coordinator = coordinator(aapManager)
|
||||
|
||||
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||
coordinator.pendingModes.value[address] shouldBe tx
|
||||
advanceTimeBy(999)
|
||||
runCurrent()
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(tx)) }
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(ad)) }
|
||||
|
||||
coordinator.scheduleSetAncMode(address, ad, debounce = 1.seconds)
|
||||
coordinator.pendingModes.value[address] shouldBe ad
|
||||
advanceTimeBy(999)
|
||||
runCurrent()
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(tx)) }
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(ad)) }
|
||||
|
||||
advanceTimeBy(1)
|
||||
runCurrent()
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(tx)) }
|
||||
coVerify(exactly = 1) { aapManager.sendCommand(address, AapCommand.SetAncMode(ad)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applying device pending confirmation is pure and keeps rendered pending mode`() = runTest {
|
||||
val coordinator = coordinator()
|
||||
|
||||
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||
|
||||
val rendered = coordinator.applyPendingTarget(active(current = off, pending = tx))
|
||||
rendered.shouldBeInstanceOf<AncTileState.Active>()
|
||||
rendered.pending shouldBe tx
|
||||
coordinator.pendingModes.value[address] shouldBe tx
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acknowledging device pending confirmation clears process target`() = runTest {
|
||||
val coordinator = coordinator()
|
||||
|
||||
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||
|
||||
coordinator.acknowledgeDeviceState(active(current = off, pending = tx))
|
||||
|
||||
coordinator.pendingModes.value[address] shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applying device current confirmation is pure`() = runTest {
|
||||
val coordinator = coordinator()
|
||||
|
||||
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||
|
||||
val rendered = coordinator.applyPendingTarget(active(current = tx, pending = null))
|
||||
rendered.shouldBeInstanceOf<AncTileState.Active>()
|
||||
rendered.pending shouldBe null
|
||||
coordinator.pendingModes.value[address] shouldBe tx
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acknowledging device current confirmation clears process target`() = runTest {
|
||||
val coordinator = coordinator()
|
||||
|
||||
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||
|
||||
coordinator.acknowledgeDeviceState(active(current = tx, pending = null))
|
||||
|
||||
coordinator.pendingModes.value[address] shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `target matching current is kept while device reports different pending mode`() = runTest {
|
||||
val coordinator = coordinator()
|
||||
|
||||
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||
|
||||
val rendered = coordinator.applyPendingTarget(active(current = tx, pending = off))
|
||||
rendered.shouldBeInstanceOf<AncTileState.Active>()
|
||||
rendered.pending shouldBe tx
|
||||
coordinator.pendingModes.value[address] shouldBe tx
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applying target filtered out of visible modes is pure`() = runTest {
|
||||
val coordinator = coordinator()
|
||||
|
||||
coordinator.scheduleSetAncMode(address, off, debounce = 1.seconds)
|
||||
|
||||
val rendered = coordinator.applyPendingTarget(active(current = on, pending = null, visible = listOf(on, tx, ad)))
|
||||
rendered.shouldBeInstanceOf<AncTileState.Active>()
|
||||
rendered.pending shouldBe null
|
||||
coordinator.pendingModes.value[address] shouldBe off
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acknowledging target filtered out of visible modes clears process target`() = runTest {
|
||||
val coordinator = coordinator()
|
||||
|
||||
coordinator.scheduleSetAncMode(address, off, debounce = 1.seconds)
|
||||
|
||||
coordinator.acknowledgeDeviceState(active(current = on, pending = null, visible = listOf(on, tx, ad)))
|
||||
|
||||
coordinator.pendingModes.value[address] shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending target clears after timeout without confirmation`() = runTest {
|
||||
val coordinator = coordinator()
|
||||
|
||||
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds, timeout = 5.seconds)
|
||||
coordinator.pendingModes.value[address] shouldBe tx
|
||||
|
||||
advanceTimeBy(5_000)
|
||||
runCurrent()
|
||||
|
||||
coordinator.pendingModes.value[address] shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send failure clears pending target`() = runTest {
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true)
|
||||
coEvery {
|
||||
aapManager.sendCommand(address, AapCommand.SetAncMode(tx))
|
||||
} throws IllegalStateException("not connected")
|
||||
val coordinator = coordinator(aapManager)
|
||||
|
||||
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||
coordinator.pendingModes.value[address] shouldBe tx
|
||||
|
||||
advanceTimeBy(1_000)
|
||||
runCurrent()
|
||||
|
||||
coordinator.pendingModes.value[address] shouldBe null
|
||||
}
|
||||
|
||||
private fun TestScope.coordinator(
|
||||
aapManager: AapConnectionManager = mockk(relaxed = true),
|
||||
): AncTileSendCoordinator = AncTileSendCoordinator(
|
||||
appScope = backgroundScope,
|
||||
aapManager = aapManager,
|
||||
)
|
||||
|
||||
private fun active(
|
||||
current: AapSetting.AncMode.Value,
|
||||
pending: AapSetting.AncMode.Value?,
|
||||
visible: List<AapSetting.AncMode.Value> = this.visible,
|
||||
) = AncTileState.Active(
|
||||
current = current,
|
||||
pending = pending,
|
||||
visible = visible,
|
||||
deviceLabel = "Pods",
|
||||
deviceAddress = address,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package eu.darken.capod.main.ui.tile
|
||||
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
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.pods.core.apple.aap.protocol.AapSetting
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class AncTileStateMapperTest : BaseTest() {
|
||||
|
||||
private val noPermissions = emptySet<Permission>()
|
||||
private val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
|
||||
private fun activeDevice(
|
||||
currentMode: AapSetting.AncMode.Value = AapSetting.AncMode.Value.ON,
|
||||
pendingMode: AapSetting.AncMode.Value? = null,
|
||||
connectionState: AapPodState.ConnectionState = AapPodState.ConnectionState.READY,
|
||||
model: PodModel = PodModel.AIRPODS_PRO,
|
||||
): PodDevice {
|
||||
val ancSetting = AapSetting.AncMode(current = currentMode, supported = supportedModes)
|
||||
return PodDevice(
|
||||
profileId = "p1",
|
||||
ble = null,
|
||||
aap = AapPodState(
|
||||
connectionState = connectionState,
|
||||
settings = mapOf(AapSetting.AncMode::class to ancSetting),
|
||||
pendingAncMode = pendingMode,
|
||||
),
|
||||
profileModel = model,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not pro returns NotPro regardless of other state`() {
|
||||
AncTileStateMapper.map(
|
||||
device = activeDevice(),
|
||||
isPro = false,
|
||||
isBluetoothEnabled = true,
|
||||
missingPermissions = noPermissions,
|
||||
) shouldBe AncTileState.NotPro
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing scan permission returns PermissionRequired`() {
|
||||
AncTileStateMapper.map(
|
||||
device = activeDevice(),
|
||||
isPro = true,
|
||||
isBluetoothEnabled = true,
|
||||
missingPermissions = setOf(Permission.BLUETOOTH_SCAN),
|
||||
) shouldBe AncTileState.PermissionRequired
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing BLUETOOTH_CONNECT returns PermissionRequired`() {
|
||||
AncTileStateMapper.map(
|
||||
device = activeDevice(),
|
||||
isPro = true,
|
||||
isBluetoothEnabled = true,
|
||||
missingPermissions = setOf(Permission.BLUETOOTH_CONNECT),
|
||||
) shouldBe AncTileState.PermissionRequired
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing non-blocking permission does not flip state`() {
|
||||
AncTileStateMapper.map(
|
||||
device = activeDevice(),
|
||||
isPro = true,
|
||||
isBluetoothEnabled = true,
|
||||
missingPermissions = setOf(Permission.POST_NOTIFICATIONS),
|
||||
).shouldBeInstanceOf<AncTileState.Active>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bluetooth disabled returns BluetoothOff`() {
|
||||
AncTileStateMapper.map(
|
||||
device = activeDevice(),
|
||||
isPro = true,
|
||||
isBluetoothEnabled = false,
|
||||
missingPermissions = noPermissions,
|
||||
) shouldBe AncTileState.BluetoothOff
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null device returns NoDevice`() {
|
||||
AncTileStateMapper.map(
|
||||
device = null,
|
||||
isPro = true,
|
||||
isBluetoothEnabled = true,
|
||||
missingPermissions = noPermissions,
|
||||
) shouldBe AncTileState.NoDevice
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device without ANC support returns NoAncSupport`() {
|
||||
val device = PodDevice(
|
||||
profileId = "p1",
|
||||
ble = null,
|
||||
aap = null,
|
||||
profileModel = PodModel.AIRPODS_GEN1,
|
||||
)
|
||||
AncTileStateMapper.map(
|
||||
device = device,
|
||||
isPro = true,
|
||||
isBluetoothEnabled = true,
|
||||
missingPermissions = noPermissions,
|
||||
) shouldBe AncTileState.NoAncSupport
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cached device with no AAP session returns NotConnected`() {
|
||||
// ANC-capable model but aap == null → user sees "Disconnected", not "Connecting forever".
|
||||
val device = PodDevice(
|
||||
profileId = "p1",
|
||||
ble = null,
|
||||
aap = null,
|
||||
profileModel = PodModel.AIRPODS_PRO,
|
||||
)
|
||||
AncTileStateMapper.map(
|
||||
device = device,
|
||||
isPro = true,
|
||||
isBluetoothEnabled = true,
|
||||
missingPermissions = noPermissions,
|
||||
) shouldBe AncTileState.NotConnected
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aap connected but not ready returns Connecting`() {
|
||||
AncTileStateMapper.map(
|
||||
device = activeDevice(connectionState = AapPodState.ConnectionState.HANDSHAKING),
|
||||
isPro = true,
|
||||
isBluetoothEnabled = true,
|
||||
missingPermissions = noPermissions,
|
||||
) shouldBe AncTileState.Connecting
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aap ready without AncMode setting returns Connecting`() {
|
||||
val device = PodDevice(
|
||||
profileId = "p1",
|
||||
ble = null,
|
||||
aap = AapPodState(connectionState = AapPodState.ConnectionState.READY),
|
||||
profileModel = PodModel.AIRPODS_PRO,
|
||||
)
|
||||
AncTileStateMapper.map(
|
||||
device = device,
|
||||
isPro = true,
|
||||
isBluetoothEnabled = true,
|
||||
missingPermissions = noPermissions,
|
||||
) shouldBe AncTileState.Connecting
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fully ready device returns Active with current and visible modes`() {
|
||||
val state = AncTileStateMapper.map(
|
||||
device = activeDevice(currentMode = AapSetting.AncMode.Value.TRANSPARENCY),
|
||||
isPro = true,
|
||||
isBluetoothEnabled = true,
|
||||
missingPermissions = noPermissions,
|
||||
)
|
||||
state.shouldBeInstanceOf<AncTileState.Active>()
|
||||
state.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
|
||||
state.visible shouldBe supportedModes
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending mode is propagated to Active`() {
|
||||
val state = AncTileStateMapper.map(
|
||||
device = activeDevice(
|
||||
currentMode = AapSetting.AncMode.Value.OFF,
|
||||
pendingMode = AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
),
|
||||
isPro = true,
|
||||
isBluetoothEnabled = true,
|
||||
missingPermissions = noPermissions,
|
||||
)
|
||||
state.shouldBeInstanceOf<AncTileState.Active>()
|
||||
state.pending shouldBe AapSetting.AncMode.Value.TRANSPARENCY
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `precedence pro gating wins over bluetooth off`() {
|
||||
AncTileStateMapper.map(
|
||||
device = activeDevice(),
|
||||
isPro = false,
|
||||
isBluetoothEnabled = false,
|
||||
missingPermissions = noPermissions,
|
||||
) shouldBe AncTileState.NotPro
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `precedence bluetooth off wins over no device`() {
|
||||
AncTileStateMapper.map(
|
||||
device = null,
|
||||
isPro = true,
|
||||
isBluetoothEnabled = false,
|
||||
missingPermissions = noPermissions,
|
||||
) shouldBe AncTileState.BluetoothOff
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `precedence permission required wins over bluetooth off`() {
|
||||
AncTileStateMapper.map(
|
||||
device = null,
|
||||
isPro = true,
|
||||
isBluetoothEnabled = false,
|
||||
missingPermissions = setOf(Permission.BLUETOOTH_SCAN),
|
||||
) shouldBe AncTileState.PermissionRequired
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package eu.darken.capod.main.ui.tile
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
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.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import java.time.Instant
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class AncTileStateStoreTest : BaseTest() {
|
||||
|
||||
private val address = "00:11:22:33:44:55"
|
||||
private val off = AapSetting.AncMode.Value.OFF
|
||||
private val on = AapSetting.AncMode.Value.ON
|
||||
private val tx = AapSetting.AncMode.Value.TRANSPARENCY
|
||||
private val ad = AapSetting.AncMode.Value.ADAPTIVE
|
||||
private val visible = listOf(off, on, tx, ad)
|
||||
|
||||
@Test
|
||||
fun `current state survives service listener gap`() = runTest {
|
||||
val devices = MutableStateFlow(listOf(activeDevice(currentMode = off)))
|
||||
val store = store(devices = devices)
|
||||
val listener = collectState(store)
|
||||
runCurrent()
|
||||
|
||||
val activeBeforeGap = store.currentState()
|
||||
activeBeforeGap.shouldBeInstanceOf<AncTileState.Active>()
|
||||
activeBeforeGap.current shouldBe off
|
||||
|
||||
listener.cancel()
|
||||
runCurrent()
|
||||
|
||||
val activeAfterGap = store.currentState()
|
||||
activeAfterGap.shouldBeInstanceOf<AncTileState.Active>()
|
||||
activeAfterGap.current shouldBe off
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `current state warms without service listener`() = runTest {
|
||||
val store = store(devices = MutableStateFlow(listOf(activeDevice(currentMode = off))))
|
||||
|
||||
runCurrent()
|
||||
|
||||
val current = store.currentState()
|
||||
current.shouldBeInstanceOf<AncTileState.Active>()
|
||||
current.current shouldBe off
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `current state reports connecting while live device is not ready`() = runTest {
|
||||
val devices = MutableStateFlow(listOf(connectingDevice()))
|
||||
val store = store(devices = devices)
|
||||
runCurrent()
|
||||
|
||||
store.currentState() shouldBe AncTileState.Connecting
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `current state overlays coordinator target before collected state updates`() = runTest {
|
||||
val coordinator = coordinator()
|
||||
val store = store(
|
||||
devices = MutableStateFlow(listOf(activeDevice(currentMode = off))),
|
||||
sendCoordinator = coordinator,
|
||||
)
|
||||
val listener = collectState(store)
|
||||
runCurrent()
|
||||
|
||||
coordinator.scheduleSetAncMode(address, on, debounce = 1.seconds)
|
||||
|
||||
val current = store.currentState()
|
||||
current.shouldBeInstanceOf<AncTileState.Active>()
|
||||
current.pending shouldBe on
|
||||
|
||||
listener.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device confirmation clears coordinator target through state store`() = runTest {
|
||||
val devices = MutableStateFlow(listOf(activeDevice(currentMode = off)))
|
||||
val coordinator = coordinator()
|
||||
val store = store(
|
||||
devices = devices,
|
||||
sendCoordinator = coordinator,
|
||||
)
|
||||
runCurrent()
|
||||
|
||||
coordinator.scheduleSetAncMode(address, on, debounce = 1.seconds)
|
||||
coordinator.pendingModes.value[address] shouldBe on
|
||||
|
||||
devices.value = listOf(activeDevice(currentMode = on))
|
||||
runCurrent()
|
||||
|
||||
coordinator.pendingModes.value[address] shouldBe null
|
||||
val current = store.currentState()
|
||||
current.shouldBeInstanceOf<AncTileState.Active>()
|
||||
current.current shouldBe on
|
||||
current.pending shouldBe null
|
||||
}
|
||||
|
||||
private fun TestScope.collectState(store: AncTileStateStore): Job = launch {
|
||||
store.state.collect {}
|
||||
}
|
||||
|
||||
private fun TestScope.store(
|
||||
devices: MutableStateFlow<List<PodDevice>>,
|
||||
profiles: MutableStateFlow<List<DeviceProfile>> = MutableStateFlow(emptyList()),
|
||||
isPro: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
bluetoothEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
missingPermissions: MutableStateFlow<Set<Permission>> = MutableStateFlow(emptySet()),
|
||||
sendCoordinator: AncTileSendCoordinator = coordinator(),
|
||||
): AncTileStateStore {
|
||||
val deviceMonitor = mockk<DeviceMonitor> {
|
||||
every { this@mockk.devices } returns devices
|
||||
}
|
||||
val profilesRepo = mockk<DeviceProfilesRepo> {
|
||||
every { this@mockk.profiles } returns profiles
|
||||
}
|
||||
val upgradeRepo = mockk<UpgradeRepo> {
|
||||
every { upgradeInfo } returns MutableStateFlow(upgradeInfo(isPro.value))
|
||||
}
|
||||
val bluetoothManager = mockk<BluetoothManager2> {
|
||||
every { isBluetoothEnabled } returns bluetoothEnabled
|
||||
}
|
||||
val permissionTool = mockk<PermissionTool> {
|
||||
every { this@mockk.missingPermissions } returns missingPermissions
|
||||
}
|
||||
|
||||
return AncTileStateStore(
|
||||
appScope = backgroundScope,
|
||||
deviceMonitor = deviceMonitor,
|
||||
profilesRepo = profilesRepo,
|
||||
upgradeRepo = upgradeRepo,
|
||||
bluetoothManager = bluetoothManager,
|
||||
permissionTool = permissionTool,
|
||||
sendCoordinator = sendCoordinator,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TestScope.coordinator(
|
||||
aapManager: AapConnectionManager = mockk(relaxed = true),
|
||||
): AncTileSendCoordinator = AncTileSendCoordinator(
|
||||
appScope = backgroundScope,
|
||||
aapManager = aapManager,
|
||||
)
|
||||
|
||||
private fun activeDevice(
|
||||
currentMode: AapSetting.AncMode.Value,
|
||||
pendingMode: AapSetting.AncMode.Value? = null,
|
||||
): PodDevice {
|
||||
val ancSetting = AapSetting.AncMode(current = currentMode, supported = visible)
|
||||
return PodDevice(
|
||||
profileId = "p1",
|
||||
ble = null,
|
||||
aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
settings = mapOf(AapSetting.AncMode::class to ancSetting),
|
||||
pendingAncMode = pendingMode,
|
||||
),
|
||||
profileAddress = address,
|
||||
profileModel = PodModel.AIRPODS_PRO,
|
||||
)
|
||||
}
|
||||
|
||||
private fun connectingDevice(): PodDevice = PodDevice(
|
||||
profileId = "p1",
|
||||
ble = null,
|
||||
aap = AapPodState(connectionState = AapPodState.ConnectionState.HANDSHAKING),
|
||||
profileAddress = address,
|
||||
profileModel = PodModel.AIRPODS_PRO,
|
||||
)
|
||||
|
||||
private fun upgradeInfo(isPro: Boolean) = object : UpgradeRepo.Info {
|
||||
override val type: UpgradeRepo.Type = UpgradeRepo.Type.FOSS
|
||||
override val isPro: Boolean = isPro
|
||||
override val upgradedAt: Instant? = null
|
||||
override val error: Throwable? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class PodDeviceTierTest : BaseTest() {
|
||||
|
||||
private fun device(
|
||||
profileId: String?,
|
||||
isSystemConnected: Boolean = false,
|
||||
isLive: Boolean = false,
|
||||
): PodDevice {
|
||||
// isLive is derived from ble != null || aap != null. Use a minimal AapPodState so we
|
||||
// don't need to fabricate a BLE snapshot for the live case.
|
||||
val aap = if (isLive) AapPodState() else null
|
||||
return PodDevice(
|
||||
profileId = profileId,
|
||||
ble = null,
|
||||
aap = aap,
|
||||
profileModel = PodModel.AIRPODS_PRO,
|
||||
isSystemConnected = isSystemConnected,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `system connected ranks above live which ranks above offline`() {
|
||||
device(profileId = "a", isSystemConnected = true).tierRank() shouldBe 0
|
||||
device(profileId = "a", isLive = true).tierRank() shouldBe 1
|
||||
device(profileId = "a").tierRank() shouldBe 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `system connected wins regardless of live`() {
|
||||
// isSystemConnected true takes precedence even if also live
|
||||
device(profileId = "a", isSystemConnected = true, isLive = true).tierRank() shouldBe 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primaryByTier picks system-connected device first`() {
|
||||
val live = device(profileId = "a", isLive = true)
|
||||
val systemConnected = device(profileId = "b", isSystemConnected = true)
|
||||
val devices = listOf(live, systemConnected)
|
||||
devices.primaryByTier(profileOrder = mapOf("a" to 0, "b" to 1)) shouldBe systemConnected
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primaryByTier uses profile order as tiebreaker within a tier`() {
|
||||
val first = device(profileId = "a", isLive = true)
|
||||
val second = device(profileId = "b", isLive = true)
|
||||
val devices = listOf(second, first)
|
||||
devices.primaryByTier(profileOrder = mapOf("a" to 0, "b" to 1)) shouldBe first
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primaryByTier ignores profileless devices`() {
|
||||
val anonymous = device(profileId = null, isSystemConnected = true)
|
||||
val profiled = device(profileId = "a", isLive = true)
|
||||
val devices = listOf(anonymous, profiled)
|
||||
devices.primaryByTier(profileOrder = mapOf("a" to 0)) shouldBe profiled
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primaryByTier returns null when no profiled devices`() {
|
||||
val devices = listOf(device(profileId = null, isLive = true))
|
||||
devices.primaryByTier(profileOrder = emptyMap()) shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primaryByTier returns null on empty list`() {
|
||||
emptyList<PodDevice>().primaryByTier(profileOrder = emptyMap()) shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primaryByTier handles missing profile order with stable fallback`() {
|
||||
// Profile id not present in map → falls back to Int.MAX_VALUE (last)
|
||||
val withOrder = device(profileId = "a", isLive = true)
|
||||
val withoutOrder = device(profileId = "z", isLive = true)
|
||||
val devices = listOf(withoutOrder, withOrder)
|
||||
devices.primaryByTier(profileOrder = mapOf("a" to 0)) shouldBe withOrder
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user