From c813be822857275337a8b3fb905e485b3e42c463 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:13:01 -0500 Subject: [PATCH] test(feature): Expand ViewModel coverage with Turbine and Mokkery --- .../connections/ScannerViewModelTest.kt | 227 +++++++----------- .../feature/messaging/MessageViewModelTest.kt | 137 ++++++++++- .../radio/RadioConfigViewModelTest.kt | 72 +++++- 3 files changed, 286 insertions(+), 150 deletions(-) diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt index 3f17e86d9..d5ea19ab4 100644 --- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt @@ -16,174 +16,113 @@ */ package org.meshtastic.feature.connections -import io.kotest.matchers.shouldBe - +import app.cash.turbine.test +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock +import dev.mokkery.matcher.any import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest -import org.meshtastic.core.datastore.RecentAddressesDataSource -import org.meshtastic.core.model.DeviceType +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.feature.connections.model.DeviceListEntry +import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.feature.connections.model.DiscoveredDevices import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue +import kotlin.test.assertNotNull -/** - * Tests for [ScannerViewModel] covering core device selection, connection, and state management. - * - * Uses `core:testing` fakes where available and mockk for remaining dependencies. - */ class ScannerViewModelTest { -/* - private lateinit var viewModel: ScannerViewModel - private lateinit var radioController: RadioController - private lateinit var serviceRepository: ServiceRepository - private lateinit var radioInterfaceService: RadioInterfaceService - private lateinit var recentAddressesDataSource: RecentAddressesDataSource - private lateinit var getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase + private val serviceRepository: ServiceRepository = mock(MockMode.autofill) + private val radioController: RadioController = mock(MockMode.autofill) + private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) + private val recentAddressesDataSource: RecentAddressesDataSource = mock(MockMode.autofill) + private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase = mock(MockMode.autofill) + private val bleScanner: org.meshtastic.core.ble.BleScanner = mock(MockMode.autofill) - private fun setUp() { - radioInterfaceService = - every { isMockInterface() } returns false - every { currentDeviceAddressFlow } returns MutableStateFlow(null) - every { supportedDeviceTypes } returns listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB) - } - getDiscoveredDevicesUseCase = - object : GetDiscoveredDevicesUseCase { - override fun invoke(showMock: Boolean) = flowOf(DiscoveredDevices()) - } + private val connectionProgressFlow = MutableStateFlow(null) + private val discoveredDevicesFlow = MutableStateFlow(DiscoveredDevices()) - viewModel = - ScannerViewModel( - serviceRepository = serviceRepository, - radioController = radioController, - radioInterfaceService = radioInterfaceService, - recentAddressesDataSource = recentAddressesDataSource, - getDiscoveredDevicesUseCase = getDiscoveredDevicesUseCase, - ) - } + @BeforeTest + fun setUp() { + every { radioInterfaceService.isMockInterface() } returns false + every { radioInterfaceService.currentDeviceAddressFlow } returns MutableStateFlow(null) + every { radioInterfaceService.supportedDeviceTypes } returns emptyList() + + every { serviceRepository.connectionProgress } returns connectionProgressFlow + every { getDiscoveredDevicesUseCase.invoke(any()) } returns discoveredDevicesFlow + every { recentAddressesDataSource.recentAddresses } returns MutableStateFlow(emptyList()) - @Test - fun testInitialization() = runTest { - setUp() - assertNull(viewModel.errorText.value, "Error text starts as null before connectionProgress emits") - } + connectionProgressFlow.value = null + discoveredDevicesFlow.value = DiscoveredDevices() - @Test - fun testSetErrorText() = runTest { - setUp() - viewModel.setErrorText("Test error") - viewModel.errorText.value shouldBe "Test error" - } - - @Test - fun testDisconnect() = runTest { - setUp() - viewModel.disconnect() - verify { radioController.setDeviceAddress(NO_DEVICE_SELECTED) } - } - - @Test - fun testChangeDeviceAddress() = runTest { - setUp() - viewModel.changeDeviceAddress("x12:34:56:78:90:AB") - verify { radioController.setDeviceAddress("x12:34:56:78:90:AB") } - } - - @Test - fun testOnSelectedBleDeviceBonded() = runTest { - setUp() - val bleDevice = - every { bonded } returns true - every { fullAddress } returns "xAA:BB:CC:DD:EE:FF" - } - val result = viewModel.onSelected(bleDevice) - assertTrue(result, "Should return true for bonded BLE device") - verify { radioController.setDeviceAddress("xAA:BB:CC:DD:EE:FF") } - } - - @Test - fun testOnSelectedBleDeviceNotBonded() = runTest { - setUp() - val result = viewModel.onSelected(bleDevice) - assertFalse(result, "Should return false for unbonded BLE device (triggers bonding)") - } - - @Test - fun testOnSelectedTcpDevice() = runTest { - setUp() - val tcpDevice = DeviceListEntry.Tcp("Meshtastic_1234", "t192.168.1.100") - val result = viewModel.onSelected(tcpDevice) - assertTrue(result, "Should return true for TCP device") - verify { radioController.setDeviceAddress("t192.168.1.100") } - } - - @Test - fun testOnSelectedMockDevice() = runTest { - setUp() - val mockDevice = DeviceListEntry.Mock("Demo Mode") - val result = viewModel.onSelected(mockDevice) - assertTrue(result, "Should return true for mock device") - verify { radioController.setDeviceAddress("m") } - } - - @Test - fun testOnSelectedUsbDeviceBonded() = runTest { - setUp() - val usbDevice = - every { bonded } returns true - every { fullAddress } returns "s/dev/ttyACM0" - } - val result = viewModel.onSelected(usbDevice) - assertTrue(result, "Should return true for bonded USB device") - verify { radioController.setDeviceAddress("s/dev/ttyACM0") } - } - - @Test - fun testOnSelectedUsbDeviceNotBonded() = runTest { - setUp() - val result = viewModel.onSelected(usbDevice) - assertFalse(result, "Should return false for unbonded USB device (triggers permission request)") - } - - @Test - fun testAddRecentAddressIgnoresNonTcpAddresses() = runTest { - setUp() - viewModel.addRecentAddress("xBLE_ADDRESS", "BLE Device") - // Should not add — address doesn't start with "t" - verify(exactly = 0) { recentAddressesDataSource.toString() } - } - - @Test - fun testSelectedNotNullFlowDefaultsToNoDeviceSelected() = runTest { - setUp() - assertEquals( - NO_DEVICE_SELECTED, - viewModel.selectedNotNullFlow.value, - "selectedNotNullFlow defaults to NO_DEVICE_SELECTED when no device is selected", + viewModel = ScannerViewModel( + serviceRepository = serviceRepository, + radioController = radioController, + radioInterfaceService = radioInterfaceService, + recentAddressesDataSource = recentAddressesDataSource, + getDiscoveredDevicesUseCase = getDiscoveredDevicesUseCase, + bleScanner = bleScanner ) } @Test - fun testSupportedDeviceTypes() = runTest { - setUp() - viewModel.supportedDeviceTypes shouldBe listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB) + fun testInitialization() { + assertNotNull(viewModel) } @Test - fun testShowMockInterfaceFalseByDefault() = runTest { - setUp() - assertFalse(viewModel.showMockInterface.value, "showMockInterface defaults to false") + fun `errorText reflects connectionProgress`() = runTest { + viewModel.errorText.test { + assertEquals(null, awaitItem()) + connectionProgressFlow.value = "Connecting..." + assertEquals("Connecting...", awaitItem()) + } } -*/ + @Test + fun `startBleScan updates isBleScanning`() = runTest { + every { bleScanner.scan(any(), any()) } returns kotlinx.coroutines.flow.emptyFlow() + + viewModel.isBleScanning.test { + assertEquals(false, awaitItem()) + viewModel.startBleScan() + assertEquals(true, awaitItem()) + + viewModel.stopBleScan() + assertEquals(false, awaitItem()) + } + } + + @Test + fun `changeDeviceAddress calls radioController`() { + every { radioController.setDeviceAddress(any()) } returns Unit + + viewModel.changeDeviceAddress("test_address") + + dev.mokkery.verify { radioController.setDeviceAddress("test_address") } + } + + @Test + fun `usbDevicesForUi emits updates`() = runTest { + viewModel.usbDevicesForUi.test { + assertEquals(emptyList(), awaitItem()) + + val device = org.meshtastic.feature.connections.model.DeviceListEntry.Usb( + usbData = object : org.meshtastic.feature.connections.model.UsbDeviceData {}, + name = "USB Device", + fullAddress = "usb_address", + bonded = true + ) + discoveredDevicesFlow.value = DiscoveredDevices(usbDevices = listOf(device)) + + assertEquals(listOf(device), awaitItem()) + } + } } diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt index 5e3017419..f1ae1c6ed 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt @@ -21,10 +21,14 @@ import app.cash.turbine.test import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every +import dev.mokkery.everySuspend import dev.mokkery.mock import dev.mokkery.matcher.any +import dev.mokkery.verify +import dev.mokkery.verifySuspend import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.meshtastic.core.repository.QuickChatActionRepository import org.meshtastic.core.model.service.ServiceAction @@ -59,12 +63,23 @@ class MessageViewModelTest { private val customEmojiPrefs: CustomEmojiPrefs = mock(MockMode.autofill) private val homoglyphPrefs: HomoglyphPrefs = mock(MockMode.autofill) private val uiPrefs: UiPrefs = mock(MockMode.autofill) + private val notificationManager: org.meshtastic.core.repository.NotificationManager = mock(MockMode.autofill) + + private val connectionStateFlow = MutableStateFlow(org.meshtastic.core.model.ConnectionState.Disconnected) + private val showQuickChatFlow = MutableStateFlow(false) + private val customEmojiFrequencyFlow = MutableStateFlow(null) + private val contactSettingsFlow = MutableStateFlow>(emptyMap()) @BeforeTest fun setUp() { savedStateHandle = SavedStateHandle(mapOf("contactKey" to "0!12345678")) nodeRepository = FakeNodeRepository() + connectionStateFlow.value = org.meshtastic.core.model.ConnectionState.Disconnected + showQuickChatFlow.value = false + customEmojiFrequencyFlow.value = null + contactSettingsFlow.value = emptyMap() + // Core flows - MUST be separate every blocks every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet()) every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig()) @@ -72,13 +87,14 @@ class MessageViewModelTest { every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(DeviceProfile()) every { serviceRepository.serviceAction } returns emptyFlow() - every { serviceRepository.connectionState } returns MutableStateFlow(org.meshtastic.core.model.ConnectionState.Disconnected) + every { serviceRepository.connectionState } returns connectionStateFlow - every { customEmojiPrefs.customEmojiFrequency } returns MutableStateFlow(null) + every { customEmojiPrefs.customEmojiFrequency } returns customEmojiFrequencyFlow every { homoglyphPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false) - every { uiPrefs.showQuickChat } returns MutableStateFlow(false) + every { uiPrefs.showQuickChat } returns showQuickChatFlow + every { uiPrefs.setShowQuickChat(any()) } returns Unit - every { packetRepository.getContactSettings() } returns MutableStateFlow(emptyMap()) + every { packetRepository.getContactSettings() } returns contactSettingsFlow every { packetRepository.getFirstUnreadMessageUuid(any()) } returns MutableStateFlow(null) every { packetRepository.hasUnreadMessages(any()) } returns MutableStateFlow(false) every { packetRepository.getUnreadCountFlow(any()) } returns MutableStateFlow(0) @@ -97,7 +113,7 @@ class MessageViewModelTest { customEmojiPrefs = customEmojiPrefs, homoglyphEncodingPrefs = homoglyphPrefs, uiPrefs = uiPrefs, - notificationManager = mock(MockMode.autofill), + notificationManager = notificationManager, ) } @@ -106,6 +122,117 @@ class MessageViewModelTest { assertNotNull(viewModel) } + @Test + fun testSetTitle() = runTest { + viewModel.title.test { + assertEquals("", awaitItem()) + viewModel.setTitle("New Title") + assertEquals("New Title", awaitItem()) + } + } + + @Test + fun testConnectionState() = runTest { + viewModel.connectionState.test { + assertEquals(org.meshtastic.core.model.ConnectionState.Disconnected, awaitItem()) + connectionStateFlow.value = org.meshtastic.core.model.ConnectionState.Connected + assertEquals(org.meshtastic.core.model.ConnectionState.Connected, awaitItem()) + } + } + + @Test + fun testToggleShowQuickChat() = runTest { + // The VM init collects from uiPrefs.showQuickChat + viewModel.showQuickChat.test { + assertEquals(false, awaitItem()) + + viewModel.toggleShowQuickChat() + // toggleShowQuickChat updates _showQuickChat AND calls uiPrefs.setShowQuickChat + // Since we are collecting, we should see the update. + assertEquals(true, awaitItem()) + } + } + + @Test + fun testFrequentEmojis() = runTest { + customEmojiFrequencyFlow.value = "👍=10,👎=5,😂=20" + + // frequentEmojis is a property, not a flow. + val emojis = viewModel.frequentEmojis + assertEquals(listOf("😂", "👍", "👎"), emojis) + } + + @Test + fun testSendMessage() = runTest { + everySuspend { sendMessageUseCase.invoke(any(), any(), any()) } returns Unit + + viewModel.sendMessage("Hello", "0!12345678", null) + + // Wait for coroutine to finish + advanceUntilIdle() + + // Verify via mokkery + verifySuspend { sendMessageUseCase.invoke("Hello", "0!12345678", null) } + } + + @Test + fun testSendReaction() = runTest { + everySuspend { serviceRepository.onServiceAction(any()) } returns Unit + + viewModel.sendReaction("❤️", 123, "0!12345678") + + advanceUntilIdle() + + verifySuspend { + serviceRepository.onServiceAction(ServiceAction.Reaction("❤️", 123, "0!12345678")) + } + } + + @Test + fun testDeleteMessages() = runTest { + everySuspend { packetRepository.deleteMessages(any()) } returns Unit + + viewModel.deleteMessages(listOf(1L, 2L)) + + advanceUntilIdle() + + verifySuspend { packetRepository.deleteMessages(listOf(1L, 2L)) } + } + + @Test + fun testUnreadCount() = runTest { + val countFlow = MutableStateFlow(5) + every { packetRepository.getUnreadCountFlow("new_contact") } returns countFlow + + viewModel.setContactKey("new_contact") + + viewModel.unreadCount.test { + // Initial 0 from stateIn + assertEquals(0, awaitItem()) + // Value from countFlow + assertEquals(5, awaitItem()) + countFlow.value = 10 + assertEquals(10, awaitItem()) + } + } + + @Test + fun testClearUnreadCount() = runTest { + val contact = "0!12345678" + everySuspend { packetRepository.clearUnreadCount(contact, 1000L) } returns Unit + everySuspend { packetRepository.updateLastReadMessage(contact, 1L, 1000L) } returns Unit + everySuspend { packetRepository.getUnreadCount(contact) } returns 0 + every { notificationManager.cancel(contact.hashCode()) } returns Unit + + viewModel.clearUnreadCount(contact, 1L, 1000L) + + advanceUntilIdle() + + verifySuspend { packetRepository.clearUnreadCount(contact, 1000L) } + verifySuspend { packetRepository.updateLastReadMessage(contact, 1L, 1000L) } + verifySuspend { notificationManager.cancel(contact.hashCode()) } + } + @Test fun testNodeRepositoryIntegration() = runTest { val testNodes = TestDataFactory.createTestNodes(3) diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index d01e87956..41441fe62 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -21,6 +21,7 @@ import app.cash.turbine.test import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every +import dev.mokkery.everySuspend import dev.mokkery.mock import dev.mokkery.matcher.any import dev.mokkery.verifySuspend @@ -125,7 +126,7 @@ class RadioConfigViewModelTest { viewModel = createViewModel() val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)) - dev.mokkery.everySuspend { radioConfigUseCase.setConfig(any(), any()) } returns 42 + everySuspend { radioConfigUseCase.setConfig(any(), any()) } returns 42 viewModel.setConfig(config) @@ -136,4 +137,73 @@ class RadioConfigViewModelTest { verifySuspend { radioConfigUseCase.setConfig(123, config) } } + + @Test + fun `toggleAnalyticsAllowed calls useCase`() { + every { toggleAnalyticsUseCase() } returns Unit + + viewModel.toggleAnalyticsAllowed() + + dev.mokkery.verify { toggleAnalyticsUseCase() } + } + + @Test + fun `toggleHomoglyphCharactersEncodingEnabled calls useCase`() { + every { toggleHomoglyphEncodingUseCase() } returns Unit + + viewModel.toggleHomoglyphCharactersEncodingEnabled() + + dev.mokkery.verify { toggleHomoglyphEncodingUseCase() } + } + + @Test + fun `setPreserveFavorites updates state`() = runTest { + viewModel.radioConfigState.test { + assertEquals(false, awaitItem().nodeDbResetPreserveFavorites) + viewModel.setPreserveFavorites(true) + assertEquals(true, awaitItem().nodeDbResetPreserveFavorites) + } + } + + @Test + fun `setOwner calls useCase`() = runTest { + val node = Node(num = 123, user = User(id = "!123")) + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node)) + viewModel = createViewModel() + + val user = User(long_name = "Test User") + everySuspend { radioConfigUseCase.setOwner(any(), any()) } returns 42 + + viewModel.setOwner(user) + + verifySuspend { radioConfigUseCase.setOwner(123, user) } + } + + @Test + fun `setRingtone calls useCase`() = runTest { + val node = Node(num = 123, user = User(id = "!123")) + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node)) + viewModel = createViewModel() + + everySuspend { radioConfigUseCase.setRingtone(any(), any()) } returns Unit + + viewModel.setRingtone("ringtone.mp3") + + assertEquals("ringtone.mp3", viewModel.radioConfigState.value.ringtone) + verifySuspend { radioConfigUseCase.setRingtone(123, "ringtone.mp3") } + } + + @Test + fun `setCannedMessages calls useCase`() = runTest { + val node = Node(num = 123, user = User(id = "!123")) + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node)) + viewModel = createViewModel() + + everySuspend { radioConfigUseCase.setCannedMessages(any(), any()) } returns Unit + + viewModel.setCannedMessages("Hello|World") + + assertEquals("Hello|World", viewModel.radioConfigState.value.cannedMessageMessages) + verifySuspend { radioConfigUseCase.setCannedMessages(123, "Hello|World") } + } }