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
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<String?>(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()
@Test
fun testInitialization() = runTest {
setUp()
assertNull(viewModel.errorText.value, "Error text starts as null before connectionProgress emits")
}
every { serviceRepository.connectionProgress } returns connectionProgressFlow
every { getDiscoveredDevicesUseCase.invoke(any()) } returns discoveredDevicesFlow
every { recentAddressesDataSource.recentAddresses } returns MutableStateFlow(emptyList())
@Test
fun testSetErrorText() = runTest {
setUp()
viewModel.setErrorText("Test error")
viewModel.errorText.value shouldBe "Test error"
}
connectionProgressFlow.value = null
discoveredDevicesFlow.value = DiscoveredDevices()
@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())
}
}
}

View file

@ -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>(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
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<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 { 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.hasUnreadMessages(any<String>()) } returns MutableStateFlow(false)
every { packetRepository.getUnreadCountFlow(any<String>()) } 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)

View file

@ -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") }
}
}