test(feature): Expand ViewModel coverage with Turbine and Mokkery

This commit is contained in:
James Rich 2026-03-18 16:13:01 -05:00
parent 727af8a3be
commit c813be8228
3 changed files with 286 additions and 150 deletions

View file

@ -16,174 +16,113 @@
*/ */
package org.meshtastic.feature.connections 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.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository 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.DiscoveredDevices
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
import kotlin.test.BeforeTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Tests for [ScannerViewModel] covering core device selection, connection, and state management.
*
* Uses `core:testing` fakes where available and mockk for remaining dependencies.
*/
class ScannerViewModelTest { class ScannerViewModelTest {
/*
private lateinit var viewModel: ScannerViewModel private lateinit var viewModel: ScannerViewModel
private lateinit var radioController: RadioController private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private lateinit var serviceRepository: ServiceRepository private val radioController: RadioController = mock(MockMode.autofill)
private lateinit var radioInterfaceService: RadioInterfaceService private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
private lateinit var recentAddressesDataSource: RecentAddressesDataSource private val recentAddressesDataSource: RecentAddressesDataSource = mock(MockMode.autofill)
private lateinit var getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase = mock(MockMode.autofill)
private val bleScanner: org.meshtastic.core.ble.BleScanner = mock(MockMode.autofill)
private fun setUp() { private val connectionProgressFlow = MutableStateFlow<String?>(null)
radioInterfaceService = private val discoveredDevicesFlow = MutableStateFlow(DiscoveredDevices())
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())
}
viewModel = @BeforeTest
ScannerViewModel( fun setUp() {
serviceRepository = serviceRepository, every { radioInterfaceService.isMockInterface() } returns false
radioController = radioController, every { radioInterfaceService.currentDeviceAddressFlow } returns MutableStateFlow(null)
radioInterfaceService = radioInterfaceService, every { radioInterfaceService.supportedDeviceTypes } returns emptyList()
recentAddressesDataSource = recentAddressesDataSource,
getDiscoveredDevicesUseCase = getDiscoveredDevicesUseCase, every { serviceRepository.connectionProgress } returns connectionProgressFlow
) every { getDiscoveredDevicesUseCase.invoke(any()) } returns discoveredDevicesFlow
} every { recentAddressesDataSource.recentAddresses } returns MutableStateFlow(emptyList())
@Test connectionProgressFlow.value = null
fun testInitialization() = runTest { discoveredDevicesFlow.value = DiscoveredDevices()
setUp()
assertNull(viewModel.errorText.value, "Error text starts as null before connectionProgress emits")
}
@Test viewModel = ScannerViewModel(
fun testSetErrorText() = runTest { serviceRepository = serviceRepository,
setUp() radioController = radioController,
viewModel.setErrorText("Test error") radioInterfaceService = radioInterfaceService,
viewModel.errorText.value shouldBe "Test error" recentAddressesDataSource = recentAddressesDataSource,
} getDiscoveredDevicesUseCase = getDiscoveredDevicesUseCase,
bleScanner = bleScanner
@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",
) )
} }
@Test @Test
fun testSupportedDeviceTypes() = runTest { fun testInitialization() {
setUp() assertNotNull(viewModel)
viewModel.supportedDeviceTypes shouldBe listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
} }
@Test @Test
fun testShowMockInterfaceFalseByDefault() = runTest { fun `errorText reflects connectionProgress`() = runTest {
setUp() viewModel.errorText.test {
assertFalse(viewModel.showMockInterface.value, "showMockInterface defaults to false") 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())
}
}
} }

View file

@ -21,10 +21,14 @@ import app.cash.turbine.test
import dev.mokkery.MockMode import dev.mokkery.MockMode
import dev.mokkery.answering.returns import dev.mokkery.answering.returns
import dev.mokkery.every import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.mock import dev.mokkery.mock
import dev.mokkery.matcher.any import dev.mokkery.matcher.any
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.meshtastic.core.repository.QuickChatActionRepository import org.meshtastic.core.repository.QuickChatActionRepository
import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.model.service.ServiceAction
@ -59,12 +63,23 @@ class MessageViewModelTest {
private val customEmojiPrefs: CustomEmojiPrefs = mock(MockMode.autofill) private val customEmojiPrefs: CustomEmojiPrefs = mock(MockMode.autofill)
private val homoglyphPrefs: HomoglyphPrefs = mock(MockMode.autofill) private val homoglyphPrefs: HomoglyphPrefs = mock(MockMode.autofill)
private val uiPrefs: UiPrefs = 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>(org.meshtastic.core.model.ConnectionState.Disconnected)
private val showQuickChatFlow = MutableStateFlow(false)
private val customEmojiFrequencyFlow = MutableStateFlow<String?>(null)
private val contactSettingsFlow = MutableStateFlow<Map<String, org.meshtastic.core.model.ContactSettings>>(emptyMap())
@BeforeTest @BeforeTest
fun setUp() { fun setUp() {
savedStateHandle = SavedStateHandle(mapOf("contactKey" to "0!12345678")) savedStateHandle = SavedStateHandle(mapOf("contactKey" to "0!12345678"))
nodeRepository = FakeNodeRepository() 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 // Core flows - MUST be separate every blocks
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet()) every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig()) every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
@ -72,13 +87,14 @@ class MessageViewModelTest {
every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(DeviceProfile()) every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(DeviceProfile())
every { serviceRepository.serviceAction } returns emptyFlow<ServiceAction>() every { serviceRepository.serviceAction } returns emptyFlow<ServiceAction>()
every { serviceRepository.connectionState } returns MutableStateFlow(org.meshtastic.core.model.ConnectionState.Disconnected) every { serviceRepository.connectionState } returns connectionStateFlow
every { customEmojiPrefs.customEmojiFrequency } returns MutableStateFlow<String?>(null) every { customEmojiPrefs.customEmojiFrequency } returns customEmojiFrequencyFlow
every { homoglyphPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false) 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<String>()) } returns MutableStateFlow(null) every { packetRepository.getFirstUnreadMessageUuid(any<String>()) } returns MutableStateFlow(null)
every { packetRepository.hasUnreadMessages(any<String>()) } returns MutableStateFlow(false) every { packetRepository.hasUnreadMessages(any<String>()) } returns MutableStateFlow(false)
every { packetRepository.getUnreadCountFlow(any<String>()) } returns MutableStateFlow(0) every { packetRepository.getUnreadCountFlow(any<String>()) } returns MutableStateFlow(0)
@ -97,7 +113,7 @@ class MessageViewModelTest {
customEmojiPrefs = customEmojiPrefs, customEmojiPrefs = customEmojiPrefs,
homoglyphEncodingPrefs = homoglyphPrefs, homoglyphEncodingPrefs = homoglyphPrefs,
uiPrefs = uiPrefs, uiPrefs = uiPrefs,
notificationManager = mock(MockMode.autofill), notificationManager = notificationManager,
) )
} }
@ -106,6 +122,117 @@ class MessageViewModelTest {
assertNotNull(viewModel) 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 @Test
fun testNodeRepositoryIntegration() = runTest { fun testNodeRepositoryIntegration() = runTest {
val testNodes = TestDataFactory.createTestNodes(3) val testNodes = TestDataFactory.createTestNodes(3)

View file

@ -21,6 +21,7 @@ import app.cash.turbine.test
import dev.mokkery.MockMode import dev.mokkery.MockMode
import dev.mokkery.answering.returns import dev.mokkery.answering.returns
import dev.mokkery.every import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.mock import dev.mokkery.mock
import dev.mokkery.matcher.any import dev.mokkery.matcher.any
import dev.mokkery.verifySuspend import dev.mokkery.verifySuspend
@ -125,7 +126,7 @@ class RadioConfigViewModelTest {
viewModel = createViewModel() viewModel = createViewModel()
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)) 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) viewModel.setConfig(config)
@ -136,4 +137,73 @@ class RadioConfigViewModelTest {
verifySuspend { radioConfigUseCase.setConfig(123, config) } 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") }
}
} }