From 450a187b65e78fd8453c2c4535b8a150a9c9fc05 Mon Sep 17 00:00:00 2001 From: darken Date: Sat, 7 Mar 2026 14:52:38 +0100 Subject: [PATCH] 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. --- .../reaction/core/autoconnect/AutoConnect.kt | 84 +++-- .../reaction/core/popup/PopUpReaction.kt | 162 ++++++--- .../main/ui/overview/OverviewViewModelTest.kt | 303 +++++++++++++++++ .../core/autoconnect/AutoConnectLogicTest.kt | 182 ++++++++++ .../core/popup/PopUpReactionLogicTest.kt | 183 ++++++++++ .../ui/ReactionSettingsViewModelTest.kt | 321 ++++++++++++++++++ .../datastore/FakeDataStoreValue.kt | 29 ++ 7 files changed, 1199 insertions(+), 65 deletions(-) create mode 100644 app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt create mode 100644 app/src/test/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectLogicTest.kt create mode 100644 app/src/test/java/eu/darken/capod/reaction/core/popup/PopUpReactionLogicTest.kt create mode 100644 app/src/test/java/eu/darken/capod/reaction/ui/ReactionSettingsViewModelTest.kt create mode 100644 app/src/test/java/testhelpers/datastore/FakeDataStoreValue.kt 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 + } +}