refactor: Standardize DeviceSettingsViewModelTest with runTest and explicit VM cleanup

This commit is contained in:
darken
2026-04-14 11:28:41 +02:00
committed by Matthias Urhahn
parent dae12e588f
commit 7d08a44a19
@@ -6,6 +6,7 @@ import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings 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.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
@@ -23,13 +24,15 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import testhelpers.coroutine.runTest2 import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
@@ -39,6 +42,7 @@ import org.junit.jupiter.api.extension.ExtendWith
import testhelpers.BaseTest import testhelpers.BaseTest
import testhelpers.TestTimeSource import testhelpers.TestTimeSource
import testhelpers.coroutine.TestDispatcherProvider import testhelpers.coroutine.TestDispatcherProvider
import testhelpers.datastore.FakeDataStoreValue
import testhelpers.livedata.InstantExecutorExtension import testhelpers.livedata.InstantExecutorExtension
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -49,12 +53,15 @@ class DeviceSettingsViewModelTest : BaseTest() {
private val testAddress: BluetoothAddress = "AA:BB:CC:DD:EE:FF" private val testAddress: BluetoothAddress = "AA:BB:CC:DD:EE:FF"
private var vm: DeviceSettingsViewModel? = null
private lateinit var deviceMonitor: DeviceMonitor private lateinit var deviceMonitor: DeviceMonitor
private lateinit var aapManager: AapConnectionManager private lateinit var aapManager: AapConnectionManager
private lateinit var upgradeRepo: UpgradeRepo private lateinit var upgradeRepo: UpgradeRepo
private lateinit var bluetoothManager: BluetoothManager2 private lateinit var bluetoothManager: BluetoothManager2
private lateinit var profilesRepo: DeviceProfilesRepo private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var generalSettings: GeneralSettings private lateinit var generalSettings: GeneralSettings
private lateinit var fakeMonitorMode: FakeDataStoreValue<MonitorMode>
private val timeSource: TimeSource = TestTimeSource() private val timeSource: TimeSource = TestTimeSource()
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>> private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
@@ -74,7 +81,6 @@ class DeviceSettingsViewModelTest : BaseTest() {
every { it.isPro } returns false every { it.isPro } returns false
}) })
// Synthesize a PodDevice for forceConnect/sendInternal lookups (currentAddress()).
val syntheticDevice = mockk<PodDevice>().also { val syntheticDevice = mockk<PodDevice>().also {
every { it.profileId } returns testAddress every { it.profileId } returns testAddress
every { it.address } returns testAddress every { it.address } returns testAddress
@@ -94,11 +100,16 @@ class DeviceSettingsViewModelTest : BaseTest() {
every { connectedDevices } returns connectedDevicesFlow every { connectedDevices } returns connectedDevicesFlow
} }
profilesRepo = mockk(relaxed = true) profilesRepo = mockk(relaxed = true)
generalSettings = mockk(relaxed = true) fakeMonitorMode = FakeDataStoreValue(MonitorMode.AUTOMATIC)
generalSettings = mockk<GeneralSettings>().also {
every { it.monitorMode } returns fakeMonitorMode.mock
}
} }
@AfterEach @AfterEach
fun teardown() { fun teardown() {
vm?.vmScope?.cancel()
vm = null
Dispatchers.resetMain() Dispatchers.resetMain()
} }
@@ -111,10 +122,19 @@ class DeviceSettingsViewModelTest : BaseTest() {
profilesRepo = profilesRepo, profilesRepo = profilesRepo,
generalSettings = generalSettings, generalSettings = generalSettings,
timeSource = timeSource, timeSource = timeSource,
) ).also { vm = it }
private fun runVmTest(testBody: suspend TestScope.() -> Unit) = runTest(testDispatcher) {
try {
testBody()
} finally {
vm?.vmScope?.cancel()
vm = null
}
}
@Test @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) val bonded = mockBondedDevice(testAddress)
every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded))
coEvery { bluetoothManager.nudgeConnection(bonded) } returns true coEvery { bluetoothManager.nudgeConnection(bonded) } returns true
@@ -126,12 +146,11 @@ class DeviceSettingsViewModelTest : BaseTest() {
vm.forceConnect() vm.forceConnect()
coVerify { bluetoothManager.nudgeConnection(bonded) } coVerify { bluetoothManager.nudgeConnection(bonded) }
// After completion, the in-flight flag should be reset
vm.state.first().isForceConnecting shouldBe false vm.state.first().isForceConnecting shouldBe false
} }
@Test @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) val bonded = mockBondedDevice(testAddress)
every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded))
coEvery { bluetoothManager.nudgeConnection(bonded) } returns false coEvery { bluetoothManager.nudgeConnection(bonded) } returns false
@@ -148,26 +167,25 @@ class DeviceSettingsViewModelTest : BaseTest() {
} }
@Test @Test
fun `forceConnect when nudge unavailable - emits OpenBluetoothSettings without calling nudge`() = fun `forceConnect when nudge unavailable - emits OpenBluetoothSettings without calling nudge`() = runVmTest {
runTest2(autoCancel = true) { val bonded = mockBondedDevice(testAddress)
val bonded = mockBondedDevice(testAddress) every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded))
every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) every { bluetoothManager.isNudgeAvailable } returns false
every { bluetoothManager.isNudgeAvailable } returns false
val vm = createViewModel() val vm = createViewModel()
vm.initialize(testAddress) vm.initialize(testAddress)
vm.state.first() vm.state.first()
vm.forceConnect() vm.forceConnect()
val event = vm.events.first() val event = vm.events.first()
event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings
coVerify(exactly = 0) { bluetoothManager.nudgeConnection(any()) } coVerify(exactly = 0) { bluetoothManager.nudgeConnection(any()) }
vm.state.first().isForceConnecting shouldBe false vm.state.first().isForceConnecting shouldBe false
} }
@Test @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()) every { bluetoothManager.bondedDevices() } returns flowOf(emptySet())
val vm = createViewModel() val vm = createViewModel()
@@ -183,69 +201,61 @@ class DeviceSettingsViewModelTest : BaseTest() {
} }
@Test @Test
fun `forceConnect when bondedDevices throws SecurityException - emits OpenBluetoothSettings`() = fun `forceConnect when bondedDevices throws SecurityException - emits OpenBluetoothSettings`() = runVmTest {
runTest2(autoCancel = true) { every { bluetoothManager.bondedDevices() } throws SecurityException("BLUETOOTH_CONNECT denied")
every { bluetoothManager.bondedDevices() } throws SecurityException("BLUETOOTH_CONNECT denied")
val vm = createViewModel() val vm = createViewModel()
vm.initialize(testAddress) vm.initialize(testAddress)
vm.state.first() vm.state.first()
vm.forceConnect() vm.forceConnect()
val event = vm.events.first() val event = vm.events.first()
event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings
coVerify(exactly = 0) { bluetoothManager.nudgeConnection(any()) } coVerify(exactly = 0) { bluetoothManager.nudgeConnection(any()) }
vm.state.first().isForceConnecting shouldBe false vm.state.first().isForceConnecting shouldBe false
} }
@Test @Test
fun `forceConnect when nudgeConnection throws - emits OpenBluetoothSettings and resets in-flight`() = fun `forceConnect when nudgeConnection throws - emits OpenBluetoothSettings and resets in-flight`() = runVmTest {
runTest2(autoCancel = true) { val bonded = mockBondedDevice(testAddress)
val bonded = mockBondedDevice(testAddress) every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded))
every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) coEvery { bluetoothManager.nudgeConnection(bonded) } throws RuntimeException("oops")
coEvery { bluetoothManager.nudgeConnection(bonded) } throws RuntimeException("oops")
val vm = createViewModel() val vm = createViewModel()
vm.initialize(testAddress) vm.initialize(testAddress)
vm.state.first() vm.state.first()
vm.forceConnect() vm.forceConnect()
val event = vm.events.first() val event = vm.events.first()
event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings
vm.state.first().isForceConnecting shouldBe false vm.state.first().isForceConnecting shouldBe false
} }
@Test @Test
fun `forceConnect concurrent calls - second call is a no-op while first in flight`() = fun `forceConnect concurrent calls - second call is a no-op while first in flight`() = runVmTest {
runTest2(autoCancel = true) { val bonded = mockBondedDevice(testAddress)
val bonded = mockBondedDevice(testAddress) every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded))
every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded))
// Block the first nudgeConnection call until we explicitly release it. val gate = CompletableDeferred<Boolean>()
val gate = CompletableDeferred<Boolean>() coEvery { bluetoothManager.nudgeConnection(bonded) } coAnswers { gate.await() }
coEvery { bluetoothManager.nudgeConnection(bonded) } coAnswers { gate.await() }
val vm = createViewModel() val vm = createViewModel()
vm.initialize(testAddress) vm.initialize(testAddress)
vm.state.first() vm.state.first()
// Start the first call — it will suspend on the gate. vm.forceConnect()
vm.forceConnect() vm.forceConnect()
// While the first call is still in-flight, the second call should be a no-op.
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) }
coVerify(exactly = 1) { bluetoothManager.nudgeConnection(bonded) } vm.state.first().isForceConnecting shouldBe false
vm.state.first().isForceConnecting shouldBe false }
}
@Test @Test
fun `setDeviceName forwards SetDeviceName command to aapManager`() = runTest2(autoCancel = true) { fun `setDeviceName forwards SetDeviceName command to aapManager`() = runVmTest {
val vm = createViewModel() val vm = createViewModel()
vm.initialize(testAddress) vm.initialize(testAddress)
vm.state.first() vm.state.first()
@@ -256,19 +266,16 @@ class DeviceSettingsViewModelTest : BaseTest() {
} }
@Test @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() val vm = createViewModel()
// Intentionally skip initialize — targetAddress stays null.
vm.setDeviceName("NewName") vm.setDeviceName("NewName")
// `any<AapCommand>()` 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 } verify { aapManager wasNot Called }
} }
@Test @Test
fun `setDeviceName failure emits SendFailed event`() = runTest2(autoCancel = true) { fun `setDeviceName failure emits SendFailed event`() = runVmTest {
val failure = IllegalStateException("socket closed") val failure = IllegalStateException("socket closed")
coEvery { coEvery {
aapManager.sendCommand(testAddress, AapCommand.SetDeviceName("NewName")) aapManager.sendCommand(testAddress, AapCommand.SetDeviceName("NewName"))
@@ -287,7 +294,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
} }
@Test @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)) connectedDevicesFlow.value = listOf(mockBondedDevice(testAddress))
val vm = createViewModel() val vm = createViewModel()
@@ -297,7 +304,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
} }
@Test @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")) connectedDevicesFlow.value = listOf(mockBondedDevice("XX:XX:XX:XX:XX:XX"))
val vm = createViewModel() val vm = createViewModel()
@@ -307,7 +314,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
} }
@Test @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() } every { bluetoothManager.connectedDevices } returns flow { awaitCancellation() }
val vm = createViewModel() val vm = createViewModel()