diff --git a/.github/workflows/reusable-android-build.yml b/.github/workflows/reusable-android-build.yml index dbb3cc978..fd7f34064 100644 --- a/.github/workflows/reusable-android-build.yml +++ b/.github/workflows/reusable-android-build.yml @@ -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() }} diff --git a/.github/workflows/reusable-android-test.yml b/.github/workflows/reusable-android-test.yml index c906ab946..f89459fbf 100644 --- a/.github/workflows/reusable-android-test.yml +++ b/.github/workflows/reusable-android-test.yml @@ -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() }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 783bb1fa8..a4e555857 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -247,6 +247,7 @@ dependencies { androidTestImplementation(libs.hilt.android.testing) testImplementation(libs.junit) + testImplementation(libs.mockk) testImplementation(libs.kotlinx.coroutines.test) } diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt index d37e73908..5985fb9bc 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt @@ -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, diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt index 3cd8247b4..14688a602 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt @@ -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 } } diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt new file mode 100644 index 000000000..b0ddc037e --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt @@ -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 . + */ +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()) } + } +} diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/TCPInterfaceTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/TCPInterfaceTest.kt new file mode 100644 index 000000000..57f749fa9 --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/repository/radio/TCPInterfaceTest.kt @@ -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 . + */ +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. +} diff --git a/app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt b/app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt new file mode 100644 index 000000000..548642ab6 --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt @@ -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 . + */ +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) } + } +} diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt new file mode 100644 index 000000000..f9502b066 --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt @@ -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 . + */ +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() + 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()) } + + 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() + 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()) } + assertEquals(7, meshPacketSlot.captured.hopLimit) + } +} diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt new file mode 100644 index 000000000..bf9d67aa6 --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt @@ -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 . + */ +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.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(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()) } + } + + @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, + ) + } +} diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt index 256aa47d6..af8a7633e 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt @@ -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() + 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() + 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) } } diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt new file mode 100644 index 000000000..53e618e44 --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt @@ -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 . + */ +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()) } + } +} diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt index c1de393db..1100362e0 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt @@ -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) } } diff --git a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt new file mode 100644 index 000000000..7e13caa76 --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt @@ -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 . + */ +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. + } +} diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt index 6de949425..ebb7884e5 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt @@ -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") diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt index 6375dec7b..35e5f29ec 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt @@ -24,6 +24,14 @@ import org.gradle.kotlin.dsl.configure fun Project.configureKover() { extensions.configure { reports { + total { + xml { + onCheck.set(true) + } + html { + onCheck.set(true) + } + } filters { excludes { // Exclude generated classes diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt new file mode 100644 index 000000000..8e0ff4dc3 --- /dev/null +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt @@ -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 . + */ +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) + } +} diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 3db9a108e..ce83b8e20 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -1137,4 +1137,8 @@ Estimated area: unknown accuracy Mark as read Now + Add Channels + 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. + Replace Channels & Settings + This QR code contains a complete configuration. This will REPLACE your existing channels and radio settings. All existing channels will be removed. diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialogTest.kt b/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialogTest.kt deleted file mode 100644 index 07cf076e2..000000000 --- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialogTest.kt +++ /dev/null @@ -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 . - */ -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) - } -} diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt index b4f2179ba..40526e30e 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt @@ -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 diff --git a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportMtuTest.kt b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportMtuTest.kt new file mode 100644 index 000000000..02702d398 --- /dev/null +++ b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportMtuTest.kt @@ -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 . + */ +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), + ) + } + } +} diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index 0e20a7817..a039d5a4d 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -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) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt index 0e74feb66..aa531ffb0 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt @@ -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 . */ - 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, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 215f12ff5..d94df2c3c 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -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, ) } } diff --git a/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt b/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt new file mode 100644 index 000000000..5bec4745b --- /dev/null +++ b/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt @@ -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 . + */ +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) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3e9b9157b..4c29f22ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }