diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkPacketHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkPacketHandlerTest.kt new file mode 100644 index 000000000..e00b2371f --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkPacketHandlerTest.kt @@ -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 . + */ +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 + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioControllerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioControllerTest.kt index 8d9f2334e..d2cc4cbd8 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioControllerTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioControllerTest.kt @@ -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(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(MockMode.autofill)), + CoroutineDispatchers(dispatcher, dispatcher, dispatcher), + ), + radioPrefs = FakeRadioPrefs(), + ) + + assertFailsWith { + 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(client) + private class TestRadioClientAccessor(client: RadioClient?) : RadioClientAccessor { + override val client = MutableStateFlow(client) override fun rebuildAndConnectAsync() = Unit diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceServiceTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceServiceTest.kt new file mode 100644 index 000000000..7ddb3f2f7 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceServiceTest.kt @@ -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 . + */ +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(null) + + var rebuildCalled = false + private set + + var disconnectCalled = false + private set + + override fun rebuildAndConnectAsync() { + rebuildCalled = true + } + + override fun disconnect() { + disconnectCalled = true + } + } +}