fix(settings): stop profile import dropping Mesh Beacon settings (#7416)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-29 10:11:16 +00:00
1 parent 7694d2103e
commit c7ff9b4ef0
14 files changed
+439 -65

No files matched your search

@@ -172,7 +172,7 @@ class MeshConfigHandlerImpl(
}
/** Returns a short summary of which Config variant is set. */
private fun Config.summarize(): String = when {
internal fun Config.summarize(): String = when {
device != null -> "device"
position != null -> "position"
power != null -> "power"
@@ -181,12 +181,14 @@ private fun Config.summarize(): String = when {
lora != null -> "lora"
bluetooth != null -> "bluetooth"
security != null -> "security"
sessionkey != null -> "sessionkey"
device_ui != null -> "device_ui"
else -> "unknown"
}
/** Returns a short summary of which ModuleConfig variant is set. */
@Suppress("CyclomaticComplexMethod")
private fun ModuleConfig.summarize(): String = when {
internal fun ModuleConfig.summarize(): String = when {
mqtt != null -> "mqtt"
serial != null -> "serial"
external_notification != null -> "external_notification"
@@ -201,6 +203,8 @@ private fun ModuleConfig.summarize(): String = when {
detection_sensor != null -> "detection_sensor"
paxcounter != null -> "paxcounter"
statusmessage != null -> "statusmessage"
traffic_management != null -> "traffic_management"
tak != null -> "tak"
mesh_beacon != null -> "mesh_beacon"
else -> "unknown"
}
@@ -0,0 +1,49 @@
/*
* 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.manager
import com.squareup.wire.ProtoAdapter
import com.squareup.wire.WireField
import org.meshtastic.proto.Config
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** Walks Wire's generated oneof, so a variant the proto grows fails here by name. JVM-only: reads `@WireField`. */
class ConfigSummaryCoverageTest {
@Test
fun `every Config variant is summarized by its field name`() {
val variants =
Config::class.java.declaredFields.filter {
it.getAnnotation(WireField::class.java)?.oneofName == "payload_variant"
}
assertTrue(variants.isNotEmpty(), "found no @WireField fields in the Config payload_variant oneof")
assertTrue(variants.any { it.name == "lora" }, "lora is not among the oneof fields found")
val mislabelled =
variants
.associate { field ->
val value = (field.type.getField("ADAPTER").get(null) as ProtoAdapter<*>).decode(ByteArray(0))
val config = Config.Builder().also { it.javaClass.getField(field.name).set(it, value) }.build()
field.name to config.summarize()
}
.filter { (name, summary) -> name != summary }
assertEquals(emptyMap(), mislabelled, "these variants are logged under the wrong name")
}
}
@@ -0,0 +1,50 @@
/*
* 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.manager
import com.squareup.wire.ProtoAdapter
import com.squareup.wire.WireField
import org.meshtastic.proto.ModuleConfig
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** Walks Wire's generated oneof, so a variant the proto grows fails here by name. JVM-only: reads `@WireField`. */
class ModuleConfigSummaryCoverageTest {
@Test
fun `every ModuleConfig variant is summarized by its field name`() {
val variants =
ModuleConfig::class.java.declaredFields.filter {
it.getAnnotation(WireField::class.java)?.oneofName == "payload_variant"
}
assertTrue(variants.isNotEmpty(), "found no @WireField fields in the ModuleConfig payload_variant oneof")
assertTrue(variants.any { it.name == "mesh_beacon" }, "mesh_beacon is not among the oneof fields found")
val mislabelled =
variants
.associate { field ->
val value = (field.type.getField("ADAPTER").get(null) as ProtoAdapter<*>).decode(ByteArray(0))
val config =
ModuleConfig.Builder().also { it.javaClass.getField(field.name).set(it, value) }.build()
field.name to config.summarize()
}
.filter { (name, summary) -> name != summary }
assertEquals(emptyMap(), mislabelled, "these variants are logged under the wrong name")
}
}
@@ -85,6 +85,9 @@ class ModuleConfigDataSource(private val moduleConfigStore: CoreModuleConfigData
config.statusmessage != null ->
current.newBuilder().also { wb -> wb.statusmessage = config.statusmessage }.build()
config.traffic_management != null ->
current.newBuilder().also { wb -> wb.traffic_management = config.traffic_management }.build()
config.tak != null -> current.newBuilder().also { wb -> wb.tak = config.tak }.build()
config.mesh_beacon != null ->
@@ -0,0 +1,63 @@
/*
* 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.datastore
import com.squareup.wire.ProtoAdapter
import com.squareup.wire.WireField
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.datastore.di.CoreModuleConfigDataStore
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** Walks Wire's generated oneof, so a variant the proto grows fails here by name. JVM-only: reads `@WireField`. */
class ModuleConfigDataSourceCoverageTest {
private class InMemoryModuleConfigStore : CoreModuleConfigDataStore {
override val data = MutableStateFlow(LocalModuleConfig.Builder().build())
override suspend fun updateData(transform: suspend (t: LocalModuleConfig) -> LocalModuleConfig) =
transform(data.value).also { data.value = it }
}
@Test
fun `every ModuleConfig variant is persisted into its LocalModuleConfig section`() = runTest {
val store = InMemoryModuleConfigStore()
val dataSource = ModuleConfigDataSource(store)
val variants =
ModuleConfig::class.java.declaredFields.filter {
it.getAnnotation(WireField::class.java)?.oneofName == "payload_variant"
}
assertTrue(variants.isNotEmpty(), "found no @WireField fields in the ModuleConfig payload_variant oneof")
assertTrue(variants.any { it.name == "mesh_beacon" }, "mesh_beacon is not among the oneof fields found")
variants.forEach { field ->
val value = (field.type.getField("ADAPTER").get(null) as ProtoAdapter<*>).decode(ByteArray(0))
dataSource.setLocalModuleConfig(
ModuleConfig.Builder().also { it.javaClass.getField(field.name).set(it, value) }.build(),
)
}
val stored = store.data.first()
val dropped = variants.map { it.name }.filter { LocalModuleConfig::class.java.getField(it).get(stored) == null }
assertEquals(emptyList(), dropped, "these variants are never persisted, or a later one cleared them")
}
}
@@ -177,7 +177,10 @@ constructor(
}
lmc.paxcounter?.let { setModuleConfig(ModuleConfig.Builder().also { wb -> wb.paxcounter = it }.build()) }
lmc.statusmessage?.let { setModuleConfig(ModuleConfig.Builder().also { wb -> wb.statusmessage = it }.build()) }
// traffic_management is not installed: it has no settings screen, and a node built without the module exports
// position_min_interval_secs = 0, which would silently turn off this node's default-on position dedup.
lmc.tak?.let { setModuleConfig(ModuleConfig.Builder().also { wb -> wb.tak = it }.build()) }
lmc.mesh_beacon?.let { setModuleConfig(ModuleConfig.Builder().also { wb -> wb.mesh_beacon = it }.build()) }
}
private suspend fun AdminEditScope.installChannelsAndLora(
@@ -87,7 +87,7 @@ class InstallProfileUseCaseTest {
}
@Test
fun `invoke installs all sections of a full profile`() = runTest {
fun `invoke opens an edit session for a populated profile`() = runTest {
val profile =
DeviceProfile.Builder()
.also { wb ->
@@ -0,0 +1,118 @@
/*
* 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.domain.usecase.settings
import com.squareup.wire.Message
import com.squareup.wire.ProtoAdapter
import com.squareup.wire.WireField
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.testing.FakeRadioConfigRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import java.lang.reflect.Field
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Walks Wire's generated fields, so a module config section the proto grows fails here by name until profile install
* writes it or [notInstalled] names it. JVM-only because it reads `@WireField`.
*/
class InstallProfileModuleConfigCoverageTest {
private val notInstalled =
mapOf(
"traffic_management" to
"no settings screen, and a node built without the module exports its position dedup switched off",
)
@Test
fun `every section named as not installed is still a LocalModuleConfig section`() {
val stale = notInstalled.keys - localModuleConfigSections().map { it.name }.toSet()
assertEquals(emptySet(), stale, "these sections left LocalModuleConfig, so drop them from notInstalled")
}
@Test
fun `every LocalModuleConfig section has a ModuleConfig variant of the same name and type`() {
val variantTypes = moduleConfigVariants().associate { it.name to it.type }
val unmatched = localModuleConfigSections().filter { variantTypes[it.name] != it.type }.map { it.name }
assertEquals(emptyList(), unmatched, "these LocalModuleConfig sections have no matching ModuleConfig variant")
}
@Test
fun `profile install writes every section except those named as not installed`() = runTest {
val radioController = FakeRadioController()
val sections = localModuleConfigSections()
val moduleConfig =
LocalModuleConfig.Builder()
.also { builder ->
sections.forEach { builder.javaClass.getField(it.name).set(builder, defaultOf(it)) }
}
.build()
InstallProfileUseCase(radioController, FakeRadioConfigRepository())(
destNum = 1234,
profile = DeviceProfile.Builder().also { it.module_config = moduleConfig }.build(),
currentUser = null,
currentLoraConfig = null,
isLocal = false,
)
val written = radioController.allModuleConfigs.associate { it.onlyVariant() }
val installed = sections.filter { it.name !in notInstalled }
assertEquals(
emptyList(),
installed.map { it.name } - written.keys,
"profile install never writes these module config sections",
)
assertEquals(emptySet(), notInstalled.keys intersect written.keys, "profile install writes these after all")
assertEquals(radioController.allModuleConfigs.size, written.size, "a section was written more than once")
installed.forEach { assertEquals(it.get(moduleConfig), written[it.name], "${it.name} was written changed") }
}
private fun ModuleConfig.onlyVariant(): Pair<String, Any> =
moduleConfigVariants().mapNotNull { field -> field.get(this)?.let { field.name to it } }.single()
private fun localModuleConfigSections(): List<Field> = LocalModuleConfig::class
.java
.declaredFields
.filter { it.isAnnotationPresent(WireField::class.java) && Message::class.java.isAssignableFrom(it.type) }
.also { sections ->
assertTrue(sections.isNotEmpty(), "found no @WireField message fields on LocalModuleConfig")
assertTrue(sections.any { it.name == "mesh_beacon" }, "mesh_beacon is not among the sections found")
}
private fun moduleConfigVariants(): List<Field> = ModuleConfig::class
.java
.declaredFields
.filter { it.getAnnotation(WireField::class.java)?.oneofName == "payload_variant" }
.also { variants ->
assertTrue(
variants.isNotEmpty(),
"found no @WireField fields in the ModuleConfig payload_variant oneof",
)
assertTrue(variants.any { it.name == "mesh_beacon" }, "mesh_beacon is not among the oneof fields found")
}
private fun defaultOf(field: Field): Any? =
(field.type.getField("ADAPTER").get(null) as ProtoAdapter<*>).decode(ByteArray(0))
}
+5 -4
View File
@@ -2,7 +2,7 @@
title: Settings — Modules & Admin
parent: User Guide
nav_order: 8
last_updated: 2026-09-19
last_updated: 2026-09-28
description: Configure optional feature modules (MQTT, telemetry, canned messages, TAK, and more) and perform device administration.
aliases:
- modules
@@ -30,7 +30,7 @@ Module settings use a card-based layout with toggle switches, dropdowns, text fi
Every module lives under **Settings → Module configuration**.
> ⚠️ **Important:** Saving a module screen restarts the node — the button reads **Save & restart**, and the node is unreachable for a few seconds afterwards. External Notification and Mesh Beacon are the exceptions: their button reads **Save**, and the node may still restart for some changes.
> ⚠️ **Important:** Saving a module screen restarts the node: the button reads **Save & restart**, and the node is unreachable for a few seconds afterwards. External Notification and Mesh Beacon are the exceptions: their button reads **Save**. External Notification may still restart the node for some changes, while a Mesh Beacon change applies without a restart.
### MQTT module
@@ -292,8 +292,9 @@ Remotely configure nodes that share your admin key:
### Backup & Restore
**Settings → Backup & Restore** writes the connected node's whole configuration to a file with
**Export configuration**, and reads a saved file back in with **Import configuration**. Export
before a factory reset, or to copy one node's setup onto another. The section is shown for your
**Export configuration**, and reads a saved file back in with **Import configuration**.
Traffic Management settings are exported but not applied on import. Export before a factory
reset, or to copy one node's setup onto another. The section is shown for your
own node only, not over remote admin.
### Advanced
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
/** Replaces the section [config] carries and keeps every other section. */
internal fun LocalModuleConfig.mergedWith(config: ModuleConfig): LocalModuleConfig = newBuilder()
.also { wb ->
wb.mqtt = config.mqtt ?: mqtt
wb.serial = config.serial ?: serial
wb.external_notification = config.external_notification ?: external_notification
wb.store_forward = config.store_forward ?: store_forward
wb.range_test = config.range_test ?: range_test
wb.telemetry = config.telemetry ?: telemetry
wb.canned_message = config.canned_message ?: canned_message
wb.audio = config.audio ?: audio
wb.remote_hardware = config.remote_hardware ?: remote_hardware
wb.neighbor_info = config.neighbor_info ?: neighbor_info
wb.ambient_lighting = config.ambient_lighting ?: ambient_lighting
wb.detection_sensor = config.detection_sensor ?: detection_sensor
wb.paxcounter = config.paxcounter ?: paxcounter
wb.statusmessage = config.statusmessage ?: statusmessage
wb.traffic_management = config.traffic_management ?: traffic_management
wb.tak = config.tak ?: tak
wb.mesh_beacon = config.mesh_beacon ?: mesh_beacon
}
.build()
@@ -599,37 +599,10 @@ open class RadioConfigViewModel(
}
}
@Suppress("CyclomaticComplexMethod")
fun setModuleConfig(config: ModuleConfig) {
val destNum = destNum ?: destNode.value?.num ?: return
safeLaunch(tag = "setModuleConfig") {
_radioConfigState.update { state ->
state.copy(
moduleConfig =
state.moduleConfig
.newBuilder()
.also { wb ->
wb.mqtt = config.mqtt ?: state.moduleConfig.mqtt
wb.serial = config.serial ?: state.moduleConfig.serial
wb.external_notification =
config.external_notification ?: state.moduleConfig.external_notification
wb.store_forward = config.store_forward ?: state.moduleConfig.store_forward
wb.range_test = config.range_test ?: state.moduleConfig.range_test
wb.telemetry = config.telemetry ?: state.moduleConfig.telemetry
wb.canned_message = config.canned_message ?: state.moduleConfig.canned_message
wb.audio = config.audio ?: state.moduleConfig.audio
wb.remote_hardware = config.remote_hardware ?: state.moduleConfig.remote_hardware
wb.neighbor_info = config.neighbor_info ?: state.moduleConfig.neighbor_info
wb.ambient_lighting = config.ambient_lighting ?: state.moduleConfig.ambient_lighting
wb.detection_sensor = config.detection_sensor ?: state.moduleConfig.detection_sensor
wb.paxcounter = config.paxcounter ?: state.moduleConfig.paxcounter
wb.statusmessage = config.statusmessage ?: state.moduleConfig.statusmessage
wb.tak = config.tak ?: state.moduleConfig.tak
wb.mesh_beacon = config.mesh_beacon ?: state.moduleConfig.mesh_beacon
}
.build(),
)
}
_radioConfigState.update { state -> state.copy(moduleConfig = state.moduleConfig.mergedWith(config)) }
expectRestartIfLocal(config.saveRebootBehavior())
radioConfigUseCase.setModuleConfig(destNum, config, onRequestId = ::registerWriteRequestId)
}
@@ -1342,35 +1315,8 @@ open class RadioConfigViewModel(
}
is RadioResponseResult.ModuleConfigResponse -> {
val response = result.config
_radioConfigState.update { state ->
state.copy(
moduleConfig =
state.moduleConfig
.newBuilder()
.also { wb ->
wb.mqtt = response.mqtt ?: state.moduleConfig.mqtt
wb.serial = response.serial ?: state.moduleConfig.serial
wb.external_notification =
response.external_notification ?: state.moduleConfig.external_notification
wb.store_forward = response.store_forward ?: state.moduleConfig.store_forward
wb.range_test = response.range_test ?: state.moduleConfig.range_test
wb.telemetry = response.telemetry ?: state.moduleConfig.telemetry
wb.canned_message = response.canned_message ?: state.moduleConfig.canned_message
wb.audio = response.audio ?: state.moduleConfig.audio
wb.remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware
wb.neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info
wb.ambient_lighting =
response.ambient_lighting ?: state.moduleConfig.ambient_lighting
wb.detection_sensor =
response.detection_sensor ?: state.moduleConfig.detection_sensor
wb.paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter
wb.statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage
wb.tak = response.tak ?: state.moduleConfig.tak
wb.mesh_beacon = response.mesh_beacon ?: state.moduleConfig.mesh_beacon
}
.build(),
)
state.copy(moduleConfig = state.moduleConfig.mergedWith(result.config))
}
if (!isLateRemoteRead) incrementCompleted()
}
@@ -1536,6 +1482,8 @@ internal fun Config.saveRebootBehavior(): RebootBehavior = when {
else -> RebootBehavior.MAY_RESTART
}
/** Firmware `AdminModule::handleSetModuleConfig` reboots for every module section except status message. */
/**
* Firmware `AdminModule::handleSetModuleConfig` reboots for every module section except status message and Mesh Beacon.
*/
internal fun ModuleConfig.saveRebootBehavior(): RebootBehavior =
if (statusmessage != null) RebootBehavior.NEVER else RebootBehavior.ALWAYS
if (statusmessage != null || mesh_beacon != null) RebootBehavior.NEVER else RebootBehavior.ALWAYS
@@ -68,6 +68,7 @@ import org.meshtastic.core.ui.component.RegularPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.RebootBehavior
import org.meshtastic.feature.settings.util.FixedUpdateIntervals
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.toDisplayString
@@ -181,6 +182,7 @@ fun MeshBeaconConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit,
// broadcast fields verbatim.
saveEnabled =
meshBeaconSaveEnabled(state.connected, radioLora.use_preset, intervalValid, state.channelList.isNotEmpty()),
rebootBehavior = RebootBehavior.NEVER,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
@@ -2804,6 +2804,48 @@ class RadioConfigViewModelTest {
assertFalse(nodeRestartTracker.restartExpected.value)
}
@Test
fun `local module save that reboots opens the restart window`() = runTest {
val node = Node(num = 123, user = User.Builder().also { wb -> wb.id = "!123" }.build())
nodeRepository.setNodes(listOf(node))
nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 123))
viewModel = createViewModel()
runCurrent()
everySuspend { radioConfigUseCase.setModuleConfig(any(), any(), any()) } returns 42
nodeRestartTracker.onConnected()
viewModel.setModuleConfig(
ModuleConfig.Builder()
.also { wb -> wb.mqtt = ModuleConfig.MQTTConfig.Builder().also { wb -> wb.enabled = true }.build() }
.build(),
)
runCurrent()
assertTrue(nodeRestartTracker.restartExpected.value)
}
@Test
fun `local Mesh Beacon save does not open the restart window`() = runTest {
val node = Node(num = 123, user = User.Builder().also { wb -> wb.id = "!123" }.build())
nodeRepository.setNodes(listOf(node))
nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 123))
viewModel = createViewModel()
runCurrent()
everySuspend { radioConfigUseCase.setModuleConfig(any(), any(), any()) } returns 42
nodeRestartTracker.onConnected()
viewModel.setModuleConfig(
ModuleConfig.Builder()
.also { wb ->
wb.mesh_beacon = MeshBeaconConfig.Builder().also { wb -> wb.broadcast_message = "hi" }.build()
}
.build(),
)
runCurrent()
assertFalse(nodeRestartTracker.restartExpected.value)
}
}
/** Extracts the trailing `onRequestId` callback from a mocked request method's args. */
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio
import com.squareup.wire.ProtoAdapter
import com.squareup.wire.WireField
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** Walks Wire's generated oneof, so a variant the proto grows fails here by name. JVM-only: reads `@WireField`. */
class ModuleConfigMergeCoverageTest {
@Test
fun `merging every ModuleConfig variant in turn fills and keeps every LocalModuleConfig section`() {
val variants =
ModuleConfig::class.java.declaredFields.filter {
it.getAnnotation(WireField::class.java)?.oneofName == "payload_variant"
}
assertTrue(variants.isNotEmpty(), "found no @WireField fields in the ModuleConfig payload_variant oneof")
assertTrue(variants.any { it.name == "mesh_beacon" }, "mesh_beacon is not among the oneof fields found")
val merged =
variants.fold(LocalModuleConfig.Builder().build()) { acc, field ->
val value = (field.type.getField("ADAPTER").get(null) as ProtoAdapter<*>).decode(ByteArray(0))
acc.mergedWith(ModuleConfig.Builder().also { it.javaClass.getField(field.name).set(it, value) }.build())
}
val dropped = variants.map { it.name }.filter { LocalModuleConfig::class.java.getField(it).get(merged) == null }
assertEquals(emptyList(), dropped, "these variants are never merged, or a later merge cleared them")
}
}