diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index d51fc8572e..e7c56f0eb5 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1098,6 +1098,7 @@ mesh_beacon_region_required mesh_beacon_target mesh_beacon_target_add mesh_beacon_target_channel_index +mesh_beacon_target_default mesh_beacon_target_remove mesh_beacon_targets mesh_map_location diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index a1bccd6cef..aeb5f78824 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1134,6 +1134,7 @@ Target %1$d Add target Channel + Default Remove target Broadcast targets Mesh Map Location diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt index d137013c78..54861b1f5c 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigItemList.kt @@ -57,6 +57,7 @@ import org.meshtastic.core.resources.mesh_beacon_region_required import org.meshtastic.core.resources.mesh_beacon_target import org.meshtastic.core.resources.mesh_beacon_target_add import org.meshtastic.core.resources.mesh_beacon_target_channel_index +import org.meshtastic.core.resources.mesh_beacon_target_default import org.meshtastic.core.resources.mesh_beacon_target_remove import org.meshtastic.core.resources.mesh_beacon_targets import org.meshtastic.core.resources.plurals_seconds @@ -95,9 +96,13 @@ private fun Int.hasFlag(flag: Int): Boolean = (this and flag) != 0 * * The region and offered/transmit preset are never user-chosen here: the radio's own LoRa region and configured preset * are always stamped in on save (`stampBeaconConfigForSave`), so the beacon can never transmit region or preset - * information the radio itself does not use. The repeated `broadcast_targets` list ([BroadcastTargetsCard]) is the only - * way to name extra beacon destinations beyond the offered channel; an empty list sends one beacon on that channel - * alone. + * information the radio itself does not use. `broadcast_offer_*` is the invitation payload content shown to listeners + * (what channel/preset they could join); the repeated `broadcast_targets` list ([BroadcastTargetsCard]) is the + * separate, only, TX destination list -- which radio settings the beacon packet itself is actually transmitted on. + * Firmware sends one beacon on the node's running preset and primary channel when this list is empty + * (`MeshBeaconModule.cpp::sendBeacon`), so the editor seeds and floors the list at one row ([seedBeaconTargets], + * [removeBeaconTarget]) rather than ever showing zero rows -- design#140 behavior 6 keeps that implicit default visible + * and editable instead of hidden. */ @Suppress("LongMethod") @Composable @@ -130,7 +135,7 @@ fun MeshBeaconConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, return } - val formState = rememberConfigState(initialValue = meshBeaconConfig) + val formState = rememberConfigState(initialValue = initialBeaconFormState(meshBeaconConfig)) val listenFlag = MeshBeaconConfig.Flags.FLAG_LISTEN_ENABLED.value val broadcastFlag = MeshBeaconConfig.Flags.FLAG_BROADCAST_ENABLED.value @@ -362,12 +367,14 @@ internal fun OfferChannelPreference( } /** - * Editor for the repeated `broadcast_targets` list: extra beacon destinations beyond the offered channel. Each row - * picks one of the radio's own channels ([channelItems]) and a preset filtered by [presetConstraint] (design#140 - * behaviors 2 and 7); region is no longer a row concept (behavior 1), the radio's own region applies to every target. + * Editor for the repeated `broadcast_targets` list: the beacon's actual TX destinations (design#140 behavior 6). Each + * row picks one of the radio's own channels ([channelItems]) or the "Default" sentinel, and a preset filtered by + * [presetConstraint] (design#140 behaviors 2 and 7) or "Default"; region is no longer a row concept (behavior 1), the + * radio's own region applies to every target. Internal (not private): unit-testable directly, mirroring + * [OfferChannelPreference]. */ @Composable -private fun BroadcastTargetsCard( +internal fun BroadcastTargetsCard( targets: List, enabled: Boolean, channelItems: List>, @@ -390,7 +397,7 @@ private fun BroadcastTargetsCard( presetsGated = presetsGated, capabilities = capabilities, onChange = { updated -> onChange(targets.mapIndexed { i, t -> if (i == index) updated(t) else t }) }, - onRemove = { onChange(targets.filterIndexed { i, _ -> i != index }) }, + onRemove = { onChange(removeBeaconTarget(targets, index)) }, ) } HorizontalDivider() @@ -421,18 +428,24 @@ private fun BroadcastTargetRow( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), style = MaterialTheme.typography.titleSmall, ) - val rowChannelIndex = target.channel_index ?: 0 + // Nullable value type throughout (design#140 behavior 6): `null` is the "Default" sentinel, matching the wire + // format where an unset channel_index/preset falls back to the running config (module_config.proto's + // BroadcastTarget). A row is never forced to a resolved value the way it was before this row supported "Default". + val defaultLabel = stringResource(Res.string.mesh_beacon_target_default) + val rowChannelIndex = target.channel_index + val nullableChannelItems: List> = + listOf(DropDownItem(value = null, label = defaultLabel)) + + channelItems.map { DropDownItem(it.value, it.label, it.icon, it.color, it.enabled, it.testTag) } val rowChannelItems = - if (channelItems.none { it.value == rowChannelIndex }) { - val fallback = - DropDownItem( + if (rowChannelIndex != null && nullableChannelItems.none { it.value == rowChannelIndex }) { + nullableChannelItems + + DropDownItem( value = rowChannelIndex, label = stringResource(Res.string.mesh_beacon_channel_number, rowChannelIndex), enabled = false, ) - channelItems + fallback } else { - channelItems + nullableChannelItems } DropDownPreference( title = stringResource(Res.string.mesh_beacon_target_channel_index), @@ -441,17 +454,22 @@ private fun BroadcastTargetRow( enabled = enabled, onItemSelected = { channelIndex -> onChange { selectBeaconTargetChannel(it, channelIndex, currentPreset) } }, ) - val selectedPreset = target.preset ?: presetConstraint.defaultPreset + val rowPreset = target.preset val presetItems = - remember(presetConstraint, presetsGated, selectedPreset, capabilities) { - buildPresetItems(presetConstraint, presetsGated, selectedPreset, capabilities) + remember(presetConstraint, presetsGated, rowPreset, capabilities) { + buildPresetItems(presetConstraint, presetsGated, rowPreset ?: presetConstraint.defaultPreset, capabilities) } + val nullablePresetItems: List> = + listOf(DropDownItem(value = null, label = defaultLabel)) + + presetItems.map { + DropDownItem(it.value, it.label, it.icon, it.color, it.enabled, it.testTag) + } val presetSummary = if (presetsGated) stringResource(Res.string.config_lora_modem_preset_licensed_summary) else null DropDownPreference( title = stringResource(Res.string.mesh_beacon_on_preset), summary = presetSummary, - items = presetItems, - selectedItem = selectedPreset, + items = nullablePresetItems, + selectedItem = rowPreset, enabled = enabled, onItemSelected = { sel -> onChange { it.copy(preset = sel) } }, ) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicy.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicy.kt index 266f1bc239..f4fcb652d6 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicy.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicy.kt @@ -121,15 +121,50 @@ internal fun stampBeaconConfigForSave( } /** - * Applies a channel pick to one broadcast target row (design#140 behavior 7): the channel index is always set, and the - * radio's currently-configured preset is preselected only the first time the row gets a channel, never overwriting a - * preset the user already chose. + * Applies a channel pick to one broadcast target row (design#140 behavior 7): the radio's currently-configured preset + * is preselected only the first time the row gets a concrete channel, never overwriting a preset the user already + * chose. Picking the "Default" sentinel (`channelIndex = null`, design#140 behavior 6) leaves any existing preset + * untouched rather than preselecting -- there is no channel to have "just been picked". */ internal fun selectBeaconTargetChannel( target: MeshBeaconConfig.BroadcastTarget, - channelIndex: Int, + channelIndex: Int?, currentPreset: ModemPreset, -): MeshBeaconConfig.BroadcastTarget = target.copy(channel_index = channelIndex, preset = target.preset ?: currentPreset) +): MeshBeaconConfig.BroadcastTarget = target.copy( + channel_index = channelIndex, + preset = if (channelIndex != null) target.preset ?: currentPreset else target.preset, +) + +/** + * Seeds an empty stored `broadcast_targets` list with one default row (design#140 behavior 6: "one row saves as the + * single broadcast config, added rows save as broadcast targets"). Firmware's own fallback for an empty list is a + * single beacon on the node's running preset and region over the primary channel (`MeshBeaconModule.cpp::sendBeacon`); + * seeding a row here makes that default visible and editable in the editor instead of leaving it implicit. A non-empty + * stored list is returned unchanged. + */ +internal fun seedBeaconTargets(stored: List): List = + stored.ifEmpty { listOf(MeshBeaconConfig.BroadcastTarget()) } + +/** + * [MeshBeaconConfigScreen]'s actual `formState` initial value: [seedBeaconTargets] applied to the loaded config's + * `broadcast_targets`, everything else untouched. Pulled out as its own function (rather than inlined at the + * `rememberConfigState` call site) so a test can call the exact production entry point instead of separately calling + * [seedBeaconTargets] and only asserting against it in isolation. + */ +internal fun initialBeaconFormState(loaded: MeshBeaconConfig): MeshBeaconConfig = + loaded.copy(broadcast_targets = seedBeaconTargets(loaded.broadcast_targets)) + +/** + * Removes the target row at [index], keeping the list at a floor of one row (design#140 behavior 6: "no UI that changes + * shape once targets exist" -- the list must never render with zero rows, since zero rows would hide firmware's + * implicit single-target fallback rather than represent "no beacon"). Removing the only remaining row replaces it with + * a fresh default row instead of emptying the list, mirroring the reference iOS editor. + */ +internal fun removeBeaconTarget( + targets: List, + index: Int, +): List = + targets.filterIndexed { i, _ -> i != index }.ifEmpty { listOf(MeshBeaconConfig.BroadcastTarget()) } /** The three broadcast-half gating decisions design#140 Q1 hangs off `radioLora.use_preset` and the STORED flag. */ internal data class BeaconBroadcastGate( diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicyTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicyTest.kt index dc3fcd83e5..71070fca3e 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicyTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigPolicyTest.kt @@ -215,6 +215,24 @@ class MeshBeaconConfigPolicyTest { assertEquals(ModemPreset.SHORT_FAST, stamped.broadcast_targets[1].preset) } + @Test + fun stampBeaconConfigForSave_defaultTargetRow_nullFieldsSurviveButRegionIsStamped() { + // A seeded/"Default" row (null channel_index, null preset) must reach the outgoing ModuleConfig with those + // fields still null -- that's the whole point of the sentinel, matching firmware's own "unset falls back to + // running config" semantics (module_config.proto). Region is the one field every target always gets, per + // save-time stamping (behavior 1), regardless of the row's own null fields. + val radioLora = + Config.LoRaConfig(region = RegionCode.EU_868, modem_preset = ModemPreset.MEDIUM_FAST, use_preset = true) + val config = MeshBeaconConfig(broadcast_targets = listOf(MeshBeaconConfig.BroadcastTarget())) + + val stamped = stampBeaconConfigForSave(config, config, radioLora, channelList = emptyList()) + + val target = stamped.broadcast_targets.single() + assertNull(target.channel_index) + assertNull(target.preset) + assertEquals(RegionCode.EU_868, target.region) + } + @Test fun stampBeaconConfigForSave_untouchedOfferChannel_defaultsToPrimary() { val radioLora = @@ -326,4 +344,87 @@ class MeshBeaconConfigPolicyTest { assertEquals(3, updated.channel_index) assertEquals(ModemPreset.LONG_MODERATE, updated.preset) } + + @Test + fun selectBeaconTargetChannel_defaultSentinel_clearsChannelAndLeavesPresetUntouched() { + val target = MeshBeaconConfig.BroadcastTarget(channel_index = 2, preset = null) + + val updated = selectBeaconTargetChannel(target, channelIndex = null, currentPreset = ModemPreset.SHORT_FAST) + + assertNull(updated.channel_index) + // Picking "Default" is not "picking a channel" (design#140 behavior 7 only fires on a concrete channel). + assertNull(updated.preset) + } + + @Test + fun selectBeaconTargetChannel_defaultSentinelWithConcretePreset_presetUnchanged() { + // Same as the null-preset case above, but with a preset the user has already deliberately chosen -- the + // regression this guards is "Default" silently resetting a concrete preset, not just leaving null alone. + val target = MeshBeaconConfig.BroadcastTarget(channel_index = 2, preset = ModemPreset.SHORT_FAST) + + val updated = selectBeaconTargetChannel(target, channelIndex = null, currentPreset = ModemPreset.LONG_FAST) + + assertNull(updated.channel_index) + assertEquals(ModemPreset.SHORT_FAST, updated.preset) + } + + @Test + fun seedBeaconTargets_emptyStoredList_seedsOneDefaultRow() { + val seeded = seedBeaconTargets(emptyList()) + + assertEquals(listOf(MeshBeaconConfig.BroadcastTarget()), seeded) + } + + @Test + fun seedBeaconTargets_nonEmptyStoredList_isUnchanged() { + val stored = listOf(MeshBeaconConfig.BroadcastTarget(channel_index = 1, preset = ModemPreset.LONG_FAST)) + + val seeded = seedBeaconTargets(stored) + + assertEquals(stored, seeded) + } + + @Test + fun initialBeaconFormState_emptyStoredConfig_seedsFormTargetsThroughTheProductionPath() { + // Calls the exact function MeshBeaconConfigScreen calls to build formState's initial value -- not + // seedBeaconTargets directly -- so this breaks if the screen's wiring to it ever comes apart. + val loaded = MeshBeaconConfig(broadcast_message = "hi", broadcast_targets = emptyList()) + + val initial = initialBeaconFormState(loaded) + + assertEquals(listOf(MeshBeaconConfig.BroadcastTarget()), initial.broadcast_targets) + assertEquals("hi", initial.broadcast_message) + } + + @Test + fun initialBeaconFormState_nonEmptyStoredConfig_isUnchanged() { + val stored = listOf(MeshBeaconConfig.BroadcastTarget(channel_index = 3)) + val loaded = MeshBeaconConfig(broadcast_targets = stored) + + val initial = initialBeaconFormState(loaded) + + assertEquals(stored, initial.broadcast_targets) + } + + @Test + fun removeBeaconTarget_removingOneOfSeveral_dropsOnlyThatRow() { + val targets = + listOf( + MeshBeaconConfig.BroadcastTarget(channel_index = 0), + MeshBeaconConfig.BroadcastTarget(channel_index = 1), + ) + + val updated = removeBeaconTarget(targets, index = 0) + + assertEquals(listOf(MeshBeaconConfig.BroadcastTarget(channel_index = 1)), updated) + } + + @Test + fun removeBeaconTarget_removingTheOnlyRow_reseedsADefaultRowInstead() { + val targets = listOf(MeshBeaconConfig.BroadcastTarget(channel_index = 4, preset = ModemPreset.SHORT_FAST)) + + val updated = removeBeaconTarget(targets, index = 0) + + assertEquals(listOf(MeshBeaconConfig.BroadcastTarget()), updated) + } } diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigUiTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigUiTest.kt index 50c49a6278..22b02a4fcb 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigUiTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MeshBeaconConfigUiTest.kt @@ -19,18 +19,28 @@ package org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Text +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.v2.runComposeUiTest +import org.meshtastic.core.model.Capabilities +import org.meshtastic.core.model.RegionPresetConstraint import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.mesh_beacon_broadcast_requires_preset import org.meshtastic.core.resources.mesh_beacon_no_channels import org.meshtastic.core.resources.mesh_beacon_region_required +import org.meshtastic.core.resources.mesh_beacon_target +import org.meshtastic.core.resources.mesh_beacon_target_default +import org.meshtastic.core.resources.mesh_beacon_target_remove import org.meshtastic.core.resources.save_changes import org.meshtastic.core.ui.component.DropDownItem import org.meshtastic.core.ui.component.EditTextPreference @@ -45,6 +55,7 @@ import org.meshtastic.proto.Config.LoRaConfig.RegionCode import org.meshtastic.proto.ModuleConfig.MeshBeaconConfig import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull /** * Mirrors [LoRaBandwidthUiTest]'s shape: composes [RadioConfigScreenList] directly with a hand-built [ConfigState], @@ -329,4 +340,137 @@ class MeshBeaconConfigUiTest { runOnIdle { assertEquals(listenFlag, savedConfig?.flags) } } + + @Test + fun emptyStoredConfig_productionSeedPathRendersExactlyOneRow() = runComposeUiTest { + // Goes through initialBeaconFormState -- the exact function MeshBeaconConfigScreen calls to build formState's + // initial value -- rather than calling seedBeaconTargets itself, so this proves the screen's own wiring + // rather than just the policy function in isolation. + val presetConstraint = + RegionPresetConstraint(presets = listOf(ModemPreset.LONG_FAST), ModemPreset.LONG_FAST, false) + val seededTargets = initialBeaconFormState(MeshBeaconConfig()).broadcast_targets + + setContent { + AppTheme { + BroadcastTargetsCard( + targets = seededTargets, + enabled = true, + channelItems = listOf(DropDownItem(0, "Primary")), + currentPreset = ModemPreset.LONG_FAST, + presetConstraint = presetConstraint, + presetsGated = false, + capabilities = Capabilities(firmwareVersion = null), + onChange = {}, + ) + } + } + + onNodeWithText(getString(Res.string.mesh_beacon_target, 1)).assertIsDisplayed() + onAllNodesWithText(getString(Res.string.mesh_beacon_target_remove)).assertCountEquals(1) + } + + @Test + fun removingTheLastTargetRow_replacesItWithAGenuineDefaultRow() = runComposeUiTest { + val presetConstraint = + RegionPresetConstraint(presets = listOf(ModemPreset.LONG_FAST), ModemPreset.LONG_FAST, false) + lateinit var targetsState: MutableState> + + setContent { + AppTheme { + targetsState = remember { mutableStateOf(seedBeaconTargets(emptyList())) } + BroadcastTargetsCard( + targets = targetsState.value, + enabled = true, + channelItems = listOf(DropDownItem(0, "Primary")), + currentPreset = ModemPreset.LONG_FAST, + presetConstraint = presetConstraint, + presetsGated = false, + capabilities = Capabilities(firmwareVersion = null), + onChange = { targetsState.value = it }, + ) + } + } + + onNodeWithText(getString(Res.string.mesh_beacon_target_remove)).performClick() + + // Not just "one row, one remove button" -- that would also pass for a wrong non-empty result. The replaced + // row must be a genuine fresh default: both fields null, not carried over from the removed row. + runOnIdle { assertEquals(listOf(MeshBeaconConfig.BroadcastTarget()), targetsState.value) } + onNodeWithText(getString(Res.string.mesh_beacon_target, 1)).assertIsDisplayed() + onAllNodesWithText(getString(Res.string.mesh_beacon_target_remove)).assertCountEquals(1) + } + + @Test + fun broadcastTargetRow_selectingDefaultChannel_clearsChannelIndexAndLeavesPresetUntouched() = runComposeUiTest { + val presetConstraint = + RegionPresetConstraint(presets = listOf(ModemPreset.LONG_FAST), ModemPreset.LONG_FAST, false) + lateinit var targetsState: MutableState> + + setContent { + AppTheme { + targetsState = remember { + mutableStateOf( + listOf(MeshBeaconConfig.BroadcastTarget(channel_index = 0, preset = ModemPreset.LONG_FAST)), + ) + } + BroadcastTargetsCard( + targets = targetsState.value, + enabled = true, + channelItems = listOf(DropDownItem(0, "Primary")), + currentPreset = ModemPreset.LONG_FAST, + presetConstraint = presetConstraint, + presetsGated = false, + capabilities = Capabilities(firmwareVersion = null), + onChange = { targetsState.value = it }, + ) + } + } + + // Opens the channel picker (currently showing the concrete channel "Primary") and picks "Default". + onNodeWithText("Primary").performClick() + onNodeWithText(getString(Res.string.mesh_beacon_target_default)).performClick() + + runOnIdle { + assertNull(targetsState.value.single().channel_index) + assertEquals(ModemPreset.LONG_FAST, targetsState.value.single().preset) + } + onNodeWithText(getString(Res.string.mesh_beacon_target_default)).assertIsDisplayed() + } + + @Test + fun broadcastTargetRow_selectingDefaultPreset_clearsPresetAndLeavesChannelUntouched() = runComposeUiTest { + val presetConstraint = + RegionPresetConstraint(presets = listOf(ModemPreset.LONG_FAST), ModemPreset.LONG_FAST, false) + lateinit var targetsState: MutableState> + + setContent { + AppTheme { + targetsState = remember { + mutableStateOf( + listOf(MeshBeaconConfig.BroadcastTarget(channel_index = 0, preset = ModemPreset.LONG_FAST)), + ) + } + BroadcastTargetsCard( + targets = targetsState.value, + enabled = true, + channelItems = listOf(DropDownItem(0, "Primary")), + currentPreset = ModemPreset.LONG_FAST, + presetConstraint = presetConstraint, + presetsGated = false, + capabilities = Capabilities(firmwareVersion = null), + onChange = { targetsState.value = it }, + ) + } + } + + // Opens the preset picker (currently showing the concrete preset "LONG_FAST") and picks "Default". + onNodeWithText(ModemPreset.LONG_FAST.name).performClick() + onNodeWithText(getString(Res.string.mesh_beacon_target_default)).performClick() + + runOnIdle { + assertNull(targetsState.value.single().preset) + assertEquals(0, targetsState.value.single().channel_index) + } + onNodeWithText(getString(Res.string.mesh_beacon_target_default)).assertIsDisplayed() + } }