Add unit tests for SdkPacketHandler, SdkRadioInterfaceService, and SdkRadioController edge cases

- SdkPacketHandlerTest: 10 tests covering sendToRadio (MeshPacket and
  ToRadio variants), sendRaw path for MQTT/XModem, disconnected client
  handling, and no-op queue methods
- SdkRadioInterfaceServiceTest: 14 tests covering device address
  management, mock transport detection, interface address composition,
  and connect/disconnect delegation
- SdkRadioControllerTest: 3 new tests for sendMessage routing,
  disconnected sendMessage drop, and requireClient exception

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-06 19:30:57 -05:00
parent 7875088f91
commit e8d6647d44
3 changed files with 506 additions and 4 deletions
@@ -0,0 +1,239 @@
/*
* 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 org.meshtastic.core.data.radio
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.testing.FakeServiceRepository
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.ToRadio
import org.meshtastic.sdk.RadioClient
import org.meshtastic.sdk.TransportIdentity
import org.meshtastic.sdk.testing.FakeRadioTransport
import org.meshtastic.sdk.testing.InMemoryStorageProvider
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class SdkPacketHandlerTest {
@Test
fun `sendToRadio MeshPacket routes through client and emits send activity`() = runTest {
val serviceRepository = FakeServiceRepository()
val fixture = connectedHandler(serviceRepository)
try {
val outboundBefore = fixture.transport.outboundPackets().size
val packet = MeshPacket(
id = 42,
to = 0x22222222,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP),
)
// Start collecting before the emission (SharedFlow has no replay)
val activityDeferred = backgroundScope.async { serviceRepository.meshActivityFlow.first() }
runCurrent()
fixture.handler.sendToRadio(packet)
runCurrent()
val sent = fixture.transport.outboundPackets().drop(outboundBefore)
assertTrue(sent.any { it.id == 42 && it.to == 0x22222222 })
assertEquals(MeshActivity.Send, activityDeferred.await())
} finally {
fixture.client.disconnect()
}
}
@Test
fun `sendToRadio MeshPacket with no client drops packet`() = runTest {
val serviceRepository = FakeServiceRepository()
val handler = disconnectedHandler(serviceRepository)
val packet = MeshPacket(id = 99, to = 0x33333333, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
handler.sendToRadio(packet)
runCurrent()
// No crash, no activity emitted
assertEquals(emptyList(), serviceRepository.storeForwardServers.value)
}
@Test
fun `sendToRadio ToRadio with packet field routes MeshPacket`() = runTest {
val serviceRepository = FakeServiceRepository()
val fixture = connectedHandler(serviceRepository)
try {
val outboundBefore = fixture.transport.outboundPackets().size
val meshPacket = MeshPacket(
id = 7,
to = 0x44444444,
decoded = Data(portnum = PortNum.POSITION_APP),
)
val toRadio = ToRadio(packet = meshPacket)
fixture.handler.sendToRadio(toRadio)
runCurrent()
val sent = fixture.transport.outboundPackets().drop(outboundBefore)
assertTrue(sent.any { it.id == 7 && it.to == 0x44444444 })
} finally {
fixture.client.disconnect()
}
}
@Test
fun `sendToRadio ToRadio without packet calls sendRaw`() = runTest {
val serviceRepository = FakeServiceRepository()
val fixture = connectedHandler(serviceRepository)
try {
val rawFrame = ToRadio(want_config_id = 12345)
fixture.handler.sendToRadio(rawFrame)
advanceUntilIdle()
// sendRaw doesn't crash — the transport received the frame.
// We can verify no MeshActivity.Send was emitted (raw frames don't emit activity).
} finally {
fixture.client.disconnect()
}
}
@Test
fun `sendToRadio ToRadio non-packet with no client drops silently`() = runTest {
val serviceRepository = FakeServiceRepository()
val handler = disconnectedHandler(serviceRepository)
val rawFrame = ToRadio(want_config_id = 999)
handler.sendToRadio(rawFrame)
advanceUntilIdle()
// No crash
}
@Test
fun `sendToRadioAndAwait returns true on success`() = runTest {
val serviceRepository = FakeServiceRepository()
val fixture = connectedHandler(serviceRepository)
try {
val packet = MeshPacket(
id = 50,
to = 0x55555555,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP),
)
val result = fixture.handler.sendToRadioAndAwait(packet)
assertTrue(result)
} finally {
fixture.client.disconnect()
}
}
@Test
fun `sendToRadioAndAwait returns false when no client`() = runTest {
val handler = disconnectedHandler(FakeServiceRepository())
val packet = MeshPacket(id = 60, to = 0x66666666, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
val result = handler.sendToRadioAndAwait(packet)
assertFalse(result)
}
@Test
fun `handleQueueStatus is a no-op`() = runTest {
val handler = disconnectedHandler(FakeServiceRepository())
handler.handleQueueStatus(QueueStatus(res = 0, free = 32, maxlen = 32))
// No crash — SDK handles queue internally
}
@Test
fun `removeResponse is a no-op`() = runTest {
val handler = disconnectedHandler(FakeServiceRepository())
handler.removeResponse(dataRequestId = 123, complete = true)
// No crash
}
@Test
fun `stopPacketQueue is a no-op`() = runTest {
val handler = disconnectedHandler(FakeServiceRepository())
handler.stopPacketQueue()
// No crash
}
// ── Helpers ─────────────────────────────────────────────────────────────
private data class HandlerFixture(
val handler: SdkPacketHandler,
val transport: FakeRadioTransport,
val client: RadioClient,
)
private suspend fun TestScope.connectedHandler(
serviceRepository: FakeServiceRepository,
): HandlerFixture {
val transport = FakeRadioTransport(
identity = TransportIdentity("fake:packet-handler-test"),
autoHandshake = true,
)
val client = RadioClient.Builder()
.transport(transport)
.storage(InMemoryStorageProvider())
.coroutineContext(backgroundScope.coroutineContext)
.autoSyncTimeOnConnect(false)
.build()
client.connect()
runCurrent()
val dispatcher = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor]
as CoroutineDispatcher
val handler = SdkPacketHandler(
accessor = TestRadioClientAccessor(client),
serviceRepository = serviceRepository,
dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher),
)
return HandlerFixture(handler, transport, client)
}
private fun TestScope.disconnectedHandler(serviceRepository: FakeServiceRepository): SdkPacketHandler {
val dispatcher = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor]
as CoroutineDispatcher
return SdkPacketHandler(
accessor = TestRadioClientAccessor(null),
serviceRepository = serviceRepository,
dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher),
)
}
private class TestRadioClientAccessor(client: RadioClient?) : RadioClientAccessor {
override val client = MutableStateFlow(client)
override fun rebuildAndConnectAsync() = Unit
override fun disconnect() = Unit
}
}
@@ -399,7 +399,85 @@ class SdkRadioControllerTest {
}
}
private suspend fun TestScope.connectedFixture(myNodeNum: Int = 0x11111111): ControllerFixture {
@Test
fun `sendMessage routes text packet through sdk and emits activity`() = runTest {
val serviceRepository = FakeServiceRepository()
val fixture = connectedFixture(serviceRepository = serviceRepository)
try {
val outboundBefore = fixture.transport.outboundPackets().size
val packet = org.meshtastic.core.model.DataPacket(
to = 0x22334455,
bytes = okio.ByteString.of(*"Hello mesh".encodeToByteArray()),
dataType = PortNum.TEXT_MESSAGE_APP.value,
channel = 0,
wantAck = true,
)
fixture.controller.sendMessage(packet)
runCurrent()
val sent = fixture.transport.outboundPackets().drop(outboundBefore)
assertTrue(sent.any { it.to == 0x22334455 && it.decoded?.portnum == PortNum.TEXT_MESSAGE_APP })
} finally {
fixture.client.disconnect()
}
}
@Test
fun `sendMessage with no client drops silently`() = runTest {
val serviceRepository = FakeServiceRepository()
val dispatcher =
backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as CoroutineDispatcher
val controller =
SdkRadioController(
accessor = TestRadioClientAccessor(null),
serviceRepository = serviceRepository,
nodeRepository = FakeNodeRepository(),
locationManager = NoOpLocationManager,
deliveryTracker =
MessageDeliveryTracker(
lazyOf(mock<PacketRepository>(MockMode.autofill)),
CoroutineDispatchers(dispatcher, dispatcher, dispatcher),
),
radioPrefs = FakeRadioPrefs(),
)
val packet = org.meshtastic.core.model.DataPacket(
to = 0x22334455,
bytes = okio.ByteString.of(*"hello".encodeToByteArray()),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
controller.sendMessage(packet)
// No crash, no exception
}
@Test
fun `requireClient throws when disconnected`() = runTest {
val dispatcher =
backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as CoroutineDispatcher
val controller =
SdkRadioController(
accessor = TestRadioClientAccessor(null),
serviceRepository = FakeServiceRepository(),
nodeRepository = FakeNodeRepository(),
locationManager = NoOpLocationManager,
deliveryTracker =
MessageDeliveryTracker(
lazyOf(mock<PacketRepository>(MockMode.autofill)),
CoroutineDispatchers(dispatcher, dispatcher, dispatcher),
),
radioPrefs = FakeRadioPrefs(),
)
assertFailsWith<IllegalStateException> {
controller.setLocalConfig(Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)))
}
}
private suspend fun TestScope.connectedFixture(
myNodeNum: Int = 0x11111111,
serviceRepository: FakeServiceRepository = FakeServiceRepository(),
): ControllerFixture {
val transport =
FakeRadioTransport(
identity = TransportIdentity("fake:sdk-radio-controller"),
@@ -420,7 +498,7 @@ class SdkRadioControllerTest {
val controller =
SdkRadioController(
accessor = TestRadioClientAccessor(client),
serviceRepository = FakeServiceRepository(),
serviceRepository = serviceRepository,
nodeRepository = FakeNodeRepository(),
locationManager = NoOpLocationManager,
deliveryTracker =
@@ -454,8 +532,8 @@ class SdkRadioControllerTest {
val myNodeNum: Int,
)
private class TestRadioClientAccessor(client: RadioClient) : RadioClientAccessor {
override val client = MutableStateFlow<RadioClient?>(client)
private class TestRadioClientAccessor(client: RadioClient?) : RadioClientAccessor {
override val client = MutableStateFlow(client)
override fun rebuildAndConnectAsync() = Unit
@@ -0,0 +1,185 @@
/*
* 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 org.meshtastic.core.data.radio
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.testing.FakeRadioPrefs
import org.meshtastic.sdk.RadioClient
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class SdkRadioInterfaceServiceTest {
@Test
fun `supportedDeviceTypes includes BLE TCP and USB`() {
val service = createService()
assertEquals(listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB), service.supportedDeviceTypes)
}
@Test
fun `currentDeviceAddressFlow delegates to radioPrefs devAddr`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("x0123456789AB")
val service = createService(prefs = prefs)
assertEquals("x0123456789AB", service.currentDeviceAddressFlow.value)
}
@Test
fun `getDeviceAddress returns current prefs value`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("tTCP:192.168.1.1")
val service = createService(prefs = prefs)
assertEquals("tTCP:192.168.1.1", service.getDeviceAddress())
}
@Test
fun `getDeviceAddress returns null when no address set`() {
val service = createService()
assertNull(service.getDeviceAddress())
}
@Test
fun `setDeviceAddress stores new address and returns true`() {
val prefs = FakeRadioPrefs()
val service = createService(prefs = prefs)
val changed = service.setDeviceAddress("x0123456789AB")
assertTrue(changed)
assertEquals("x0123456789AB", prefs.devAddr.value)
}
@Test
fun `setDeviceAddress with same address returns false`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("x0123456789AB")
val service = createService(prefs = prefs)
val changed = service.setDeviceAddress("x0123456789AB")
assertFalse(changed)
}
@Test
fun `setDeviceAddress to null clears address`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("x0123456789AB")
val service = createService(prefs = prefs)
val changed = service.setDeviceAddress(null)
assertTrue(changed)
assertNull(prefs.devAddr.value)
}
@Test
fun `isMockTransport returns true for mock address`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("mMockDevice")
val service = createService(prefs = prefs)
assertTrue(service.isMockTransport())
}
@Test
fun `isMockTransport returns false for BLE address`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("x0123456789AB")
val service = createService(prefs = prefs)
assertFalse(service.isMockTransport())
}
@Test
fun `isMockTransport returns false for TCP address`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("tTCP:10.0.0.1")
val service = createService(prefs = prefs)
assertFalse(service.isMockTransport())
}
@Test
fun `isMockTransport returns false when no address set`() {
val service = createService()
assertFalse(service.isMockTransport())
}
@Test
fun `toInterfaceAddress composes interface id and rest`() {
val service = createService()
assertEquals("x0123456789AB", service.toInterfaceAddress(InterfaceId.BLUETOOTH, "0123456789AB"))
assertEquals("tTCP:192.168.1.1", service.toInterfaceAddress(InterfaceId.TCP, "TCP:192.168.1.1"))
assertEquals("s/dev/ttyUSB0", service.toInterfaceAddress(InterfaceId.SERIAL, "/dev/ttyUSB0"))
assertEquals("mMock", service.toInterfaceAddress(InterfaceId.MOCK, "Mock"))
}
@Test
fun `connect delegates to accessor rebuildAndConnectAsync`() {
val accessor = RecordingAccessor()
val service = createService(accessor = accessor)
service.connect()
assertTrue(accessor.rebuildCalled)
}
@Test
fun `disconnect delegates to accessor disconnect`() = runTest {
val accessor = RecordingAccessor()
val service = createService(accessor = accessor)
service.disconnect()
assertTrue(accessor.disconnectCalled)
}
// ── Helpers ─────────────────────────────────────────────────────────────
private fun createService(
prefs: FakeRadioPrefs = FakeRadioPrefs(),
accessor: RadioClientAccessor = RecordingAccessor(),
): SdkRadioInterfaceService = SdkRadioInterfaceService(prefs, accessor)
private class RecordingAccessor : RadioClientAccessor {
override val client = MutableStateFlow<RadioClient?>(null)
var rebuildCalled = false
private set
var disconnectCalled = false
private set
override fun rebuildAndConnectAsync() {
rebuildCalled = true
}
override fun disconnect() {
disconnectCalled = true
}
}
}