From 7d08a44a197f67e1214b82b85b6bcbd8a039939e Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 14 Apr 2026 11:24:30 +0200 Subject: [PATCH] refactor: Standardize DeviceSettingsViewModelTest with runTest and explicit VM cleanup --- .../DeviceSettingsViewModelTest.kt | 159 +++++++++--------- 1 file changed, 83 insertions(+), 76 deletions(-) diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index 00596fee..a78a3fc6 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -6,6 +6,7 @@ import eu.darken.capod.common.bluetooth.BluetoothDevice2 import eu.darken.capod.common.bluetooth.BluetoothManager2 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.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.pods.core.apple.aap.AapConnectionManager @@ -23,13 +24,15 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain -import testhelpers.coroutine.runTest2 +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withTimeout import org.junit.jupiter.api.AfterEach @@ -39,6 +42,7 @@ import org.junit.jupiter.api.extension.ExtendWith import testhelpers.BaseTest import testhelpers.TestTimeSource import testhelpers.coroutine.TestDispatcherProvider +import testhelpers.datastore.FakeDataStoreValue import testhelpers.livedata.InstantExecutorExtension @OptIn(ExperimentalCoroutinesApi::class) @@ -49,12 +53,15 @@ class DeviceSettingsViewModelTest : BaseTest() { private val testAddress: BluetoothAddress = "AA:BB:CC:DD:EE:FF" + private var vm: DeviceSettingsViewModel? = null + private lateinit var deviceMonitor: DeviceMonitor private lateinit var aapManager: AapConnectionManager private lateinit var upgradeRepo: UpgradeRepo private lateinit var bluetoothManager: BluetoothManager2 private lateinit var profilesRepo: DeviceProfilesRepo private lateinit var generalSettings: GeneralSettings + private lateinit var fakeMonitorMode: FakeDataStoreValue private val timeSource: TimeSource = TestTimeSource() private lateinit var devicesFlow: MutableStateFlow> @@ -74,7 +81,6 @@ class DeviceSettingsViewModelTest : BaseTest() { every { it.isPro } returns false }) - // Synthesize a PodDevice for forceConnect/sendInternal lookups (currentAddress()). val syntheticDevice = mockk().also { every { it.profileId } returns testAddress every { it.address } returns testAddress @@ -94,11 +100,16 @@ class DeviceSettingsViewModelTest : BaseTest() { every { connectedDevices } returns connectedDevicesFlow } profilesRepo = mockk(relaxed = true) - generalSettings = mockk(relaxed = true) + fakeMonitorMode = FakeDataStoreValue(MonitorMode.AUTOMATIC) + generalSettings = mockk().also { + every { it.monitorMode } returns fakeMonitorMode.mock + } } @AfterEach fun teardown() { + vm?.vmScope?.cancel() + vm = null Dispatchers.resetMain() } @@ -111,10 +122,19 @@ class DeviceSettingsViewModelTest : BaseTest() { profilesRepo = profilesRepo, generalSettings = generalSettings, timeSource = timeSource, - ) + ).also { vm = it } + + private fun runVmTest(testBody: suspend TestScope.() -> Unit) = runTest(testDispatcher) { + try { + testBody() + } finally { + vm?.vmScope?.cancel() + vm = null + } + } @Test - fun `forceConnect happy path - bonded exists, nudge accepted, no event emitted`() = runTest2(autoCancel = true) { + fun `forceConnect happy path - bonded exists, nudge accepted, no event emitted`() = runVmTest { val bonded = mockBondedDevice(testAddress) every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) coEvery { bluetoothManager.nudgeConnection(bonded) } returns true @@ -126,12 +146,11 @@ class DeviceSettingsViewModelTest : BaseTest() { vm.forceConnect() coVerify { bluetoothManager.nudgeConnection(bonded) } - // After completion, the in-flight flag should be reset vm.state.first().isForceConnecting shouldBe false } @Test - fun `forceConnect when nudge not accepted - emits OpenBluetoothSettings`() = runTest2(autoCancel = true) { + fun `forceConnect when nudge not accepted - emits OpenBluetoothSettings`() = runVmTest { val bonded = mockBondedDevice(testAddress) every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) coEvery { bluetoothManager.nudgeConnection(bonded) } returns false @@ -148,26 +167,25 @@ class DeviceSettingsViewModelTest : BaseTest() { } @Test - fun `forceConnect when nudge unavailable - emits OpenBluetoothSettings without calling nudge`() = - runTest2(autoCancel = true) { - val bonded = mockBondedDevice(testAddress) - every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) - every { bluetoothManager.isNudgeAvailable } returns false + fun `forceConnect when nudge unavailable - emits OpenBluetoothSettings without calling nudge`() = runVmTest { + val bonded = mockBondedDevice(testAddress) + every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) + every { bluetoothManager.isNudgeAvailable } returns false - val vm = createViewModel() - vm.initialize(testAddress) - vm.state.first() + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() - vm.forceConnect() + vm.forceConnect() - val event = vm.events.first() - event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings - coVerify(exactly = 0) { bluetoothManager.nudgeConnection(any()) } - vm.state.first().isForceConnecting shouldBe false - } + val event = vm.events.first() + event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings + coVerify(exactly = 0) { bluetoothManager.nudgeConnection(any()) } + vm.state.first().isForceConnecting shouldBe false + } @Test - fun `forceConnect when no bonded device - emits OpenBluetoothSettings`() = runTest2(autoCancel = true) { + fun `forceConnect when no bonded device - emits OpenBluetoothSettings`() = runVmTest { every { bluetoothManager.bondedDevices() } returns flowOf(emptySet()) val vm = createViewModel() @@ -183,69 +201,61 @@ class DeviceSettingsViewModelTest : BaseTest() { } @Test - fun `forceConnect when bondedDevices throws SecurityException - emits OpenBluetoothSettings`() = - runTest2(autoCancel = true) { - every { bluetoothManager.bondedDevices() } throws SecurityException("BLUETOOTH_CONNECT denied") + fun `forceConnect when bondedDevices throws SecurityException - emits OpenBluetoothSettings`() = runVmTest { + every { bluetoothManager.bondedDevices() } throws SecurityException("BLUETOOTH_CONNECT denied") - val vm = createViewModel() - vm.initialize(testAddress) - vm.state.first() + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() - vm.forceConnect() + vm.forceConnect() - val event = vm.events.first() - event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings - coVerify(exactly = 0) { bluetoothManager.nudgeConnection(any()) } - vm.state.first().isForceConnecting shouldBe false - } + val event = vm.events.first() + event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings + coVerify(exactly = 0) { bluetoothManager.nudgeConnection(any()) } + vm.state.first().isForceConnecting shouldBe false + } @Test - fun `forceConnect when nudgeConnection throws - emits OpenBluetoothSettings and resets in-flight`() = - runTest2(autoCancel = true) { - val bonded = mockBondedDevice(testAddress) - every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) - coEvery { bluetoothManager.nudgeConnection(bonded) } throws RuntimeException("oops") + fun `forceConnect when nudgeConnection throws - emits OpenBluetoothSettings and resets in-flight`() = runVmTest { + val bonded = mockBondedDevice(testAddress) + every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) + coEvery { bluetoothManager.nudgeConnection(bonded) } throws RuntimeException("oops") - val vm = createViewModel() - vm.initialize(testAddress) - vm.state.first() + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() - vm.forceConnect() + vm.forceConnect() - val event = vm.events.first() - event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings - vm.state.first().isForceConnecting shouldBe false - } + val event = vm.events.first() + event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings + vm.state.first().isForceConnecting shouldBe false + } @Test - fun `forceConnect concurrent calls - second call is a no-op while first in flight`() = - runTest2(autoCancel = true) { - val bonded = mockBondedDevice(testAddress) - every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) + fun `forceConnect concurrent calls - second call is a no-op while first in flight`() = runVmTest { + val bonded = mockBondedDevice(testAddress) + every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) - // Block the first nudgeConnection call until we explicitly release it. - val gate = CompletableDeferred() - coEvery { bluetoothManager.nudgeConnection(bonded) } coAnswers { gate.await() } + val gate = CompletableDeferred() + coEvery { bluetoothManager.nudgeConnection(bonded) } coAnswers { gate.await() } - val vm = createViewModel() - vm.initialize(testAddress) - vm.state.first() + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() - // Start the first call — it will suspend on the gate. - vm.forceConnect() - // While the first call is still in-flight, the second call should be a no-op. - vm.forceConnect() + vm.forceConnect() + vm.forceConnect() - // Release the first call. - gate.complete(true) + gate.complete(true) - // nudgeConnection should have been invoked exactly once across both forceConnect calls. - coVerify(exactly = 1) { bluetoothManager.nudgeConnection(bonded) } - vm.state.first().isForceConnecting shouldBe false - } + coVerify(exactly = 1) { bluetoothManager.nudgeConnection(bonded) } + vm.state.first().isForceConnecting shouldBe false + } @Test - fun `setDeviceName forwards SetDeviceName command to aapManager`() = runTest2(autoCancel = true) { + fun `setDeviceName forwards SetDeviceName command to aapManager`() = runVmTest { val vm = createViewModel() vm.initialize(testAddress) vm.state.first() @@ -256,19 +266,16 @@ class DeviceSettingsViewModelTest : BaseTest() { } @Test - fun `setDeviceName when no target address is a no-op`() = runTest2(autoCancel = true) { + fun `setDeviceName when no target address is a no-op`() = runVmTest { val vm = createViewModel() - // Intentionally skip initialize — targetAddress stays null. vm.setDeviceName("NewName") - // `any()` can't be used here (AapCommand is sealed, mockk can't stub it), - // so verify the entire manager was not called instead. verify { aapManager wasNot Called } } @Test - fun `setDeviceName failure emits SendFailed event`() = runTest2(autoCancel = true) { + fun `setDeviceName failure emits SendFailed event`() = runVmTest { val failure = IllegalStateException("socket closed") coEvery { aapManager.sendCommand(testAddress, AapCommand.SetDeviceName("NewName")) @@ -287,7 +294,7 @@ class DeviceSettingsViewModelTest : BaseTest() { } @Test - fun `isClassicallyConnected is true when device address is in connected devices`() = runTest2(autoCancel = true) { + fun `isClassicallyConnected is true when device address is in connected devices`() = runVmTest { connectedDevicesFlow.value = listOf(mockBondedDevice(testAddress)) val vm = createViewModel() @@ -297,7 +304,7 @@ class DeviceSettingsViewModelTest : BaseTest() { } @Test - fun `isClassicallyConnected is false when device address is not in connected devices`() = runTest2(autoCancel = true) { + fun `isClassicallyConnected is false when device address is not in connected devices`() = runVmTest { connectedDevicesFlow.value = listOf(mockBondedDevice("XX:XX:XX:XX:XX:XX")) val vm = createViewModel() @@ -307,7 +314,7 @@ class DeviceSettingsViewModelTest : BaseTest() { } @Test - fun `state emits even when connected devices flow has not emitted yet`() = runTest2(autoCancel = true) { + fun `state emits even when connected devices flow has not emitted yet`() = runVmTest { every { bluetoothManager.connectedDevices } returns flow { awaitCancellation() } val vm = createViewModel()