test(reaction): Add ViewModel and reaction logic unit tests

Extract pure decision functions from AutoConnect and PopUpReaction for testability, following the existing PlayPause pattern. Add FakeDataStoreValue test helper.

Fix reversed Duration.between args in PopUpReaction connection monitor that caused the false-positive age filter to never trigger.
This commit is contained in:
darken
2026-03-07 14:19:17 +00:00
committed by Matthias Urhahn
parent da6a6f6ff2
commit 450a187b65
7 changed files with 1199 additions and 65 deletions
@@ -77,25 +77,26 @@ class AutoConnect @Inject constructor(
val condition = reactionSettings.autoConnectCondition.valueBlocking
log(TAG) { "Checking condition $condition" }
val conditionFulfilled = when (condition) {
AutoConnectCondition.WHEN_SEEN -> true
AutoConnectCondition.CASE_OPEN -> when (mainDevice) {
is DualApplePods -> mainDevice.caseLidState == DualApplePods.LidState.OPEN
else -> true
}
AutoConnectCondition.IN_EAR -> when (mainDevice) {
is HasEarDetection -> {
if (mainDevice is HasEarDetectionDual && reactionSettings.onePodMode.valueBlocking) {
mainDevice.isEitherPodInEar
} else {
mainDevice.isBeingWorn
}
}
else -> true
}
}
if (!conditionFulfilled) {
log(TAG) { "Auto connect condition ($condition) is not fullfilled." }
val lidState = (mainDevice as? DualApplePods)?.caseLidState
val isBeingWorn = (mainDevice as? HasEarDetection)?.isBeingWorn ?: false
val isEitherPodInEar = (mainDevice as? HasEarDetectionDual)?.isEitherPodInEar ?: false
val onePodMode = reactionSettings.onePodMode.valueBlocking
val decision = evaluateAutoConnect(
mainDeviceAddr = mainDeviceAddr,
hasBondedDevice = true,
isAlreadyConnected = false,
condition = condition,
lidState = lidState,
isBeingWorn = isBeingWorn,
isEitherPodInEar = isEitherPodInEar,
onePodMode = onePodMode,
supportsEarDetection = mainDevice is HasEarDetection,
)
if (!decision.shouldConnect) {
log(TAG) { "Auto connect condition ($condition) is not fullfilled: ${decision.reason}" }
return@map
}
val result = bluetoothManager.nudgeConnection(bondedDevice)
@@ -103,6 +104,51 @@ class AutoConnect @Inject constructor(
}
.setupCommonEventHandlers(TAG) { "monitor" }
internal fun evaluateAutoConnect(
mainDeviceAddr: String?,
hasBondedDevice: Boolean,
isAlreadyConnected: Boolean,
condition: AutoConnectCondition,
lidState: DualApplePods.LidState?,
isBeingWorn: Boolean,
isEitherPodInEar: Boolean,
onePodMode: Boolean,
supportsEarDetection: Boolean,
): AutoConnectDecision {
if (mainDeviceAddr.isNullOrEmpty()) {
return AutoConnectDecision(false, "No main device address")
}
if (!hasBondedDevice) {
return AutoConnectDecision(false, "No bonded device")
}
if (isAlreadyConnected) {
return AutoConnectDecision(false, "Already connected")
}
return when (condition) {
AutoConnectCondition.WHEN_SEEN -> AutoConnectDecision(true, "WHEN_SEEN: device visible")
AutoConnectCondition.CASE_OPEN -> {
if (lidState == null) {
AutoConnectDecision(true, "CASE_OPEN: unsupported device, permissive fallback")
} else when (lidState) {
DualApplePods.LidState.OPEN -> AutoConnectDecision(true, "CASE_OPEN: lid is open")
else -> AutoConnectDecision(false, "CASE_OPEN: lid is $lidState")
}
}
AutoConnectCondition.IN_EAR -> {
if (!supportsEarDetection) {
return AutoConnectDecision(true, "IN_EAR: unsupported device, permissive fallback")
}
val inEar = if (onePodMode) isEitherPodInEar else isBeingWorn
AutoConnectDecision(inEar, if (inEar) "IN_EAR: pod in ear" else "IN_EAR: not in ear")
}
}
}
data class AutoConnectDecision(
val shouldConnect: Boolean,
val reason: String,
)
companion object {
private val TAG = logTag("Reaction", "AutoConnect")
}
@@ -71,42 +71,32 @@ class PopUpReaction @Inject constructor(
private fun throttleCasePopUps(current: DualApplePods): Event? {
val cooldownKey = current.meta.profile?.id ?: current.identifier.toString()
val now = Instant.now()
val lastShown = caseCoolDowns[cooldownKey]
val decision = evaluateCasePopUp(
currentLidState = current.caseLidState,
lastShownTime = lastShown,
now = now,
)
log(TAG) { "Case popup decision: ${decision.reason}" }
if (decision.shouldResetCooldown) {
caseCoolDowns.remove(cooldownKey)
}
return when {
current.caseLidState == DualApplePods.LidState.OPEN -> {
log(TAG, INFO) { "Show popup" }
val now = Instant.now()
val lastShown = caseCoolDowns[cooldownKey] ?: Instant.MIN
val sinceLastPop = Duration.between(lastShown, now)
log(TAG) { "Time since last case popup: $sinceLastPop" }
if (sinceLastPop >= Duration.ofSeconds(10)) {
caseCoolDowns[cooldownKey] = Instant.now()
Event.PopupShow(device = current)
} else {
log(TAG, INFO) { "Case popup is still on cooldown: $sinceLastPop" }
null
}
decision.shouldShow -> {
caseCoolDowns[cooldownKey] = now
Event.PopupShow(device = current)
}
current.caseLidState != DualApplePods.LidState.OPEN -> {
when (current.caseLidState) {
DualApplePods.LidState.CLOSED -> {
log(TAG, INFO) { "Lid was actively closed, resetting cooldown." }
caseCoolDowns.remove(cooldownKey)
}
else -> {
log(TAG, WARN) { "Lid was was not actively closed, refreshing cooldown." }
caseCoolDowns[cooldownKey] = Instant.now()
}
decision.shouldHide -> {
if (!decision.shouldResetCooldown) {
caseCoolDowns[cooldownKey] = now
}
log(TAG, INFO) { "Hide popup" }
Event.PopupHide()
}
else -> null
}
}
@@ -150,29 +140,26 @@ class PopUpReaction @Inject constructor(
}
if (currentConnected == null || currentBroadcasted == null) {
// We need an active connection
return@mapNotNull null
}
val ageOfBroadcastedDevice = Duration.between(Instant.now(), currentBroadcasted.seenFirstAt)
val ageOfConnectedDevice = Duration.between(Instant.now(), currentConnected.seenFirstAt)
if (ageOfBroadcastedDevice > (ageOfConnectedDevice + Duration.ofSeconds(30))) {
// This is likely a false positive, some random nearby device
// We expect the first broadcasts to not be much older than the first connection
log(TAG, VERBOSE) { "Current broadcasted main device is probably a false-positive" }
return@mapNotNull null
}
val deviceAge = Duration.between(currentBroadcasted.seenFirstAt, Instant.now())
val connectionAge = Duration.between(currentConnected.seenFirstAt, Instant.now())
val now = Instant.now()
val lastShown = connectionCoolDowns[currentConnected.address]
val sinceLastPop = lastShown?.let { Duration.between(it, now) }
log(TAG) { "Time since last connection popup: ${sinceLastPop?.seconds}s" }
val decision = evaluateConnectionPopUp(
hasConnectedDevice = true,
hasPodDevice = true,
hasAlreadyShown = connectionCoolDowns.containsKey(currentConnected.address),
deviceAge = deviceAge,
connectionAge = connectionAge,
)
if (lastShown == null) {
log(TAG) { "Connection popup decision: ${decision.reason}" }
if (decision.shouldShow) {
connectionCoolDowns[currentConnected.address] = Instant.now()
Event.PopupShow(device = currentBroadcasted)
} else {
log(TAG) { "Connection popup is still on cooldown: $sinceLastPop" }
null
}
}
@@ -191,6 +178,89 @@ class PopUpReaction @Inject constructor(
) : Event()
}
internal fun evaluateCasePopUp(
currentLidState: DualApplePods.LidState?,
lastShownTime: Instant?,
now: Instant,
cooldownDuration: Duration = Duration.ofSeconds(10),
): CasePopUpDecision {
if (currentLidState == null) {
return CasePopUpDecision(
shouldShow = false,
shouldHide = false,
shouldResetCooldown = false,
reason = "Non-DualApplePods device",
)
}
return when (currentLidState) {
DualApplePods.LidState.OPEN -> {
val sinceLastPop = lastShownTime?.let { Duration.between(it, now) }
if (sinceLastPop == null || sinceLastPop >= cooldownDuration) {
CasePopUpDecision(
shouldShow = true,
shouldHide = false,
shouldResetCooldown = false,
reason = "Lid OPEN, cooldown expired or first show",
)
} else {
CasePopUpDecision(
shouldShow = false,
shouldHide = false,
shouldResetCooldown = false,
reason = "Lid OPEN, still on cooldown ($sinceLastPop)",
)
}
}
DualApplePods.LidState.CLOSED -> CasePopUpDecision(
shouldShow = false,
shouldHide = true,
shouldResetCooldown = true,
reason = "Lid CLOSED, resetting cooldown",
)
else -> CasePopUpDecision(
shouldShow = false,
shouldHide = true,
shouldResetCooldown = false,
reason = "Lid $currentLidState, refreshing cooldown",
)
}
}
data class CasePopUpDecision(
val shouldShow: Boolean,
val shouldHide: Boolean,
val shouldResetCooldown: Boolean,
val reason: String,
)
internal fun evaluateConnectionPopUp(
hasConnectedDevice: Boolean,
hasPodDevice: Boolean,
hasAlreadyShown: Boolean,
deviceAge: Duration,
connectionAge: Duration,
maxAgeDiff: Duration = Duration.ofSeconds(30),
): ConnectionPopUpDecision {
if (!hasConnectedDevice) {
return ConnectionPopUpDecision(false, "No connected device")
}
if (!hasPodDevice) {
return ConnectionPopUpDecision(false, "No pod device found")
}
if (deviceAge.abs() > (connectionAge.abs() + maxAgeDiff)) {
return ConnectionPopUpDecision(false, "Broadcast too old, likely false positive")
}
if (hasAlreadyShown) {
return ConnectionPopUpDecision(false, "Already shown for this connection")
}
return ConnectionPopUpDecision(true, "New connection detected")
}
data class ConnectionPopUpDecision(
val shouldShow: Boolean,
val reason: String,
)
companion object {
private val TAG = logTag("Reaction", "PopUp")
}
@@ -0,0 +1,303 @@
package eu.darken.capod.main.ui.overview
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.coroutine.TestDispatcherProvider
import testhelpers.datastore.FakeDataStoreValue
import testhelpers.livedata.InstantExecutorExtension
import org.junit.jupiter.api.extension.ExtendWith
@OptIn(ExperimentalCoroutinesApi::class)
@ExtendWith(InstantExecutorExtension::class)
class OverviewViewModelTest : BaseTest() {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var monitorControl: MonitorControl
private lateinit var podMonitor: PodMonitor
private lateinit var permissionTool: PermissionTool
private lateinit var generalSettings: GeneralSettings
private lateinit var debugSettings: DebugSettings
private lateinit var upgradeRepo: UpgradeRepo
private lateinit var bluetoothManager: BluetoothManager2
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var missingPermissionsFlow: MutableStateFlow<Set<Permission>>
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
private lateinit var connectedDevicesFlow: MutableStateFlow<List<BluetoothDevice2>>
private lateinit var isBluetoothEnabledFlow: MutableStateFlow<Boolean>
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
private lateinit var fakeMonitorMode: FakeDataStoreValue<MonitorMode>
private lateinit var fakeDebugMode: FakeDataStoreValue<Boolean>
@BeforeEach
fun setup() {
Dispatchers.setMain(testDispatcher)
missingPermissionsFlow = MutableStateFlow(emptySet())
devicesFlow = MutableStateFlow(emptyList())
connectedDevicesFlow = MutableStateFlow(emptyList())
isBluetoothEnabledFlow = MutableStateFlow(true)
profilesFlow = MutableStateFlow(emptyList())
upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(relaxed = true))
fakeMonitorMode = FakeDataStoreValue(MonitorMode.AUTOMATIC)
fakeDebugMode = FakeDataStoreValue(false)
monitorControl = mockk(relaxed = true)
podMonitor = mockk<PodMonitor>().also {
every { it.devices } returns devicesFlow
}
permissionTool = mockk<PermissionTool>(relaxed = true).also {
every { it.missingPermissions } returns missingPermissionsFlow
}
generalSettings = mockk<GeneralSettings>().also {
every { it.monitorMode } returns fakeMonitorMode.mock
}
debugSettings = mockk<DebugSettings>().also {
every { it.isDebugModeEnabled } returns fakeDebugMode.mock
}
upgradeRepo = mockk<UpgradeRepo>().also {
every { it.upgradeInfo } returns upgradeInfoFlow
}
bluetoothManager = mockk<BluetoothManager2>().also {
every { it.connectedDevices } returns connectedDevicesFlow
every { it.isBluetoothEnabled } returns isBluetoothEnabledFlow
}
profilesRepo = mockk<DeviceProfilesRepo>().also {
every { it.profiles } returns profilesFlow
}
}
@AfterEach
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel() = OverviewViewModel(
dispatcherProvider = TestDispatcherProvider(testDispatcher),
monitorControl = monitorControl,
podMonitor = podMonitor,
permissionTool = permissionTool,
generalSettings = generalSettings,
debugSettings = debugSettings,
upgradeRepo = upgradeRepo,
bluetoothManager = bluetoothManager,
profilesRepo = profilesRepo,
)
@Nested
inner class StateTests {
@Test
fun `state emits with correct initial values`() = runTest(testDispatcher) {
val vm = createViewModel()
val state = vm.state.first()
state.permissions shouldBe emptySet()
state.devices shouldBe emptyList()
state.isDebugMode shouldBe false
state.isBluetoothEnabled shouldBe true
state.showUnmatchedDevices shouldBe false
}
@Test
fun `devices empty when permissions missing`() = runTest(testDispatcher) {
missingPermissionsFlow.value = setOf(Permission.BLUETOOTH)
val vm = createViewModel()
val state = vm.state.first()
state.devices shouldBe emptyList()
}
@Test
fun `devices passed through when permissions granted`() = runTest(testDispatcher) {
val device = mockk<PodDevice>(relaxed = true)
devicesFlow.value = listOf(device)
val vm = createViewModel()
val state = vm.state.first()
state.devices shouldBe listOf(device)
}
@Test
fun `profiledDevices returns only devices with non-null profile`() {
val withProfile = object : PodDevice.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = "Test")
}
val withoutProfile = object : PodDevice.Meta {
override val profile: DeviceProfile? = null
}
val profiled = mockk<PodDevice>(relaxed = true) { every { meta } returns withProfile }
val unmatched = mockk<PodDevice>(relaxed = true) { every { meta } returns withoutProfile }
val state = OverviewViewModel.State(
now = java.time.Instant.now(),
permissions = emptySet(),
devices = listOf(profiled, unmatched),
isDebugMode = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,
)
state.profiledDevices shouldBe listOf(profiled)
}
@Test
fun `unmatchedDevices returns only devices with null profile`() {
val withProfile = object : PodDevice.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = "Test")
}
val withoutProfile = object : PodDevice.Meta {
override val profile: DeviceProfile? = null
}
val profiled = mockk<PodDevice>(relaxed = true) { every { meta } returns withProfile }
val unmatched = mockk<PodDevice>(relaxed = true) { every { meta } returns withoutProfile }
val state = OverviewViewModel.State(
now = java.time.Instant.now(),
permissions = emptySet(),
devices = listOf(profiled, unmatched),
isDebugMode = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,
)
state.unmatchedDevices shouldBe listOf(unmatched)
}
}
@Nested
inner class WorkerAutolaunchTests {
@Test
fun `MANUAL mode - never starts monitor`() = runTest(testDispatcher) {
fakeMonitorMode.value = MonitorMode.MANUAL
val vm = createViewModel()
// Collect workerAutolaunch to trigger the side effect
vm.workerAutolaunch.first()
verify(exactly = 0) { monitorControl.startMonitor(any()) }
}
@Test
fun `ALWAYS mode - starts monitor when permissions OK`() = runTest(testDispatcher) {
fakeMonitorMode.value = MonitorMode.ALWAYS
val vm = createViewModel()
vm.workerAutolaunch.first()
verify(exactly = 1) { monitorControl.startMonitor(any()) }
}
@Test
fun `ALWAYS mode - does NOT start monitor when permissions missing`() = runTest(testDispatcher) {
fakeMonitorMode.value = MonitorMode.ALWAYS
missingPermissionsFlow.value = setOf(Permission.BLUETOOTH)
val vm = createViewModel()
vm.workerAutolaunch.first()
verify(exactly = 0) { monitorControl.startMonitor(any()) }
}
@Test
fun `AUTOMATIC mode - starts monitor when connected devices exist`() = runTest(testDispatcher) {
fakeMonitorMode.value = MonitorMode.AUTOMATIC
connectedDevicesFlow.value = listOf(mockk(relaxed = true))
val vm = createViewModel()
vm.workerAutolaunch.first()
verify(exactly = 1) { monitorControl.startMonitor(any()) }
}
@Test
fun `AUTOMATIC mode - does NOT start monitor when no connected devices`() = runTest(testDispatcher) {
fakeMonitorMode.value = MonitorMode.AUTOMATIC
connectedDevicesFlow.value = emptyList()
val vm = createViewModel()
vm.workerAutolaunch.first()
verify(exactly = 0) { monitorControl.startMonitor(any()) }
}
}
@Nested
inner class ActionTests {
@Test
fun `toggleUnmatchedDevices flips state`() = runTest(testDispatcher) {
val vm = createViewModel()
val initial = vm.state.first()
initial.showUnmatchedDevices shouldBe false
vm.toggleUnmatchedDevices()
val updated = vm.state.first()
updated.showUnmatchedDevices shouldBe true
}
@Test
fun `onPermissionResult calls permissionTool recheck`() = runTest(testDispatcher) {
val vm = createViewModel()
vm.onPermissionResult(true)
verify(exactly = 1) { permissionTool.recheck() }
}
@Test
fun `requestPermission emits event`() = runTest(testDispatcher) {
val vm = createViewModel()
vm.requestPermission(Permission.BLUETOOTH)
val event = vm.requestPermissionEvent.first()
event shouldBe Permission.BLUETOOTH
}
}
}
@@ -0,0 +1,182 @@
package eu.darken.capod.reaction.core.autoconnect
import eu.darken.capod.pods.core.apple.DualApplePods
import io.kotest.matchers.shouldBe
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class AutoConnectLogicTest : BaseTest() {
private lateinit var autoConnect: AutoConnect
@BeforeEach
fun setup() {
autoConnect = AutoConnect(
bluetoothManager = mockk(relaxed = true),
podMonitor = mockk(relaxed = true),
generalSettings = mockk(relaxed = true),
reactionSettings = mockk(relaxed = true),
deviceProfilesRepo = mockk(relaxed = true),
)
}
private fun evaluate(
mainDeviceAddr: String? = "AA:BB:CC:DD:EE:FF",
hasBondedDevice: Boolean = true,
isAlreadyConnected: Boolean = false,
condition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
lidState: DualApplePods.LidState? = null,
isBeingWorn: Boolean = false,
isEitherPodInEar: Boolean = false,
onePodMode: Boolean = false,
supportsEarDetection: Boolean = false,
) = autoConnect.evaluateAutoConnect(
mainDeviceAddr = mainDeviceAddr,
hasBondedDevice = hasBondedDevice,
isAlreadyConnected = isAlreadyConnected,
condition = condition,
lidState = lidState,
isBeingWorn = isBeingWorn,
isEitherPodInEar = isEitherPodInEar,
onePodMode = onePodMode,
supportsEarDetection = supportsEarDetection,
)
@Nested
inner class Preconditions {
@Test
fun `null main device address - should NOT connect`() {
evaluate(mainDeviceAddr = null).shouldConnect shouldBe false
}
@Test
fun `empty main device address - should NOT connect`() {
evaluate(mainDeviceAddr = "").shouldConnect shouldBe false
}
@Test
fun `no bonded device - should NOT connect`() {
evaluate(hasBondedDevice = false).shouldConnect shouldBe false
}
@Test
fun `already connected - should NOT connect`() {
evaluate(isAlreadyConnected = true).shouldConnect shouldBe false
}
}
@Nested
inner class WhenSeenCondition {
@Test
fun `WHEN_SEEN and not connected - should connect`() {
evaluate(condition = AutoConnectCondition.WHEN_SEEN).shouldConnect shouldBe true
}
}
@Nested
inner class CaseOpenCondition {
@Test
fun `CASE_OPEN with lid OPEN - should connect`() {
evaluate(
condition = AutoConnectCondition.CASE_OPEN,
lidState = DualApplePods.LidState.OPEN,
).shouldConnect shouldBe true
}
@Test
fun `CASE_OPEN with lid CLOSED - should NOT connect`() {
evaluate(
condition = AutoConnectCondition.CASE_OPEN,
lidState = DualApplePods.LidState.CLOSED,
).shouldConnect shouldBe false
}
@Test
fun `CASE_OPEN with lid UNKNOWN - should NOT connect`() {
evaluate(
condition = AutoConnectCondition.CASE_OPEN,
lidState = DualApplePods.LidState.UNKNOWN,
).shouldConnect shouldBe false
}
@Test
fun `CASE_OPEN with lid NOT_IN_CASE - should NOT connect`() {
evaluate(
condition = AutoConnectCondition.CASE_OPEN,
lidState = DualApplePods.LidState.NOT_IN_CASE,
).shouldConnect shouldBe false
}
@Test
fun `CASE_OPEN with null lidState (unsupported device) - should connect (permissive fallback)`() {
evaluate(
condition = AutoConnectCondition.CASE_OPEN,
lidState = null,
).shouldConnect shouldBe true
}
}
@Nested
inner class InEarCondition {
@Test
fun `IN_EAR with both pods in ear (normal mode) - should connect`() {
evaluate(
condition = AutoConnectCondition.IN_EAR,
isBeingWorn = true,
isEitherPodInEar = true,
onePodMode = false,
supportsEarDetection = true,
).shouldConnect shouldBe true
}
@Test
fun `IN_EAR with no pods in ear - should NOT connect`() {
evaluate(
condition = AutoConnectCondition.IN_EAR,
isBeingWorn = false,
isEitherPodInEar = false,
onePodMode = false,
supportsEarDetection = true,
).shouldConnect shouldBe false
}
@Test
fun `IN_EAR with one pod in ear (normal mode, not both) - should NOT connect`() {
evaluate(
condition = AutoConnectCondition.IN_EAR,
isBeingWorn = false,
isEitherPodInEar = true,
onePodMode = false,
supportsEarDetection = true,
).shouldConnect shouldBe false
}
@Test
fun `IN_EAR with one pod in ear (one-pod mode) - should connect`() {
evaluate(
condition = AutoConnectCondition.IN_EAR,
isBeingWorn = false,
isEitherPodInEar = true,
onePodMode = true,
supportsEarDetection = true,
).shouldConnect shouldBe true
}
@Test
fun `IN_EAR with unsupported device - should connect (permissive fallback)`() {
evaluate(
condition = AutoConnectCondition.IN_EAR,
isBeingWorn = false,
isEitherPodInEar = false,
supportsEarDetection = false,
).shouldConnect shouldBe true
}
}
}
@@ -0,0 +1,183 @@
package eu.darken.capod.reaction.core.popup
import eu.darken.capod.pods.core.apple.DualApplePods
import io.kotest.matchers.shouldBe
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Duration
import java.time.Instant
class PopUpReactionLogicTest : BaseTest() {
private lateinit var popUpReaction: PopUpReaction
@BeforeEach
fun setup() {
popUpReaction = PopUpReaction(
podMonitor = mockk(relaxed = true),
reactionSettings = mockk(relaxed = true),
bluetoothManager = mockk(relaxed = true),
)
}
@Nested
inner class CasePopUpTests {
private val now = Instant.ofEpochSecond(1000)
private val cooldown = Duration.ofSeconds(10)
private fun evaluate(
currentLidState: DualApplePods.LidState?,
lastShownTime: Instant? = null,
) = popUpReaction.evaluateCasePopUp(
currentLidState = currentLidState,
lastShownTime = lastShownTime,
now = now,
cooldownDuration = cooldown,
)
@Test
fun `lid OPEN with no cooldown (first show) - should show`() {
val decision = evaluate(
currentLidState = DualApplePods.LidState.OPEN,
lastShownTime = null,
)
decision.shouldShow shouldBe true
decision.shouldHide shouldBe false
}
@Test
fun `lid OPEN within cooldown - should NOT show`() {
val decision = evaluate(
currentLidState = DualApplePods.LidState.OPEN,
lastShownTime = now.minusSeconds(5),
)
decision.shouldShow shouldBe false
}
@Test
fun `lid OPEN with cooldown expired - should show`() {
val decision = evaluate(
currentLidState = DualApplePods.LidState.OPEN,
lastShownTime = now.minusSeconds(15),
)
decision.shouldShow shouldBe true
}
@Test
fun `lid OPEN with cooldown exactly at boundary - should show`() {
val decision = evaluate(
currentLidState = DualApplePods.LidState.OPEN,
lastShownTime = now.minusSeconds(10),
)
decision.shouldShow shouldBe true
}
@Test
fun `lid CLOSED - should NOT show, should hide, should reset cooldown`() {
val decision = evaluate(currentLidState = DualApplePods.LidState.CLOSED)
decision.shouldShow shouldBe false
decision.shouldHide shouldBe true
decision.shouldResetCooldown shouldBe true
}
@Test
fun `lid UNKNOWN - should NOT show, should hide, should NOT reset cooldown`() {
val decision = evaluate(currentLidState = DualApplePods.LidState.UNKNOWN)
decision.shouldShow shouldBe false
decision.shouldHide shouldBe true
decision.shouldResetCooldown shouldBe false
}
@Test
fun `lid NOT_IN_CASE - should NOT show, should hide, should NOT reset cooldown`() {
val decision = evaluate(currentLidState = DualApplePods.LidState.NOT_IN_CASE)
decision.shouldShow shouldBe false
decision.shouldHide shouldBe true
decision.shouldResetCooldown shouldBe false
}
@Test
fun `null lid state (non-DualApplePods) - should NOT show, should NOT hide`() {
val decision = evaluate(currentLidState = null)
decision.shouldShow shouldBe false
decision.shouldHide shouldBe false
decision.shouldResetCooldown shouldBe false
}
}
@Nested
inner class ConnectionPopUpTests {
private fun evaluate(
hasConnectedDevice: Boolean = true,
hasPodDevice: Boolean = true,
hasAlreadyShown: Boolean = false,
deviceAge: Duration = Duration.ofSeconds(5),
connectionAge: Duration = Duration.ofSeconds(5),
) = popUpReaction.evaluateConnectionPopUp(
hasConnectedDevice = hasConnectedDevice,
hasPodDevice = hasPodDevice,
hasAlreadyShown = hasAlreadyShown,
deviceAge = deviceAge,
connectionAge = connectionAge,
)
@Test
fun `connected + pod found + within age + not shown yet - should show`() {
evaluate().shouldShow shouldBe true
}
@Test
fun `not connected - should NOT show`() {
evaluate(hasConnectedDevice = false).shouldShow shouldBe false
}
@Test
fun `connected but no pod device - should NOT show`() {
evaluate(hasPodDevice = false).shouldShow shouldBe false
}
@Test
fun `already shown for this connection - should NOT show`() {
evaluate(hasAlreadyShown = true).shouldShow shouldBe false
}
@Test
fun `broadcast device age much older than connection (false positive) - should NOT show`() {
evaluate(
deviceAge = Duration.ofSeconds(60),
connectionAge = Duration.ofSeconds(5),
).shouldShow shouldBe false
}
@Test
fun `broadcast device age within threshold of connection - should show`() {
evaluate(
deviceAge = Duration.ofSeconds(20),
connectionAge = Duration.ofSeconds(5),
).shouldShow shouldBe true
}
@Test
fun `broadcast device age exactly at threshold - should NOT show`() {
evaluate(
deviceAge = Duration.ofSeconds(36),
connectionAge = Duration.ofSeconds(5),
).shouldShow shouldBe false
}
@Test
fun `negative durations (reversed Duration-between args) must still detect false positives`() {
// Duration.between(now, pastTimestamp) produces negative durations.
// The false-positive filter must work regardless of sign.
evaluate(
deviceAge = Duration.ofSeconds(-60),
connectionAge = Duration.ofSeconds(-5),
).shouldShow shouldBe false
}
}
}
@@ -0,0 +1,321 @@
package eu.darken.capod.reaction.ui
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavEvent
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.reaction.core.ReactionSettings
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.coroutine.TestDispatcherProvider
import testhelpers.datastore.FakeDataStoreValue
import testhelpers.livedata.InstantExecutorExtension
import org.junit.jupiter.api.extension.ExtendWith
@OptIn(ExperimentalCoroutinesApi::class)
@ExtendWith(InstantExecutorExtension::class)
class ReactionSettingsViewModelTest : BaseTest() {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
private lateinit var upgradeRepo: UpgradeRepo
private lateinit var reactionSettings: ReactionSettings
private lateinit var generalSettings: GeneralSettings
private lateinit var fakeOnePodMode: FakeDataStoreValue<Boolean>
private lateinit var fakeAutoPlay: FakeDataStoreValue<Boolean>
private lateinit var fakeAutoPause: FakeDataStoreValue<Boolean>
private lateinit var fakeAutoConnect: FakeDataStoreValue<Boolean>
private lateinit var fakeAutoConnectCondition: FakeDataStoreValue<AutoConnectCondition>
private lateinit var fakeShowPopUpOnCaseOpen: FakeDataStoreValue<Boolean>
private lateinit var fakeShowPopUpOnConnection: FakeDataStoreValue<Boolean>
private lateinit var fakeMonitorMode: FakeDataStoreValue<MonitorMode>
@BeforeEach
fun setup() {
Dispatchers.setMain(testDispatcher)
upgradeInfoFlow = MutableStateFlow(mockUpgradeInfo(isPro = false))
upgradeRepo = mockk<UpgradeRepo>().also {
every { it.upgradeInfo } returns upgradeInfoFlow
}
fakeOnePodMode = FakeDataStoreValue(false)
fakeAutoPlay = FakeDataStoreValue(false)
fakeAutoPause = FakeDataStoreValue(false)
fakeAutoConnect = FakeDataStoreValue(false)
fakeAutoConnectCondition = FakeDataStoreValue(AutoConnectCondition.WHEN_SEEN)
fakeShowPopUpOnCaseOpen = FakeDataStoreValue(false)
fakeShowPopUpOnConnection = FakeDataStoreValue(false)
reactionSettings = mockk<ReactionSettings>().also {
every { it.onePodMode } returns fakeOnePodMode.mock
every { it.autoPlay } returns fakeAutoPlay.mock
every { it.autoPause } returns fakeAutoPause.mock
every { it.autoConnect } returns fakeAutoConnect.mock
every { it.autoConnectCondition } returns fakeAutoConnectCondition.mock
every { it.showPopUpOnCaseOpen } returns fakeShowPopUpOnCaseOpen.mock
every { it.showPopUpOnConnection } returns fakeShowPopUpOnConnection.mock
}
fakeMonitorMode = FakeDataStoreValue(MonitorMode.AUTOMATIC)
generalSettings = mockk<GeneralSettings>().also {
every { it.monitorMode } returns fakeMonitorMode.mock
}
}
@AfterEach
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel() = ReactionSettingsViewModel(
dispatcherProvider = TestDispatcherProvider(testDispatcher),
reactionSettings = reactionSettings,
generalSettings = generalSettings,
upgradeRepo = upgradeRepo,
)
private fun mockUpgradeInfo(isPro: Boolean): UpgradeRepo.Info = mockk<UpgradeRepo.Info>().also {
every { it.isPro } returns isPro
}
@Nested
inner class StateTests {
@Test
fun `state combines all flows correctly`() = runTest(testDispatcher) {
fakeAutoPlay.value = true
fakeAutoPause.value = true
fakeAutoConnect.value = true
fakeOnePodMode.value = true
fakeShowPopUpOnCaseOpen.value = true
fakeShowPopUpOnConnection.value = true
fakeAutoConnectCondition.value = AutoConnectCondition.CASE_OPEN
upgradeInfoFlow.value = mockUpgradeInfo(isPro = true)
val vm = createViewModel()
val state = vm.state.first()
state.isPro shouldBe true
state.autoPlay shouldBe true
state.autoPause shouldBe true
state.autoConnect shouldBe true
state.onePodMode shouldBe true
state.showPopUpOnCaseOpen shouldBe true
state.showPopUpOnConnection shouldBe true
state.autoConnectCondition shouldBe AutoConnectCondition.CASE_OPEN
}
}
@Nested
inner class AutoPlayTests {
@Test
fun `setAutoPlay true when pro - sets setting`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = true)
val vm = createViewModel()
vm.state.first() // ensure state is initialized
vm.setAutoPlay(true)
fakeAutoPlay.value shouldBe true
}
@Test
fun `setAutoPlay true when not pro - navigates to upgrade, does NOT change setting`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = false)
val vm = createViewModel()
vm.state.first()
vm.setAutoPlay(true)
fakeAutoPlay.value shouldBe false
val event = vm.navEvents.first()
(event as NavEvent.GoTo).destination shouldBe Nav.Main.Upgrade
}
@Test
fun `setAutoPlay false - always sets (no pro check)`() = runTest(testDispatcher) {
fakeAutoPlay.value = true
val vm = createViewModel()
vm.state.first()
vm.setAutoPlay(false)
fakeAutoPlay.value shouldBe false
}
}
@Nested
inner class AutoPauseTests {
@Test
fun `setAutoPause true when pro - sets setting`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = true)
val vm = createViewModel()
vm.state.first()
vm.setAutoPause(true)
fakeAutoPause.value shouldBe true
}
@Test
fun `setAutoPause true when not pro - navigates to upgrade`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = false)
val vm = createViewModel()
vm.state.first()
vm.setAutoPause(true)
fakeAutoPause.value shouldBe false
val event = vm.navEvents.first()
(event as NavEvent.GoTo).destination shouldBe Nav.Main.Upgrade
}
@Test
fun `setAutoPause false - always sets`() = runTest(testDispatcher) {
fakeAutoPause.value = true
val vm = createViewModel()
vm.state.first()
vm.setAutoPause(false)
fakeAutoPause.value shouldBe false
}
}
@Nested
inner class AutoConnectTests {
@Test
fun `setAutoConnect true when pro - sets setting AND sets monitorMode to ALWAYS`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = true)
val vm = createViewModel()
vm.state.first()
vm.setAutoConnect(true)
fakeAutoConnect.value shouldBe true
fakeMonitorMode.value shouldBe MonitorMode.ALWAYS
}
@Test
fun `setAutoConnect true when not pro - navigates to upgrade`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = false)
val vm = createViewModel()
vm.state.first()
vm.setAutoConnect(true)
fakeAutoConnect.value shouldBe false
val event = vm.navEvents.first()
(event as NavEvent.GoTo).destination shouldBe Nav.Main.Upgrade
}
@Test
fun `setAutoConnect false - sets setting, does NOT change monitorMode`() = runTest(testDispatcher) {
fakeAutoConnect.value = true
fakeMonitorMode.value = MonitorMode.ALWAYS
val vm = createViewModel()
vm.state.first()
vm.setAutoConnect(false)
fakeAutoConnect.value shouldBe false
fakeMonitorMode.value shouldBe MonitorMode.ALWAYS
}
}
@Nested
inner class PopUpTests {
@Test
fun `setShowPopUpOnCaseOpen true when pro - sets setting`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = true)
val vm = createViewModel()
vm.state.first()
vm.setShowPopUpOnCaseOpen(true)
fakeShowPopUpOnCaseOpen.value shouldBe true
}
@Test
fun `setShowPopUpOnCaseOpen true when not pro - navigates to upgrade`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = false)
val vm = createViewModel()
vm.state.first()
vm.setShowPopUpOnCaseOpen(true)
fakeShowPopUpOnCaseOpen.value shouldBe false
val event = vm.navEvents.first()
(event as NavEvent.GoTo).destination shouldBe Nav.Main.Upgrade
}
@Test
fun `setShowPopUpOnConnection true when pro - sets setting`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = true)
val vm = createViewModel()
vm.state.first()
vm.setShowPopUpOnConnection(true)
fakeShowPopUpOnConnection.value shouldBe true
}
@Test
fun `setShowPopUpOnConnection true when not pro - navigates to upgrade`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = false)
val vm = createViewModel()
vm.state.first()
vm.setShowPopUpOnConnection(true)
fakeShowPopUpOnConnection.value shouldBe false
val event = vm.navEvents.first()
(event as NavEvent.GoTo).destination shouldBe Nav.Main.Upgrade
}
}
@Nested
inner class DirectSettingTests {
@Test
fun `setOnePodMode - direct assignment (no pro check)`() = runTest(testDispatcher) {
val vm = createViewModel()
vm.setOnePodMode(true)
fakeOnePodMode.value shouldBe true
}
@Test
fun `setAutoConnectCondition - direct assignment (no pro check)`() = runTest(testDispatcher) {
val vm = createViewModel()
vm.setAutoConnectCondition(AutoConnectCondition.IN_EAR)
fakeAutoConnectCondition.value shouldBe AutoConnectCondition.IN_EAR
}
}
}
@@ -0,0 +1,29 @@
package testhelpers.datastore
import eu.darken.capod.common.datastore.DataStoreValue
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
class FakeDataStoreValue<T>(initial: T) {
private val _flow = MutableStateFlow(initial)
val mock: DataStoreValue<T> = mockk<DataStoreValue<T>>().also { m ->
every { m.flow } returns _flow
coEvery { m.update(any()) } coAnswers {
@Suppress("UNCHECKED_CAST")
val transform = firstArg<(T) -> T>()
val old = _flow.value
val new = transform(old)
_flow.value = new
DataStoreValue.Updated(old, new)
}
}
var value: T
get() = _flow.value
set(v) {
_flow.value = v
}
}