feat(tile): Add ANC Quick Settings tile

This commit is contained in:
darken
2026-05-03 10:58:05 +02:00
committed by Matthias Urhahn
parent 39922df792
commit acc6e92b2d
15 changed files with 1356 additions and 16 deletions
@@ -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
}
}