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

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

View file

@ -0,0 +1,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)
}
}

View file

@ -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 &amp; 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>

View file

@ -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)
}
}

View file

@ -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