mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
4e2c429180
commit
45227fb142
26 changed files with 1270 additions and 307 deletions
4
.github/workflows/reusable-android-build.yml
vendored
4
.github/workflows/reusable-android-build.yml
vendored
|
|
@ -69,7 +69,7 @@ jobs:
|
|||
echo "datadogApplicationId=$DATADOG_APPLICATION_ID" >> ./secrets.properties
|
||||
echo "datadogClientToken=$DATADOG_CLIENT_TOKEN" >> ./secrets.properties
|
||||
- name: Build and Run Unit Tests
|
||||
run: ./gradlew assembleDebug testGoogleDebugUnitTest testFdroidDebugUnitTest koverXmlReport --continue --scan
|
||||
run: ./gradlew assembleDebug testDebugUnitTest testGoogleDebugUnitTest testFdroidDebugUnitTest koverXmlReport --continue --scan
|
||||
env:
|
||||
VERSION_CODE: ${{ env.VERSION_CODE }}
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ jobs:
|
|||
slug: meshtastic/Meshtastic-Android
|
||||
report_type: coverage
|
||||
directory: .
|
||||
files: build/reports/kover/report.xml
|
||||
files: "**/build/reports/kover/report.xml"
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
|
|
|
|||
2
.github/workflows/reusable-android-test.yml
vendored
2
.github/workflows/reusable-android-test.yml
vendored
|
|
@ -111,7 +111,7 @@ jobs:
|
|||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
report_type: coverage
|
||||
slug: meshtastic/Meshtastic-Android
|
||||
files: build/reports/kover/report.xml
|
||||
files: "**/build/reports/kover/report.xml"
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
|
|
|
|||
|
|
@ -247,6 +247,7 @@ dependencies {
|
|||
androidTestImplementation(libs.hilt.android.testing)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import java.net.InetAddress
|
|||
import java.net.Socket
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
class TCPInterface
|
||||
open class TCPInterface
|
||||
@AssistedInject
|
||||
constructor(
|
||||
service: RadioInterfaceService,
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ constructor(
|
|||
sessionPasskey.set(key)
|
||||
}
|
||||
|
||||
private fun getHopLimit(): Int = localConfig.value.lora.hopLimit
|
||||
private fun getHopLimit(): Int = localConfig.value.lora.hopLimit.takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
|
||||
|
||||
private fun getAdminChannelIndex(toNum: Int): Int {
|
||||
val myNum = nodeManager?.myNodeNum ?: return 0
|
||||
|
|
@ -422,5 +422,7 @@ constructor(
|
|||
private const val NODE_ID_PREFIX = "!"
|
||||
private const val NODE_ID_START_INDEX = 1
|
||||
private const val HEX_RADIX = 16
|
||||
|
||||
private const val DEFAULT_HOP_LIMIT = 3
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,6 @@ package org.meshtastic.buildlogic
|
|||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
import org.jetbrains.dokka.gradle.DokkaExtension
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
|
||||
fun Project.configureDokka() {
|
||||
|
|
@ -28,33 +27,6 @@ fun Project.configureDokka() {
|
|||
// Use the full project path as the module name to ensure uniqueness
|
||||
moduleName.set(project.path.removePrefix(":").replace(":", "-").ifEmpty { project.name })
|
||||
|
||||
// Discover and register Android source sets (main + flavors)
|
||||
val registerAndroidSourceSets = {
|
||||
project.file("src").listFiles()
|
||||
?.filter { it.isDirectory && !it.name.contains("test", ignoreCase = true) }
|
||||
?.forEach { sourceDir ->
|
||||
val sourceSetName = sourceDir.name
|
||||
val ktDir = File(sourceDir, "kotlin")
|
||||
val javaDir = File(sourceDir, "java")
|
||||
if (ktDir.exists() || javaDir.exists()) {
|
||||
dokkaSourceSets.maybeCreate(sourceSetName).apply {
|
||||
if (ktDir.exists()) sourceRoots.from(ktDir)
|
||||
if (javaDir.exists()) sourceRoots.from(javaDir)
|
||||
suppress.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pluginManager.withPlugin("com.android.library") {
|
||||
if (!plugins.hasPlugin("org.jetbrains.kotlin.multiplatform")) {
|
||||
registerAndroidSourceSets()
|
||||
}
|
||||
}
|
||||
pluginManager.withPlugin("com.android.application") {
|
||||
registerAndroidSourceSets()
|
||||
}
|
||||
|
||||
dokkaSourceSets.configureEach {
|
||||
perPackageOption {
|
||||
matchingRegex.set("hilt_aggregated_deps")
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@ import org.gradle.kotlin.dsl.configure
|
|||
fun Project.configureKover() {
|
||||
extensions.configure<KoverProjectExtension> {
|
||||
reports {
|
||||
total {
|
||||
xml {
|
||||
onCheck.set(true)
|
||||
}
|
||||
html {
|
||||
onCheck.set(true)
|
||||
}
|
||||
}
|
||||
filters {
|
||||
excludes {
|
||||
// Exclude generated classes
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.database.DatabaseManager
|
||||
import org.meshtastic.core.database.MeshtasticDatabase
|
||||
import org.meshtastic.core.database.dao.MeshLogDao
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
|
||||
import org.meshtastic.proto.MeshProtos.Data
|
||||
import org.meshtastic.proto.MeshProtos.FromRadio
|
||||
import org.meshtastic.proto.MeshProtos.MeshPacket
|
||||
import org.meshtastic.proto.Portnums.PortNum
|
||||
import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics
|
||||
import org.meshtastic.proto.TelemetryProtos.Telemetry
|
||||
import java.util.UUID
|
||||
|
||||
class MeshLogRepositoryTest {
|
||||
|
||||
private val dbManager: DatabaseManager = mockk()
|
||||
private val appDatabase: MeshtasticDatabase = mockk()
|
||||
private val meshLogDao: MeshLogDao = mockk()
|
||||
private val meshLogPrefs: MeshLogPrefs = mockk()
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
|
||||
|
||||
private val repository = MeshLogRepository(dbManager, dispatchers, meshLogPrefs)
|
||||
|
||||
init {
|
||||
every { dbManager.currentDb } returns MutableStateFlow(appDatabase)
|
||||
every { appDatabase.meshLogDao() } returns meshLogDao
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseTelemetryLog preserves zero temperature`() = runTest(testDispatcher) {
|
||||
val zeroTemp = 0.0f
|
||||
val envMetrics = EnvironmentMetrics.newBuilder().setTemperature(zeroTemp).build()
|
||||
val telemetry = Telemetry.newBuilder().setEnvironmentMetrics(envMetrics).build()
|
||||
|
||||
val meshPacket =
|
||||
MeshPacket.newBuilder()
|
||||
.setDecoded(
|
||||
Data.newBuilder().setPayload(telemetry.toByteString()).setPortnum(PortNum.TELEMETRY_APP),
|
||||
)
|
||||
.build()
|
||||
|
||||
val meshLog =
|
||||
MeshLog(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
message_type = "telemetry",
|
||||
received_date = System.currentTimeMillis(),
|
||||
raw_message = "",
|
||||
fromRadio = FromRadio.newBuilder().setPacket(meshPacket).build(),
|
||||
)
|
||||
|
||||
// Using reflection to test private method parseTelemetryLog
|
||||
val method = MeshLogRepository::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
|
||||
method.isAccessible = true
|
||||
val result = method.invoke(repository, meshLog) as Telemetry?
|
||||
|
||||
assertNotNull(result)
|
||||
val resultMetrics = result?.environmentMetrics
|
||||
assertNotNull(resultMetrics)
|
||||
assertEquals(zeroTemp, resultMetrics?.temperature!!, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseTelemetryLog maps missing temperature to NaN`() = runTest(testDispatcher) {
|
||||
val envMetrics = EnvironmentMetrics.newBuilder().build() // Temperature not set
|
||||
val telemetry = Telemetry.newBuilder().setEnvironmentMetrics(envMetrics).build()
|
||||
|
||||
val meshPacket =
|
||||
MeshPacket.newBuilder()
|
||||
.setDecoded(
|
||||
Data.newBuilder().setPayload(telemetry.toByteString()).setPortnum(PortNum.TELEMETRY_APP),
|
||||
)
|
||||
.build()
|
||||
|
||||
val meshLog =
|
||||
MeshLog(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
message_type = "telemetry",
|
||||
received_date = System.currentTimeMillis(),
|
||||
raw_message = "",
|
||||
fromRadio = FromRadio.newBuilder().setPacket(meshPacket).build(),
|
||||
)
|
||||
|
||||
val method = MeshLogRepository::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
|
||||
method.isAccessible = true
|
||||
val result = method.invoke(repository, meshLog) as Telemetry?
|
||||
|
||||
assertNotNull(result)
|
||||
val resultMetrics = result?.environmentMetrics
|
||||
|
||||
// Should be NaN as per repository logic for missing fields
|
||||
assertEquals(Float.NaN, resultMetrics?.temperature!!, 0.01f)
|
||||
}
|
||||
}
|
||||
|
|
@ -1137,4 +1137,8 @@
|
|||
<string name="compass_uncertainty_unknown">Estimated area: unknown accuracy</string>
|
||||
<string name="mark_as_read">Mark as read</string>
|
||||
<string name="now">Now</string>
|
||||
<string name="add_channels_title">Add Channels</string>
|
||||
<string name="add_channels_description">The following channels were found in the QR code. Select the once you would like to add to your device. Existing channels will be preserved.</string>
|
||||
<string name="replace_channels_and_settings_title">Replace Channels & Settings</string>
|
||||
<string name="replace_channels_and_settings_description">This QR code contains a complete configuration. This will REPLACE your existing channels and radio settings. All existing channels will be removed.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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.ui.qr
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.meshtastic.core.strings.getString
|
||||
import org.junit.Assert
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.core.model.Channel
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.accept
|
||||
import org.meshtastic.core.strings.add
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.new_channel_rcvd
|
||||
import org.meshtastic.core.strings.replace
|
||||
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
|
||||
import org.meshtastic.proto.ConfigProtos
|
||||
import org.meshtastic.proto.channelSet
|
||||
import org.meshtastic.proto.channelSettings
|
||||
import org.meshtastic.proto.copy
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ScannedQrCodeDialogTest {
|
||||
|
||||
@get:Rule val composeTestRule = createComposeRule()
|
||||
|
||||
private fun getString(id: Int): String = InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
|
||||
|
||||
private fun getRandomKey() = Channel.getRandomKey()
|
||||
|
||||
private val channels = channelSet {
|
||||
settings.add(Channel.default.settings)
|
||||
loraConfig = Channel.default.loraConfig
|
||||
}
|
||||
|
||||
private val incoming = channelSet {
|
||||
settings.addAll(
|
||||
listOf(
|
||||
Channel.default.settings,
|
||||
channelSettings {
|
||||
name = "2"
|
||||
psk = getRandomKey()
|
||||
},
|
||||
channelSettings {
|
||||
name = "3"
|
||||
psk = getRandomKey()
|
||||
},
|
||||
channelSettings {
|
||||
name = "admin"
|
||||
psk = getRandomKey()
|
||||
},
|
||||
),
|
||||
)
|
||||
loraConfig =
|
||||
Channel.default.loraConfig.copy { modemPreset = ConfigProtos.Config.LoRaConfig.ModemPreset.SHORT_FAST }
|
||||
}
|
||||
|
||||
private fun testScannedQrCodeDialog(onDismiss: () -> Unit = {}, onConfirm: (ChannelSet) -> Unit = {}) =
|
||||
composeTestRule.setContent {
|
||||
ScannedQrCodeDialog(channels = channels, incoming = incoming, onDismiss = onDismiss, onConfirm = onConfirm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testScannedQrCodeDialog_showsDialogTitle() {
|
||||
composeTestRule.apply {
|
||||
testScannedQrCodeDialog()
|
||||
|
||||
// Verify that the dialog title is displayed
|
||||
onNodeWithText(getString(Res.string.new_channel_rcvd)).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testScannedQrCodeDialog_showsAddAndReplaceButtons() {
|
||||
composeTestRule.apply {
|
||||
testScannedQrCodeDialog()
|
||||
|
||||
// Verify that the "Add" and "Replace" buttons are displayed
|
||||
onNodeWithText(getString(Res.string.add)).assertIsDisplayed()
|
||||
onNodeWithText(getString(Res.string.replace)).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testScannedQrCodeDialog_showsCancelAndAcceptButtons() {
|
||||
composeTestRule.apply {
|
||||
testScannedQrCodeDialog()
|
||||
|
||||
// Verify the "Cancel" and "Accept" buttons are displayed
|
||||
onNodeWithText(getString(Res.string.cancel)).assertIsDisplayed()
|
||||
onNodeWithText(getString(Res.string.accept)).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testScannedQrCodeDialog_clickCancelButton() {
|
||||
var onDismissClicked = false
|
||||
composeTestRule.apply {
|
||||
testScannedQrCodeDialog(onDismiss = { onDismissClicked = true })
|
||||
|
||||
// Click the "Cancel" button
|
||||
onNodeWithText(getString(Res.string.cancel)).performClick()
|
||||
}
|
||||
|
||||
// Verify onDismiss is called
|
||||
Assert.assertTrue(onDismissClicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testScannedQrCodeDialog_replaceChannels() {
|
||||
var actualChannelSet: ChannelSet? = null
|
||||
composeTestRule.apply {
|
||||
testScannedQrCodeDialog(onConfirm = { actualChannelSet = it })
|
||||
|
||||
// Click the "Accept" button
|
||||
onNodeWithText(getString(Res.string.accept)).performClick()
|
||||
}
|
||||
|
||||
// Verify onConfirm is called with the correct ChannelSet
|
||||
Assert.assertEquals(incoming, actualChannelSet)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testScannedQrCodeDialog_addChannels() {
|
||||
var actualChannelSet: ChannelSet? = null
|
||||
composeTestRule.apply {
|
||||
testScannedQrCodeDialog(onConfirm = { actualChannelSet = it })
|
||||
|
||||
// Click the "Add" button then the "Accept" button
|
||||
onNodeWithText(getString(Res.string.add)).performClick()
|
||||
onNodeWithText(getString(Res.string.accept)).performClick()
|
||||
}
|
||||
|
||||
// Verify onConfirm is called with the correct ChannelSet
|
||||
val expectedChannelSet =
|
||||
channels.copy {
|
||||
val list = LinkedHashSet(settings + incoming.settingsList)
|
||||
settings.clear()
|
||||
settings.addAll(list)
|
||||
}
|
||||
Assert.assertEquals(expectedChannelSet, actualChannelSet)
|
||||
}
|
||||
}
|
||||
|
|
@ -54,9 +54,11 @@ import org.meshtastic.core.model.Channel
|
|||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.accept
|
||||
import org.meshtastic.core.strings.add
|
||||
import org.meshtastic.core.strings.add_channels_description
|
||||
import org.meshtastic.core.strings.cancel
|
||||
import org.meshtastic.core.strings.new_channel_rcvd
|
||||
import org.meshtastic.core.strings.replace
|
||||
import org.meshtastic.core.strings.replace_channels_and_settings_description
|
||||
import org.meshtastic.core.ui.component.ChannelSelection
|
||||
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
|
||||
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset
|
||||
|
|
@ -124,7 +126,13 @@ fun ScannedQrCodeDialog(
|
|||
|
||||
val selectedChannelSet =
|
||||
channelSet.copy {
|
||||
val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }
|
||||
// When adding (not replacing), include all previous channels + selected new channels.
|
||||
// Since 'channelSet.settings' already contains the merged distinct list, we just filter it.
|
||||
val result =
|
||||
settings.filterIndexed { i, _ ->
|
||||
val isExisting = !shouldReplace && i < channels.settingsCount
|
||||
isExisting || channelSelections.getOrNull(i) == true
|
||||
}
|
||||
settings.clear()
|
||||
settings.addAll(result)
|
||||
}
|
||||
|
|
@ -153,30 +161,6 @@ fun ScannedQrCodeDialog(
|
|||
if (current.usePreset != new.usePreset) {
|
||||
changes.add("Use Preset: ${current.usePreset} -> ${new.usePreset}")
|
||||
}
|
||||
if (current.txEnabled != new.txEnabled) {
|
||||
changes.add("Transmit Enabled: ${current.txEnabled} -> ${new.txEnabled}")
|
||||
}
|
||||
if (current.channelNum != new.channelNum) {
|
||||
changes.add("Channel Number: ${current.channelNum} -> ${new.channelNum}")
|
||||
}
|
||||
if (current.bandwidth != new.bandwidth) {
|
||||
changes.add("Bandwidth: ${current.bandwidth} -> ${new.bandwidth}")
|
||||
}
|
||||
if (current.codingRate != new.codingRate) {
|
||||
changes.add("Coding Rate: ${current.codingRate} -> ${new.codingRate}")
|
||||
}
|
||||
if (current.spreadFactor != new.spreadFactor) {
|
||||
changes.add("Spread Factor: ${current.spreadFactor} -> ${new.spreadFactor}")
|
||||
}
|
||||
if (current.sx126XRxBoostedGain != new.sx126XRxBoostedGain) {
|
||||
changes.add("RX Boosted Gain: ${current.sx126XRxBoostedGain} -> ${new.sx126XRxBoostedGain}")
|
||||
}
|
||||
if (current.overrideFrequency != new.overrideFrequency) {
|
||||
changes.add("Override Frequency: ${current.overrideFrequency} -> ${new.overrideFrequency}")
|
||||
}
|
||||
if (current.ignoreMqtt != new.ignoreMqtt) {
|
||||
changes.add("Ignore MQTT: ${current.ignoreMqtt} -> ${new.ignoreMqtt}")
|
||||
}
|
||||
|
||||
changes
|
||||
} else {
|
||||
|
|
@ -204,13 +188,30 @@ fun ScannedQrCodeDialog(
|
|||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
if (shouldReplace) {
|
||||
Res.string.replace_channels_and_settings_description
|
||||
} else {
|
||||
Res.string.add_channels_description
|
||||
},
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
itemsIndexed(channelSet.settingsList) { index, channel ->
|
||||
val isExisting = !shouldReplace && index < channels.settingsCount
|
||||
val channelObj = Channel(channel, channelSet.loraConfig)
|
||||
ChannelSelection(
|
||||
index = index,
|
||||
title = channel.name.ifEmpty { modemPresetName },
|
||||
enabled = true,
|
||||
isSelected = channelSelections[index],
|
||||
enabled = !isExisting,
|
||||
isSelected = if (isExisting) true else channelSelections[index],
|
||||
onSelected = {
|
||||
if (it || selectedChannelSet.settingsCount > 1) {
|
||||
channelSelections[index] = it
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* 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.feature.firmware.ota
|
||||
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteService
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.client.android.ScanResult
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.toKotlinUuid
|
||||
|
||||
private val SERVICE_UUID = UUID.fromString("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
||||
private val OTA_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130005")
|
||||
private val TX_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130003")
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
|
||||
class BleOtaTransportMtuTest {
|
||||
|
||||
private val centralManager: CentralManager = mockk(relaxed = true)
|
||||
private val address = "00:11:22:33:44:55"
|
||||
private val transport = BleOtaTransport(centralManager, address)
|
||||
|
||||
@Test
|
||||
fun `connect requests MTU`() = runTest {
|
||||
val peripheral: Peripheral = mockk(relaxed = true)
|
||||
val otaChar: RemoteCharacteristic = mockk(relaxed = true)
|
||||
val txChar: RemoteCharacteristic = mockk(relaxed = true)
|
||||
val service: RemoteService = mockk(relaxed = true)
|
||||
val scanResult: ScanResult = mockk(relaxed = true)
|
||||
|
||||
every { scanResult.peripheral } returns peripheral
|
||||
every { centralManager.scan(any(), any()) } returns flowOf(scanResult)
|
||||
every { peripheral.address } returns address
|
||||
every { peripheral.state } returns MutableStateFlow(ConnectionState.Connected)
|
||||
coEvery { peripheral.services(any()) } returns MutableStateFlow(listOf(service))
|
||||
every { service.uuid } returns SERVICE_UUID.toKotlinUuid()
|
||||
every { service.characteristics } returns listOf(otaChar, txChar)
|
||||
every { otaChar.uuid } returns OTA_CHARACTERISTIC_UUID.toKotlinUuid()
|
||||
every { txChar.uuid } returns TX_CHARACTERISTIC_UUID.toKotlinUuid()
|
||||
coEvery { centralManager.connect(any(), any()) } returns Unit
|
||||
every { txChar.subscribe() } returns MutableSharedFlow()
|
||||
|
||||
transport.connect().getOrThrow()
|
||||
|
||||
// Verify connect was called with automaticallyRequestHighestValueLength = true
|
||||
coVerify {
|
||||
centralManager.connect(
|
||||
peripheral,
|
||||
CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,10 @@ dependencies {
|
|||
|
||||
googleImplementation(libs.location.services)
|
||||
googleImplementation(libs.maps.compose)
|
||||
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
testImplementation(libs.androidx.test.ext.junit)
|
||||
testImplementation(libs.robolectric)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-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
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* 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.feature.node.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -41,7 +40,6 @@ import org.meshtastic.core.model.util.UnitConversions
|
|||
import org.meshtastic.core.model.util.UnitConversions.toTempString
|
||||
import org.meshtastic.core.model.util.toSmallDistanceString
|
||||
import org.meshtastic.core.model.util.toSpeedString
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.current
|
||||
import org.meshtastic.core.strings.dew_point
|
||||
|
|
@ -74,7 +72,7 @@ internal fun EnvironmentMetrics(
|
|||
remember(node.environmentMetrics, isFahrenheit, displayUnits) {
|
||||
buildList {
|
||||
with(node.environmentMetrics) {
|
||||
if (hasTemperature()) {
|
||||
if (!temperature.isNaN()) {
|
||||
add(
|
||||
VectorMetricInfo(
|
||||
Res.string.temperature,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import org.meshtastic.core.ui.util.toPosition
|
|||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.ConfigProtos.Config
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
import org.meshtastic.proto.MeshProtos.MeshPacket
|
||||
import org.meshtastic.proto.Portnums
|
||||
|
|
@ -240,11 +241,14 @@ constructor(
|
|||
launch {
|
||||
radioConfigRepository.deviceProfileFlow.collect { profile ->
|
||||
val moduleConfig = profile.moduleConfig
|
||||
val displayUnits = profile.config.display.units
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
isManaged = profile.config.security.isManaged,
|
||||
isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
|
||||
displayUnits = profile.config.display.units,
|
||||
isFahrenheit =
|
||||
moduleConfig.telemetry.environmentDisplayFahrenheit ||
|
||||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
|
||||
displayUnits = displayUnits,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* 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.feature.node.metrics
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics
|
||||
import org.meshtastic.proto.TelemetryProtos.Telemetry
|
||||
|
||||
class EnvironmentMetricsStateTest {
|
||||
|
||||
@Test
|
||||
fun `environmentMetricsFiltered correctly calculates times`() {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val metrics =
|
||||
listOf(
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now - 100)
|
||||
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(20f))
|
||||
.build(),
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now - 50)
|
||||
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(22f))
|
||||
.build(),
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now)
|
||||
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(21f))
|
||||
.build(),
|
||||
)
|
||||
val state = EnvironmentMetricsState(metrics)
|
||||
val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS)
|
||||
|
||||
assertEquals(now - 100, result.times.first)
|
||||
assertEquals(now, result.times.second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `environmentMetricsFiltered ignores invalid timestamps`() {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val metrics =
|
||||
listOf(
|
||||
Telemetry.newBuilder()
|
||||
.setTime(0)
|
||||
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(20f))
|
||||
.build(),
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now)
|
||||
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(21f))
|
||||
.build(),
|
||||
)
|
||||
val state = EnvironmentMetricsState(metrics)
|
||||
val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS)
|
||||
|
||||
// Only the valid timestamp should be considered for filters
|
||||
assertEquals(now, result.times.first)
|
||||
assertEquals(now, result.times.second)
|
||||
assertEquals(1, result.metrics.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `environmentMetricsFiltered handles valid zero temperatures`() {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val metrics =
|
||||
listOf(
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now)
|
||||
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(0.0f))
|
||||
.build(),
|
||||
)
|
||||
val state = EnvironmentMetricsState(metrics)
|
||||
val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS)
|
||||
|
||||
assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal])
|
||||
assertEquals(0.0f, result.rightMinMax.first, 0.01f)
|
||||
assertEquals(0.0f, result.rightMinMax.second, 0.01f)
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +141,7 @@ androidx-test-runner = { module = "androidx.test:runner", version = "1.7.0" }
|
|||
androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version = "3.7.0" }
|
||||
junit = { module = "junit:junit", version = "4.13.2" }
|
||||
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||
robolectric = { module = "org.robolectric:robolectric", version = "4.14.1" }
|
||||
|
||||
# Other
|
||||
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue