feat(test): Add comprehensive unit and instrumentation tests (#4260)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-01-19 19:52:03 -06:00 committed by GitHub
parent 4e2c429180
commit 45227fb142
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1270 additions and 307 deletions

View file

@ -0,0 +1,102 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.radio
import io.mockk.confirmVerified
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
class StreamInterfaceTest {
private val service: RadioInterfaceService = mockk(relaxed = true)
// Concrete implementation for testing
private class TestStreamInterface(service: RadioInterfaceService) : StreamInterface(service) {
override fun sendBytes(p: ByteArray) {}
fun testReadChar(c: Byte) = readChar(c)
}
private val streamInterface = TestStreamInterface(service)
@Test
fun `readChar delivers a 1-byte packet`() {
// Header: START1, START2, LenMSB=0, LenLSB=1
val packet = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x42)
packet.forEach { streamInterface.testReadChar(it) }
verify { service.handleFromRadio(byteArrayOf(0x42)) }
}
@Test
fun `readChar handles zero length packet`() {
// Header: START1, START2, LenMSB=0, LenLSB=0
val packet = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x00)
packet.forEach { streamInterface.testReadChar(it) }
verify { service.handleFromRadio(byteArrayOf()) }
}
@Test
fun `readChar loses sync on invalid START2`() {
// START1, wrong START2, START1, START2, LenMSB=0, LenLSB=1, payload
val data = byteArrayOf(0x94.toByte(), 0x00, 0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x55)
data.forEach { streamInterface.testReadChar(it) }
verify { service.handleFromRadio(byteArrayOf(0x55)) }
}
@Test
fun `readChar handles multiple packets sequentially`() {
val packet1 = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x11)
val packet2 = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x22)
packet1.forEach { streamInterface.testReadChar(it) }
packet2.forEach { streamInterface.testReadChar(it) }
verify { service.handleFromRadio(byteArrayOf(0x11)) }
verify { service.handleFromRadio(byteArrayOf(0x22)) }
confirmVerified(service)
}
@Test
fun `readChar handles large packet up to MAX_TO_FROM_RADIO_SIZE`() {
val size = 512
val payload = ByteArray(size) { it.toByte() }
val header = byteArrayOf(0x94.toByte(), 0xc3.toByte(), (size shr 8).toByte(), (size and 0xff).toByte())
header.forEach { streamInterface.testReadChar(it) }
payload.forEach { streamInterface.testReadChar(it) }
verify { service.handleFromRadio(payload) }
}
@Test
fun `readChar loses sync on overly large packet length`() {
// 513 bytes is > 512
val header = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x02, 0x01)
header.forEach { streamInterface.testReadChar(it) }
// Should ignore and reset, not expecting handleFromRadio
verify(exactly = 0) { service.handleFromRadio(any()) }
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.radio
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertArrayEquals
import org.junit.Test
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.proto.MeshProtos
class TCPInterfaceTest {
private val service: RadioInterfaceService = mockk(relaxed = true)
private val dispatchers: CoroutineDispatchers = mockk(relaxed = true)
@Test
fun `keepAlive generates correct heartbeat bytes`() = runTest {
val address = "192.168.1.1:4403"
// We need a subclass to capture handleSendToRadio or sendBytes
val tcpInterface =
object : TCPInterface(service, dispatchers, address) {
var capturedBytes: ByteArray? = null
override fun handleSendToRadio(p: ByteArray) {
capturedBytes = p
}
// Override connect to prevent it from starting automatically in init
override fun connect() {}
}
tcpInterface.keepAlive()
val expectedHeartbeat =
MeshProtos.ToRadio.newBuilder()
.setHeartbeat(MeshProtos.Heartbeat.getDefaultInstance())
.build()
.toByteArray()
assertArrayEquals("Heartbeat bytes should match", expectedHeartbeat, tcpInterface.capturedBytes)
}
// Since startConnect is private, we'd normally need reflection or to make a internal method.
// For now, testing keepAlive is a good first step for stability.
}

View file

@ -0,0 +1,111 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.service
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.fromRadio
class FromRadioPacketHandlerTest {
private val serviceRepository: ServiceRepository = mockk(relaxed = true)
private val router: MeshRouter = mockk(relaxed = true)
private val mqttManager: MeshMqttManager = mockk(relaxed = true)
private val packetHandler: PacketHandler = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private val configFlowManager: MeshConfigFlowManager = mockk(relaxed = true)
private val configHandler: MeshConfigHandler = mockk(relaxed = true)
private lateinit var handler: FromRadioPacketHandler
@Before
fun setUp() {
every { router.configFlowManager } returns configFlowManager
every { router.configHandler } returns configHandler
handler = FromRadioPacketHandler(serviceRepository, router, mqttManager, packetHandler, serviceNotifications)
}
@Test
fun `handleFromRadio routes MY_INFO to configFlowManager`() {
val myInfo = MeshProtos.MyNodeInfo.newBuilder().setMyNodeNum(1234).build()
val proto = fromRadio { this.myInfo = myInfo }
handler.handleFromRadio(proto)
verify { configFlowManager.handleMyInfo(myInfo) }
}
@Test
fun `handleFromRadio routes METADATA to configFlowManager`() {
val metadata = MeshProtos.DeviceMetadata.newBuilder().setFirmwareVersion("v1.0").build()
val proto = fromRadio { this.metadata = metadata }
handler.handleFromRadio(proto)
verify { configFlowManager.handleLocalMetadata(metadata) }
}
@Test
fun `handleFromRadio routes NODE_INFO to configFlowManager`() {
val nodeInfo = MeshProtos.NodeInfo.newBuilder().setNum(1234).build()
val proto = fromRadio { this.nodeInfo = nodeInfo }
handler.handleFromRadio(proto)
verify { configFlowManager.handleNodeInfo(nodeInfo) }
verify { serviceRepository.setStatusMessage(any()) }
}
@Test
fun `handleFromRadio routes QUEUESTATUS to packetHandler`() {
val queueStatus = MeshProtos.QueueStatus.newBuilder().setFree(5).build()
val proto = fromRadio { this.queueStatus = queueStatus }
handler.handleFromRadio(proto)
verify { packetHandler.handleQueueStatus(queueStatus) }
}
@Test
fun `handleFromRadio routes CONFIG to configHandler`() {
val config = ConfigProtos.Config.newBuilder().build()
val proto = fromRadio { this.config = config }
handler.handleFromRadio(proto)
verify { configHandler.handleDeviceConfig(config) }
}
@Test
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository and notifications`() {
val notification = MeshProtos.ClientNotification.newBuilder().setReplyId(42).build()
val proto = fromRadio { this.clientNotification = notification }
handler.handleFromRadio(proto)
verify { serviceRepository.setClientNotification(notification) }
verify { serviceNotifications.showClientNotification(notification) }
verify { packetHandler.removeResponse(42, false) }
}
}

View file

@ -0,0 +1,101 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.service
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.MeshProtos.MeshPacket
class MeshCommandSenderHopLimitTest {
private val packetHandler: PacketHandler = mockk(relaxed = true)
private val nodeManager = MeshNodeManager()
private val connectionStateHolder: ConnectionStateHandler = mockk(relaxed = true)
private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
private val localConfigFlow = MutableStateFlow(LocalConfig.getDefaultInstance())
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = CoroutineScope(testDispatcher)
private lateinit var commandSender: MeshCommandSender
@Before
fun setUp() {
val connectedFlow = MutableStateFlow(ConnectionState.Connected)
every { connectionStateHolder.connectionState } returns connectedFlow
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
commandSender = MeshCommandSender(packetHandler, nodeManager, connectionStateHolder, radioConfigRepository)
commandSender.start(testScope)
}
@Test
fun `sendData uses default hop limit when config hop limit is zero`() = runTest(testDispatcher) {
val packet =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = byteArrayOf(1, 2, 3),
dataType = 1, // PortNum.TEXT_MESSAGE_APP
)
val meshPacketSlot = slot<MeshPacket>()
every { packetHandler.sendToRadio(capture(meshPacketSlot)) } returns Unit
// Ensure localConfig has lora.hopLimit = 0
localConfigFlow.value =
LocalConfig.newBuilder().setLora(Config.LoRaConfig.newBuilder().setHopLimit(0)).build()
commandSender.sendData(packet)
verify(exactly = 1) { packetHandler.sendToRadio(any<MeshPacket>()) }
val capturedHopLimit = meshPacketSlot.captured.hopLimit
assertTrue("Hop limit should be greater than 0, but was $capturedHopLimit", capturedHopLimit > 0)
assertEquals(3, capturedHopLimit)
}
@Test
fun `sendData respects non-zero hop limit from config`() = runTest(testDispatcher) {
val packet = DataPacket(to = DataPacket.ID_BROADCAST, bytes = byteArrayOf(1, 2, 3), dataType = 1)
val meshPacketSlot = slot<MeshPacket>()
every { packetHandler.sendToRadio(capture(meshPacketSlot)) } returns Unit
localConfigFlow.value =
LocalConfig.newBuilder().setLora(Config.LoRaConfig.newBuilder().setHopLimit(7)).build()
commandSender.sendData(packet)
verify { packetHandler.sendToRadio(any<MeshPacket>()) }
assertEquals(7, meshPacketSlot.captured.hopLimit)
}
}

View file

@ -0,0 +1,181 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.service
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.MeshProtos.ToRadio
class MeshConnectionManagerTest {
private val radioInterfaceService: RadioInterfaceService = mockk(relaxed = true)
private val connectionStateHolder = ConnectionStateHandler()
private val serviceBroadcasts: MeshServiceBroadcasts = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private val uiPrefs: UiPrefs = mockk(relaxed = true)
private val packetHandler: PacketHandler = mockk(relaxed = true)
private val nodeRepository: NodeRepository = mockk(relaxed = true)
private val locationManager: MeshLocationManager = mockk(relaxed = true)
private val mqttManager: MeshMqttManager = mockk(relaxed = true)
private val historyManager: MeshHistoryManager = mockk(relaxed = true)
private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
private val commandSender: MeshCommandSender = mockk(relaxed = true)
private val nodeManager: MeshNodeManager = mockk(relaxed = true)
private val analytics: PlatformAnalytics = mockk(relaxed = true)
private val radioConnectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
private val localConfigFlow = MutableStateFlow(LocalConfig.getDefaultInstance())
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var manager: MeshConnectionManager
@Before
fun setUp() {
mockkStatic("org.jetbrains.compose.resources.StringResourcesKt")
coEvery { org.jetbrains.compose.resources.getString(any()) } returns "Mocked String"
coEvery { org.jetbrains.compose.resources.getString(any(), *anyVararg()) } returns "Mocked String"
every { radioInterfaceService.connectionState } returns radioConnectionState
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
every { nodeRepository.myNodeInfo } returns MutableStateFlow<MyNodeEntity?>(null)
manager =
MeshConnectionManager(
radioInterfaceService,
connectionStateHolder,
serviceBroadcasts,
serviceNotifications,
uiPrefs,
packetHandler,
nodeRepository,
locationManager,
mqttManager,
historyManager,
radioConfigRepository,
commandSender,
nodeManager,
analytics,
)
}
@After
fun tearDown() {
unmockkStatic("org.jetbrains.compose.resources.StringResourcesKt")
}
@Test
fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) {
manager.start(backgroundScope)
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
assertEquals(
"State should be Connecting after radio Connected",
ConnectionState.Connecting,
connectionStateHolder.connectionState.value,
)
verify { serviceBroadcasts.broadcastConnection() }
verify { packetHandler.sendToRadio(any<ToRadio.Builder>()) }
}
@Test
fun `Disconnected state stops services`() = runTest(testDispatcher) {
manager.start(backgroundScope)
// Transition to Connected first so that Disconnected actually does something
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
radioConnectionState.value = ConnectionState.Disconnected
advanceUntilIdle()
assertEquals(
"State should be Disconnected after radio Disconnected",
ConnectionState.Disconnected,
connectionStateHolder.connectionState.value,
)
verify { packetHandler.stopPacketQueue() }
verify { locationManager.stop() }
verify { mqttManager.stop() }
}
@Test
fun `DeviceSleep behavior when power saving is off maps to Disconnected`() = runTest(testDispatcher) {
// Power saving disabled + Role CLIENT
val config =
LocalConfig.newBuilder()
.apply {
powerBuilder.setIsPowerSaving(false)
deviceBuilder.setRole(Config.DeviceConfig.Role.CLIENT)
}
.build()
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
manager.start(backgroundScope)
advanceUntilIdle()
radioConnectionState.value = ConnectionState.DeviceSleep
advanceUntilIdle()
assertEquals(
"State should be Disconnected when power saving is off",
ConnectionState.Disconnected,
connectionStateHolder.connectionState.value,
)
}
@Test
fun `DeviceSleep behavior when power saving is on stays in DeviceSleep`() = runTest(testDispatcher) {
// Power saving enabled
val config = LocalConfig.newBuilder().apply { powerBuilder.setIsPowerSaving(true) }.build()
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
manager.start(backgroundScope)
advanceUntilIdle()
radioConnectionState.value = ConnectionState.DeviceSleep
advanceUntilIdle()
assertEquals(
"State should stay in DeviceSleep when power saving is on",
ConnectionState.DeviceSleep,
connectionStateHolder.connectionState.value,
)
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,81 +17,114 @@
package com.geeksville.mesh.service
import com.google.protobuf.ByteString
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.user
class MeshDataMapperTest {
private lateinit var dataMapper: MeshDataMapper
private lateinit var nodeManager: MeshNodeManager
private val nodeManager: MeshNodeManager = mockk()
private lateinit var mapper: MeshDataMapper
@Before
fun setUp() {
nodeManager = MeshNodeManager() // Use internal testing constructor
dataMapper = MeshDataMapper(nodeManager)
mapper = MeshDataMapper(nodeManager)
}
@Test
fun `toNodeID returns broadcast ID for broadcast num`() {
assertEquals(DataPacket.ID_BROADCAST, dataMapper.toNodeID(DataPacket.NODENUM_BROADCAST))
fun `toNodeID resolves broadcast correctly`() {
assertEquals(DataPacket.ID_BROADCAST, mapper.toNodeID(DataPacket.NODENUM_BROADCAST))
}
@Test
fun `toNodeID returns user ID from node database`() {
val nodeNum = 123
val userId = "!0000007b" // hex for 123
nodeManager.nodeDBbyNodeNum[nodeNum] = NodeEntity(num = nodeNum, user = user { id = userId })
fun `toNodeID resolves known node correctly`() {
val nodeNum = 1234
val nodeId = "!1234abcd"
val nodeEntity = mockk<NodeEntity>()
every { nodeEntity.user.id } returns nodeId
every { nodeManager.nodeDBbyNodeNum[nodeNum] } returns nodeEntity
assertEquals(userId, dataMapper.toNodeID(nodeNum))
assertEquals(nodeId, mapper.toNodeID(nodeNum))
}
@Test
fun `toNodeID returns default ID if node not in database`() {
val nodeNum = 123
val expectedId = "!0000007b"
assertEquals(expectedId, dataMapper.toNodeID(nodeNum))
fun `toNodeID resolves unknown node to default ID`() {
val nodeNum = 1234
every { nodeManager.nodeDBbyNodeNum[nodeNum] } returns null
assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), mapper.toNodeID(nodeNum))
}
@Test
fun `toDataPacket returns null if no decoded payload`() {
fun `toDataPacket returns null when no decoded data`() {
val packet = MeshProtos.MeshPacket.newBuilder().build()
assertNull(dataMapper.toDataPacket(packet))
assertNull(mapper.toDataPacket(packet))
}
@Test
fun `toDataPacket correctly maps protobuf to DataPacket`() {
val payload = "Hello".encodeToByteArray()
val packet =
fun `toDataPacket maps basic fields correctly`() {
val nodeNum = 1234
val nodeId = "!1234abcd"
val nodeEntity = mockk<NodeEntity>()
every { nodeEntity.user.id } returns nodeId
every { nodeManager.nodeDBbyNodeNum[any()] } returns nodeEntity
val proto =
MeshProtos.MeshPacket.newBuilder()
.apply {
from = 1
to = 2
id = 12345
id = 42
from = nodeNum
to = DataPacket.NODENUM_BROADCAST
rxTime = 1600000000
rxSnr = 5.5f
rxRssi = -100
hopLimit = 3
hopStart = 3
decoded =
MeshProtos.Data.newBuilder()
.apply {
portnumValue = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE
setPayload(ByteString.copyFrom(payload))
portnumValue = 1 // TEXT_MESSAGE_APP
payload = ByteString.copyFrom("hello".toByteArray())
replyId = 123
}
.build()
}
.build()
val dataPacket = dataMapper.toDataPacket(packet)
val result = mapper.toDataPacket(proto)
assertNotNull(result)
assertEquals(42, result!!.id)
assertEquals(nodeId, result.from)
assertEquals(DataPacket.ID_BROADCAST, result.to)
assertEquals(1600000000000L, result.time)
assertEquals(5.5f, result.snr)
assertEquals(-100, result.rssi)
assertEquals(1, result.dataType)
assertEquals("hello", result.bytes?.decodeToString())
assertEquals(123, result.replyId)
}
assertEquals("!00000001", dataPacket?.from)
assertEquals("!00000002", dataPacket?.to)
assertEquals(12345, dataPacket?.id)
assertEquals(1600000000000L, dataPacket?.time)
assertEquals(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, dataPacket?.dataType)
assertEquals("Hello", dataPacket?.bytes?.decodeToString())
@Test
fun `toDataPacket maps PKC channel correctly for encrypted packets`() {
val proto =
MeshProtos.MeshPacket.newBuilder()
.apply {
pkiEncrypted = true
channel = 1
decoded = MeshProtos.Data.getDefaultInstance()
}
.build()
every { nodeManager.nodeDBbyNodeNum[any()] } returns null
val result = mapper.toDataPacket(proto)
assertEquals(DataPacket.PKC_CHANNEL_INDEX, result!!.channel)
}
}

View file

@ -0,0 +1,101 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.service
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.MeshProtos
class MeshMessageProcessorTest {
private val nodeManager: MeshNodeManager = mockk(relaxed = true)
private val serviceRepository: ServiceRepository = mockk(relaxed = true)
private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
private val router: MeshRouter = mockk(relaxed = true)
private val fromRadioDispatcher: FromRadioPacketHandler = mockk(relaxed = true)
private val meshLogRepositoryLazy = dagger.Lazy { meshLogRepository }
private val dataHandler: MeshDataHandler = mockk(relaxed = true)
private val isNodeDbReady = MutableStateFlow(false)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var processor: MeshMessageProcessor
@Before
fun setUp() {
every { nodeManager.isNodeDbReady } returns isNodeDbReady
every { router.dataHandler } returns dataHandler
processor =
MeshMessageProcessor(nodeManager, serviceRepository, meshLogRepositoryLazy, router, fromRadioDispatcher)
processor.start(testScope)
}
@Test
fun `early packets are buffered and flushed when DB is ready`() = runTest(testDispatcher) {
val packet =
MeshProtos.MeshPacket.newBuilder()
.apply {
id = 123
decoded = MeshProtos.Data.newBuilder().setPortnumValue(1).build()
}
.build()
// 1. Database is NOT ready
isNodeDbReady.value = false
testScheduler.runCurrent() // trigger start() onEach
processor.handleReceivedMeshPacket(packet, 999)
// Verify that handleReceivedData has NOT been called yet
verify(exactly = 0) { dataHandler.handleReceivedData(any(), any(), any(), any()) }
// 2. Database becomes ready
isNodeDbReady.value = true
testScheduler.runCurrent() // trigger onEach(true)
// Verify that handleReceivedData is now called with the buffered packet
verify(exactly = 1) { dataHandler.handleReceivedData(match { it.id == 123 }, any(), any(), any()) }
}
@Test
fun `packets are processed immediately if DB is already ready`() = runTest(testDispatcher) {
val packet =
MeshProtos.MeshPacket.newBuilder()
.apply {
id = 456
decoded = MeshProtos.Data.newBuilder().setPortnumValue(1).build()
}
.build()
isNodeDbReady.value = true
testScheduler.runCurrent()
processor.handleReceivedMeshPacket(packet, 999)
verify(exactly = 1) { dataHandler.handleReceivedData(match { it.id == 456 }, any(), any(), any()) }
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,79 +16,122 @@
*/
package com.geeksville.mesh.service
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.user
class MeshNodeManagerTest {
private val nodeRepository: NodeRepository = mockk(relaxed = true)
private val serviceBroadcasts: MeshServiceBroadcasts = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private lateinit var nodeManager: MeshNodeManager
@Before
fun setUp() {
nodeManager = MeshNodeManager() // Use internal testing constructor
nodeManager = MeshNodeManager(nodeRepository, serviceBroadcasts, serviceNotifications)
}
@Test
fun `getOrCreateNodeInfo returns existing node`() {
val node = NodeEntity(num = 1, longName = "Node 1", shortName = "N1")
nodeManager.nodeDBbyNodeNum[1] = node
val result = nodeManager.getOrCreateNodeInfo(1)
assertEquals(node, result)
}
@Test
fun `getOrCreateNodeInfo creates new node if not exists`() {
val nodeNum = 456
fun `getOrCreateNodeInfo creates default user for unknown node`() {
val nodeNum = 1234
val result = nodeManager.getOrCreateNodeInfo(nodeNum)
assertNotNull(result)
assertEquals(nodeNum, result.num)
assertTrue(result.user.longName.startsWith("Meshtastic"))
assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), result.user.id)
}
@Test
fun `getMyNodeInfo returns info from nodeDB when available`() {
val myNum = 123
nodeManager.myNodeNum = myNum
val myNode =
NodeEntity(
num = myNum,
user =
user {
id = "!0000007b"
longName = "My Node"
shortName = "MY"
hwModel = MeshProtos.HardwareModel.TBEAM
},
)
nodeManager.nodeDBbyNodeNum[myNum] = myNode
fun `handleReceivedUser preserves existing user if incoming is default`() {
val nodeNum = 1234
val existingUser = user {
id = "!12345678"
longName = "My Custom Name"
shortName = "MCN"
hwModel = MeshProtos.HardwareModel.TLORA_V2
}
// This test will hit the null NodeRepository, so we might need to mock it if we want to test fallbacks.
// But since we set myNodeNum and nodeDBbyNodeNum, it should return from memory if we are careful.
// Actually getMyNodeInfo calls nodeRepository.myNodeInfo.value if memory lookup fails.
// Setup existing node
nodeManager.updateNodeInfo(nodeNum) { it.user = existingUser }
val incomingDefaultUser = user {
id = "!12345678"
longName = "Meshtastic 5678"
shortName = "5678"
hwModel = MeshProtos.HardwareModel.UNSET
}
nodeManager.handleReceivedUser(nodeNum, incomingDefaultUser)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertEquals("My Custom Name", result!!.user.longName)
assertEquals(MeshProtos.HardwareModel.TLORA_V2, result.user.hwModel)
}
@Test
fun `clear resets state`() {
nodeManager.myNodeNum = 123
nodeManager.nodeDBbyNodeNum[1] = NodeEntity(num = 1)
nodeManager.isNodeDbReady.value = true
fun `handleReceivedUser updates user if incoming is higher detail`() {
val nodeNum = 1234
val existingUser = user {
id = "!12345678"
longName = "Meshtastic 5678"
shortName = "5678"
hwModel = MeshProtos.HardwareModel.UNSET
}
nodeManager.updateNodeInfo(nodeNum) { it.user = existingUser }
val incomingDetailedUser = user {
id = "!12345678"
longName = "Real User"
shortName = "RU"
hwModel = MeshProtos.HardwareModel.TLORA_V1
}
nodeManager.handleReceivedUser(nodeNum, incomingDetailedUser)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertEquals("Real User", result!!.user.longName)
assertEquals(MeshProtos.HardwareModel.TLORA_V1, result.user.hwModel)
}
@Test
fun `handleReceivedPosition updates node position`() {
val nodeNum = 1234
val position =
MeshProtos.Position.newBuilder()
.apply {
latitudeI = 450000000
longitudeI = 900000000
}
.build()
nodeManager.handleReceivedPosition(nodeNum, 9999, position)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertNotNull(result!!.position)
assertEquals(45.0, result.latitude, 0.0001)
assertEquals(90.0, result.longitude, 0.0001)
}
@Test
fun `clear resets internal state`() {
nodeManager.updateNodeInfo(1234) { it.longName = "Test" }
nodeManager.clear()
assertNull(nodeManager.myNodeNum)
assertTrue(nodeManager.nodeDBbyNodeNum.isEmpty())
assertFalse(nodeManager.isNodeDbReady.value)
assertTrue(nodeManager.nodeDBbyID.isEmpty())
assertNull(nodeManager.myNodeNum)
}
}

View file

@ -0,0 +1,106 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.service
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.MeshProtos
class PacketHandlerTest {
private val packetRepository: PacketRepository = mockk(relaxed = true)
private val serviceBroadcasts: MeshServiceBroadcasts = mockk(relaxed = true)
private val radioInterfaceService: RadioInterfaceService = mockk(relaxed = true)
private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
private val connectionStateHolder: ConnectionStateHandler = mockk(relaxed = true)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var handler: PacketHandler
@Before
fun setUp() {
handler =
PacketHandler(
dagger.Lazy { packetRepository },
serviceBroadcasts,
radioInterfaceService,
dagger.Lazy { meshLogRepository },
connectionStateHolder,
)
handler.start(testScope)
}
@Test
fun `sendToRadio with ToRadio Builder sends immediately`() {
val builder =
MeshProtos.ToRadio.newBuilder().apply { packet = MeshProtos.MeshPacket.newBuilder().setId(123).build() }
handler.sendToRadio(builder)
verify { radioInterfaceService.sendToRadio(any()) }
// Verify broadcast status ENROUTE (via status mapping) is not directly testable easily without more mocks,
// but we verify the call to radio service occurred.
}
@Test
fun `sendToRadio with MeshPacket queues and sends when connected`() = runTest(testDispatcher) {
val packet = MeshProtos.MeshPacket.newBuilder().setId(456).build()
every { connectionStateHolder.connectionState } returns MutableStateFlow(ConnectionState.Connected)
handler.sendToRadio(packet)
testScheduler.runCurrent()
verify { radioInterfaceService.sendToRadio(any()) }
}
@Test
fun `handleQueueStatus completes deferred`() = runTest(testDispatcher) {
val packet = MeshProtos.MeshPacket.newBuilder().setId(789).build()
every { connectionStateHolder.connectionState } returns MutableStateFlow(ConnectionState.Connected)
handler.sendToRadio(packet)
testScheduler.runCurrent()
val status =
MeshProtos.QueueStatus.newBuilder()
.apply {
meshPacketId = 789
res = 0 // Success
free = 1
}
.build()
handler.handleQueueStatus(status)
testScheduler.runCurrent()
// If it completed, the queue job should move to the next packet or finish.
// We can't easily check the deferred inside, but we can check if it cleared the internal wait.
}
}