diff --git a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt index b32e0d6b..fdf455b6 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt @@ -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") } diff --git a/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt b/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt index cee6ec66..9dedb59e 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt @@ -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") } diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt new file mode 100644 index 00000000..696163b6 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt @@ -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> + private lateinit var devicesFlow: MutableStateFlow> + private lateinit var connectedDevicesFlow: MutableStateFlow> + private lateinit var isBluetoothEnabledFlow: MutableStateFlow + private lateinit var profilesFlow: MutableStateFlow> + private lateinit var upgradeInfoFlow: MutableStateFlow + private lateinit var fakeMonitorMode: FakeDataStoreValue + private lateinit var fakeDebugMode: FakeDataStoreValue + + @BeforeEach + fun setup() { + Dispatchers.setMain(testDispatcher) + + missingPermissionsFlow = MutableStateFlow(emptySet()) + devicesFlow = MutableStateFlow(emptyList()) + connectedDevicesFlow = MutableStateFlow(emptyList()) + isBluetoothEnabledFlow = MutableStateFlow(true) + profilesFlow = MutableStateFlow(emptyList()) + upgradeInfoFlow = MutableStateFlow(mockk(relaxed = true)) + fakeMonitorMode = FakeDataStoreValue(MonitorMode.AUTOMATIC) + fakeDebugMode = FakeDataStoreValue(false) + + monitorControl = mockk(relaxed = true) + + podMonitor = mockk().also { + every { it.devices } returns devicesFlow + } + + permissionTool = mockk(relaxed = true).also { + every { it.missingPermissions } returns missingPermissionsFlow + } + + generalSettings = mockk().also { + every { it.monitorMode } returns fakeMonitorMode.mock + } + + debugSettings = mockk().also { + every { it.isDebugModeEnabled } returns fakeDebugMode.mock + } + + upgradeRepo = mockk().also { + every { it.upgradeInfo } returns upgradeInfoFlow + } + + bluetoothManager = mockk().also { + every { it.connectedDevices } returns connectedDevicesFlow + every { it.isBluetoothEnabled } returns isBluetoothEnabledFlow + } + + profilesRepo = mockk().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(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(relaxed = true) { every { meta } returns withProfile } + val unmatched = mockk(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(relaxed = true) { every { meta } returns withProfile } + val unmatched = mockk(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 + } + } +} diff --git a/app/src/test/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectLogicTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectLogicTest.kt new file mode 100644 index 00000000..19b77156 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectLogicTest.kt @@ -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 + } + } +} diff --git a/app/src/test/java/eu/darken/capod/reaction/core/popup/PopUpReactionLogicTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/popup/PopUpReactionLogicTest.kt new file mode 100644 index 00000000..c5c2db03 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/reaction/core/popup/PopUpReactionLogicTest.kt @@ -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 + } + } +} diff --git a/app/src/test/java/eu/darken/capod/reaction/ui/ReactionSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/reaction/ui/ReactionSettingsViewModelTest.kt new file mode 100644 index 00000000..666f4d82 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/reaction/ui/ReactionSettingsViewModelTest.kt @@ -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 + private lateinit var upgradeRepo: UpgradeRepo + private lateinit var reactionSettings: ReactionSettings + private lateinit var generalSettings: GeneralSettings + + private lateinit var fakeOnePodMode: FakeDataStoreValue + private lateinit var fakeAutoPlay: FakeDataStoreValue + private lateinit var fakeAutoPause: FakeDataStoreValue + private lateinit var fakeAutoConnect: FakeDataStoreValue + private lateinit var fakeAutoConnectCondition: FakeDataStoreValue + private lateinit var fakeShowPopUpOnCaseOpen: FakeDataStoreValue + private lateinit var fakeShowPopUpOnConnection: FakeDataStoreValue + private lateinit var fakeMonitorMode: FakeDataStoreValue + + @BeforeEach + fun setup() { + Dispatchers.setMain(testDispatcher) + + upgradeInfoFlow = MutableStateFlow(mockUpgradeInfo(isPro = false)) + upgradeRepo = mockk().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().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().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().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 + } + } +} diff --git a/app/src/test/java/testhelpers/datastore/FakeDataStoreValue.kt b/app/src/test/java/testhelpers/datastore/FakeDataStoreValue.kt new file mode 100644 index 00000000..7303f1ff --- /dev/null +++ b/app/src/test/java/testhelpers/datastore/FakeDataStoreValue.kt @@ -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(initial: T) { + private val _flow = MutableStateFlow(initial) + + val mock: DataStoreValue = mockk>().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 + } +}