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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue