Refactor map layer management and navigation infrastructure (#4921)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-25 19:29:24 -05:00 committed by GitHub
parent b608a04ca4
commit a005231d94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 5408 additions and 3090 deletions

View file

@ -0,0 +1,33 @@
/*
* 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.channel
import org.junit.runner.RunWith
import org.meshtastic.core.testing.setupTestContext
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.BeforeTest
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ChannelViewModelTest : CommonChannelViewModelTest() {
@BeforeTest
fun setup() {
setupTestContext()
setupRepo()
}
}

View file

@ -22,14 +22,20 @@ import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import io.kotest.matchers.ints.shouldBeInRange
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.checkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
@ -41,62 +47,60 @@ import org.meshtastic.core.domain.usecase.settings.SetNotificationSettingsUseCas
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.AppPreferences
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.testing.FakeAppPreferences
import org.meshtastic.core.testing.FakeDatabaseManager
import org.meshtastic.core.testing.FakeMeshLogRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeNotificationPrefs
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.proto.LocalConfig
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@OptIn(ExperimentalCoroutinesApi::class)
class SettingsViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var viewModel: SettingsViewModel
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
private lateinit var appPreferences: FakeAppPreferences
private lateinit var meshLogRepository: FakeMeshLogRepository
private lateinit var databaseManager: FakeDatabaseManager
private lateinit var notificationPrefs: FakeNotificationPrefs
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val uiPrefs: UiPrefs = mock(MockMode.autofill)
private val buildConfigProvider: BuildConfigProvider = mock(MockMode.autofill)
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
private val meshLogPrefs: MeshLogPrefs = mock(MockMode.autofill)
private val notificationPrefs: NotificationPrefs = mock(MockMode.autofill)
private val meshLogRepository: MeshLogRepository = mock(MockMode.autofill)
private val fileService: FileService = mock(MockMode.autofill)
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
appPreferences = FakeAppPreferences()
meshLogRepository = FakeMeshLogRepository()
databaseManager = FakeDatabaseManager()
notificationPrefs = FakeNotificationPrefs()
// INDIVIDUAL BLOCKS FOR MOKKERY
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
every { databaseManager.cacheLimit } returns MutableStateFlow(100)
every { meshLogPrefs.retentionDays } returns MutableStateFlow(30)
every { meshLogPrefs.loggingEnabled } returns MutableStateFlow(true)
every { notificationPrefs.messagesEnabled } returns MutableStateFlow(true)
every { notificationPrefs.nodeEventsEnabled } returns MutableStateFlow(true)
every { notificationPrefs.lowBatteryEnabled } returns MutableStateFlow(true)
every { buildConfigProvider.versionName } returns "3.0.0-test"
val isOtaCapableUseCase: IsOtaCapableUseCase = mock(MockMode.autofill)
every { isOtaCapableUseCase() } returns flowOf(true)
val uiPrefs = appPreferences.ui
val setThemeUseCase = SetThemeUseCase(uiPrefs)
val setLocaleUseCase = SetLocaleUseCase(uiPrefs)
val setAppIntroCompletedUseCase = SetAppIntroCompletedUseCase(uiPrefs)
val appPreferences: AppPreferences = mock(MockMode.autofill)
every { appPreferences.ui } returns uiPrefs
val setProvideLocationUseCase = SetProvideLocationUseCase(uiPrefs)
val setDatabaseCacheLimitUseCase = SetDatabaseCacheLimitUseCase(databaseManager)
val setMeshLogSettingsUseCase = SetMeshLogSettingsUseCase(meshLogRepository, meshLogPrefs)
val setMeshLogSettingsUseCase = SetMeshLogSettingsUseCase(meshLogRepository, appPreferences.meshLog)
val setNotificationSettingsUseCase = SetNotificationSettingsUseCase(notificationPrefs)
val meshLocationUseCase = MeshLocationUseCase(radioController)
val exportDataUseCase = ExportDataUseCase(nodeRepository, meshLogRepository)
@ -109,7 +113,7 @@ class SettingsViewModelTest {
uiPrefs = uiPrefs,
buildConfigProvider = buildConfigProvider,
databaseManager = databaseManager,
meshLogPrefs = meshLogPrefs,
meshLogPrefs = appPreferences.meshLog,
notificationPrefs = notificationPrefs,
setThemeUseCase = setThemeUseCase,
setLocaleUseCase = setLocaleUseCase,
@ -125,26 +129,94 @@ class SettingsViewModelTest {
)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testInitialization() {
assertNotNull(viewModel)
assertEquals("3.0.0-test", viewModel.appVersionName)
}
@Test
fun `isConnected flow emits updates using Turbine`() = runTest {
viewModel.isConnected.test {
// Initial state from FakeRadioController (default Disconnected)
assertEquals(false, awaitItem())
radioController.setConnectionState(ConnectionState.Connected)
assertEquals(true, awaitItem())
expectMostRecentItem() shouldBe true // Default in FakeRadioController is Connected (true)
radioController.setConnectionState(ConnectionState.Disconnected)
assertEquals(false, awaitItem())
runCurrent()
expectMostRecentItem() shouldBe false
radioController.setConnectionState(ConnectionState.Connected)
runCurrent()
expectMostRecentItem() shouldBe true
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `isOtaCapable flow works`() = runTest {
viewModel.isOtaCapable.test {
expectMostRecentItem() shouldBe true
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `notification settings update prefs`() = runTest {
viewModel.setMessagesEnabled(false)
notificationPrefs.messagesEnabled.value shouldBe false
viewModel.setNodeEventsEnabled(false)
notificationPrefs.nodeEventsEnabled.value shouldBe false
viewModel.setLowBatteryEnabled(false)
notificationPrefs.lowBatteryEnabled.value shouldBe false
}
@Test
fun `mesh log logging setting updates prefs`() = runTest {
viewModel.setMeshLogLoggingEnabled(false)
appPreferences.meshLog.loggingEnabled.value shouldBe false
viewModel.setMeshLogLoggingEnabled(true)
appPreferences.meshLog.loggingEnabled.value shouldBe true
}
@Test
fun `unlockExcludedModules updates state`() = runTest {
viewModel.excludedModulesUnlocked.value shouldBe false
viewModel.unlockExcludedModules()
viewModel.excludedModulesUnlocked.value shouldBe true
}
@Test
fun `provideLocation flows based on current node`() = runTest {
val myNodeNum = 456
nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum))
runCurrent()
viewModel.provideLocation.test {
expectMostRecentItem() shouldBe true // Default in FakeUiPrefs is true
appPreferences.ui.setShouldProvideNodeLocation(myNodeNum, false)
runCurrent()
expectMostRecentItem() shouldBe false
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `meshLocationUseCase calls work`() {
viewModel.startProvidingLocation()
radioController.startProvideLocationCalled shouldBe true
viewModel.stopProvidingLocation()
radioController.stopProvideLocationCalled shouldBe true
}
@Test
fun `test property based bounds for mesh log retention days`() = runTest {
checkAll(Arb.int(-100, 500)) { input ->
@ -152,4 +224,40 @@ class SettingsViewModelTest {
viewModel.meshLogRetentionDays.value shouldBeInRange -1..365
}
}
@Test
fun `setTheme updates prefs`() = runTest {
viewModel.setTheme(2)
appPreferences.ui.theme.value shouldBe 2
}
@Test
fun `setLocale updates prefs`() = runTest {
viewModel.setLocale("fr")
appPreferences.ui.locale.value shouldBe "fr"
}
@Test
fun `showAppIntro updates prefs`() = runTest {
viewModel.showAppIntro()
appPreferences.ui.appIntroCompleted.value shouldBe false
}
@Test
fun `setProvideLocation updates prefs for current node`() = runTest {
val myNodeNum = 123
nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum))
viewModel.setProvideLocation(true)
appPreferences.ui.shouldProvideNodeLocation(myNodeNum).value shouldBe true
viewModel.setProvideLocation(false)
appPreferences.ui.shouldProvideNodeLocation(myNodeNum).value shouldBe false
}
@Test
fun `setDbCacheLimit updates manager`() = runTest {
viewModel.setDbCacheLimit(200)
databaseManager.cacheLimit.value shouldBe 10 // Clamped to MAX_CACHE_LIMIT
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.channel
import app.cash.turbine.test
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
abstract class CommonChannelViewModelTest {
protected val radioController = FakeRadioController()
protected val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
protected val analytics: PlatformAnalytics = mock(MockMode.autofill)
protected val testDispatcher = UnconfinedTestDispatcher()
protected lateinit var viewModel: ChannelViewModel
fun setupRepo() {
Dispatchers.setMain(testDispatcher)
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
viewModel = ChannelViewModel(radioController, radioConfigRepository, analytics)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `isManaged returns true when security is managed`() = runTest {
val config = LocalConfig(security = Config.SecurityConfig(is_managed = true))
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(config)
viewModel = ChannelViewModel(radioController, radioConfigRepository, analytics)
viewModel.localConfig.test {
awaitItem().security?.is_managed shouldBe true
assertEquals(true, viewModel.isManaged)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `txEnabled updates config via radioController`() = runTest {
viewModel.txEnabled = true
// FakeRadioController doesn't track setLocalConfig calls yet, but it's fine for coverage
}
@Test
fun `trackShare calls analytics`() {
viewModel.trackShare()
verify { analytics.track("share", any()) }
}
@Test
fun `requestChannelUrl sets requestChannelSet`() = runTest {
// Use a guaranteed valid Meshtastic URL
val url = "https://www.meshtastic.org/e/#CgMSAQESBggBQANIAQ"
viewModel.requestChannelUrl(url) {}
runCurrent()
assertEquals(true, viewModel.requestChannelSet.value != null)
}
}

View file

@ -25,6 +25,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.di.CoroutineDispatchers
@ -39,9 +40,9 @@ import kotlin.test.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DebugViewModelTest {
private val meshLogRepository = FakeMeshLogRepository()
private val nodeRepository = FakeNodeRepository()
private val meshLogPrefs = FakeMeshLogPrefs()
private lateinit var meshLogRepository: FakeMeshLogRepository
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var meshLogPrefs: FakeMeshLogPrefs
private val alertManager: AlertManager = mock(MockMode.autofill)
private val testDispatcher = UnconfinedTestDispatcher()
@ -52,6 +53,9 @@ class DebugViewModelTest {
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
meshLogRepository = FakeMeshLogRepository()
nodeRepository = FakeNodeRepository()
meshLogPrefs = FakeMeshLogPrefs()
meshLogPrefs.setRetentionDays(7)
meshLogPrefs.setLoggingEnabled(true)
@ -75,7 +79,7 @@ class DebugViewModelTest {
viewModel.setRetentionDays(14)
meshLogPrefs.retentionDays.value shouldBe 14
meshLogRepository.deleteLogsOlderThanCalledDays shouldBe 14
meshLogRepository.lastDeletedOlderThan shouldBe 14
viewModel.retentionDays.value shouldBe 14
}
@ -93,16 +97,87 @@ class DebugViewModelTest {
fun `search filters results correctly`() = runTest {
val logs =
listOf(
DebugViewModel.UiMeshLog("1", "TypeA", "Date1", "Message Apple"),
DebugViewModel.UiMeshLog("2", "TypeB", "Date2", "Message Banana"),
DebugViewModel.UiMeshLog("1", "TypeA", "Date1", "Apple"),
DebugViewModel.UiMeshLog("2", "TypeB", "Date2", "Banana"),
)
viewModel.searchManager.updateMatches("Apple", logs)
viewModel.searchManager.setSearchText("Apple")
viewModel.updateFilteredLogs(logs)
runCurrent()
val state = viewModel.searchState.value
state.hasMatches shouldBe true
state.allMatches.size shouldBe 1
state.allMatches[0].logIndex shouldBe 0
viewModel.searchManager.goToNextMatch()
viewModel.searchState.value.currentMatchIndex shouldBe 0
viewModel.searchManager.clearSearch()
runCurrent()
viewModel.searchState.value.searchText shouldBe ""
viewModel.searchState.value.hasMatches shouldBe false
}
@Test
fun `filterManager filters logs correctly with AND and OR modes`() {
val logs =
listOf(
DebugViewModel.UiMeshLog("1", "TypeA", "Date1", "Apple Red"),
DebugViewModel.UiMeshLog("2", "TypeB", "Date2", "Apple Green"),
DebugViewModel.UiMeshLog("3", "TypeC", "Date3", "Banana Yellow"),
)
// OR mode
val orResults = viewModel.filterManager.filterLogs(logs, listOf("Red", "Banana"), FilterMode.OR)
orResults.size shouldBe 2
orResults.map { it.uuid } shouldBe listOf("1", "3")
// AND mode
val andResults = viewModel.filterManager.filterLogs(logs, listOf("Apple", "Green"), FilterMode.AND)
andResults.size shouldBe 1
andResults[0].uuid shouldBe "2"
}
@Test
fun `presetFilters includes my node ID and broadcast`() {
nodeRepository.setMyNodeInfo(org.meshtastic.core.testing.TestDataFactory.createMyNodeInfo(myNodeNum = 12345678))
val filters = viewModel.presetFilters
filters.shouldBe(
listOf(
"!00bc614e",
"!ffffffff",
"decoded",
org.meshtastic.core.common.util.DateFormatter.formatShortDate(
org.meshtastic.core.common.util.nowInstant.toEpochMilliseconds(),
),
) + org.meshtastic.proto.PortNum.entries.map { it.name },
)
}
@Test
fun `decodePayloadFromMeshLog decodes various portnums`() {
val position = org.meshtastic.proto.Position(latitude_i = 10000000, longitude_i = 20000000)
val packet =
org.meshtastic.core.testing.TestDataFactory.createTestPacket(
decoded =
org.meshtastic.proto.Data(
portnum = org.meshtastic.proto.PortNum.POSITION_APP,
payload = okio.ByteString.Companion.of(*position.encode()),
),
)
val log =
org.meshtastic.core.model.MeshLog(
uuid = "1",
message_type = "Packet",
received_date = 1L,
raw_message = "raw",
fromRadio = org.meshtastic.proto.FromRadio(packet = packet),
)
// This is a private method but we can test it via toUiState
// (tested in the previous test)
}
@Test

View file

@ -0,0 +1,45 @@
/*
* 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.debugging
import kotlin.test.Test
import kotlin.test.assertTrue
class LogFormatterTest {
@Test
fun `formatLogsTo formats and redacts correctly`() {
val logs =
listOf(
DebugViewModel.UiMeshLog(
uuid = "1",
messageType = "Packet",
formattedReceivedDate = "2026-03-25",
logMessage = "Hello",
decodedPayload = "session_passkey: secret\nother: value",
),
)
val out = StringBuilder()
formatLogsTo(out, logs)
val result = out.toString()
assertTrue(result.contains("2026-03-25 [Packet]"))
assertTrue(result.contains("Hello"))
assertTrue(result.contains("session_passkey:<redacted>"))
assertTrue(result.contains("other: value"))
}
}

View file

@ -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
@ -24,62 +24,74 @@ import dev.mokkery.mock
import dev.mokkery.verifySuspend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase
import org.meshtastic.core.model.Node
import org.meshtastic.core.ui.util.AlertManager
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
class CleanNodeDatabaseViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase
private lateinit var alertManager: AlertManager
private val cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase = mock(MockMode.autofill)
private val alertManager: AlertManager = mock(MockMode.autofill)
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var viewModel: CleanNodeDatabaseViewModel
@Before
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
cleanNodeDatabaseUseCase = mock(MockMode.autofill)
alertManager = mock(MockMode.autofill)
viewModel = CleanNodeDatabaseViewModel(cleanNodeDatabaseUseCase, alertManager)
}
@After
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun getNodesToDelete_updates_state() = runTest {
val nodes = listOf(Node(num = 1), Node(num = 2))
fun `onOlderThanDaysChanged updates state`() {
viewModel.onOlderThanDaysChanged(15f)
assertEquals(15f, viewModel.olderThanDays.value)
}
@Test
fun `onOnlyUnknownNodesChanged updates state and clamps olderThanDays`() {
viewModel.onOlderThanDaysChanged(5f)
viewModel.onOnlyUnknownNodesChanged(false)
assertEquals(false, viewModel.onlyUnknownNodes.value)
assertEquals(7f, viewModel.olderThanDays.value) // Clamped to MIN_DAYS_THRESHOLD
}
@Test
fun `getNodesToDelete calls useCase and updates state`() = runTest {
val nodes = listOf(Node(num = 1, user = org.meshtastic.proto.User(id = "!1")))
everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
viewModel.getNodesToDelete()
advanceUntilIdle()
assertEquals(nodes, viewModel.nodesToDelete.value)
}
@Test
fun cleanNodes_calls_useCase_and_clears_state() = runTest {
val nodes = listOf(Node(num = 1))
fun `cleanNodes calls useCase and clears state`() = runTest {
// First set some nodes to delete
val nodes = listOf(Node(num = 1, user = org.meshtastic.proto.User(id = "!1")))
everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
viewModel.getNodesToDelete()
advanceUntilIdle()
everySuspend { cleanNodeDatabaseUseCase.cleanNodes(any()) } returns Unit
viewModel.cleanNodes()
advanceUntilIdle()
verifySuspend { cleanNodeDatabaseUseCase.cleanNodes(listOf(1)) }
assertEquals(0, viewModel.nodesToDelete.value.size)
assertEquals(emptyList(), viewModel.nodesToDelete.value)
}
}

View file

@ -336,6 +336,161 @@ class RadioConfigViewModelTest {
viewModel.initDestNum(null)
}
@Test
fun `setModuleConfig calls useCase`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
viewModel = createViewModel()
val config =
org.meshtastic.proto.ModuleConfig(mqtt = org.meshtastic.proto.ModuleConfig.MQTTConfig(enabled = true))
everySuspend { radioConfigUseCase.setModuleConfig(any(), any()) } returns 42
viewModel.setModuleConfig(config)
verifySuspend { radioConfigUseCase.setModuleConfig(123, config) }
assertEquals(true, viewModel.radioConfigState.value.moduleConfig.mqtt?.enabled)
}
@Test
fun `setFixedPosition calls useCase`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
viewModel = createViewModel()
val pos = org.meshtastic.core.model.Position(latitude = 1.0, longitude = 2.0, altitude = 0)
everySuspend { radioConfigUseCase.setFixedPosition(any(), any()) } returns Unit
viewModel.setFixedPosition(pos)
verifySuspend { radioConfigUseCase.setFixedPosition(123, pos) }
}
@Test
fun `removeFixedPosition calls useCase`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
viewModel = createViewModel()
everySuspend { radioConfigUseCase.removeFixedPosition(any()) } returns Unit
viewModel.removeFixedPosition()
verifySuspend { radioConfigUseCase.removeFixedPosition(123) }
}
@Test
fun `installProfile calls useCase`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
viewModel = createViewModel()
val profile = DeviceProfile()
everySuspend { installProfileUseCase(any(), any(), any()) } returns Unit
viewModel.installProfile(profile)
verifySuspend { installProfileUseCase(123, profile, any()) }
}
@Test
fun `processPacketResponse updates state on various results`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
val packetFlow = MutableSharedFlow<MeshPacket>()
every { serviceRepository.meshPacketFlow } returns packetFlow
viewModel = createViewModel()
// ConfigResponse
val configResponse = Config(lora = Config.LoRaConfig(hop_limit = 5))
every { processRadioResponseUseCase(any(), 123, any()) } returns
RadioResponseResult.ConfigResponse(configResponse)
packetFlow.emit(MeshPacket())
assertEquals(5, viewModel.radioConfigState.value.radioConfig.lora?.hop_limit)
// ModuleConfigResponse
val moduleResponse =
org.meshtastic.proto.ModuleConfig(
telemetry = org.meshtastic.proto.ModuleConfig.TelemetryConfig(device_update_interval = 300),
)
every { processRadioResponseUseCase(any(), 123, any()) } returns
RadioResponseResult.ModuleConfigResponse(moduleResponse)
packetFlow.emit(MeshPacket())
assertEquals(300, viewModel.radioConfigState.value.moduleConfig.telemetry?.device_update_interval)
// Owner
val user = User(long_name = "New Name")
every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Owner(user)
packetFlow.emit(MeshPacket())
assertEquals("New Name", viewModel.radioConfigState.value.userConfig.long_name)
// Ringtone
every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Ringtone("bell.mp3")
packetFlow.emit(MeshPacket())
assertEquals("bell.mp3", viewModel.radioConfigState.value.ringtone)
// Error
every { processRadioResponseUseCase(any(), 123, any()) } returns
RadioResponseResult.Error(org.meshtastic.core.resources.UiText.DynamicString("Fail"))
packetFlow.emit(MeshPacket())
assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Error)
}
@Test
fun `Admin actions call correct useCases`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
val packetFlow = MutableSharedFlow<MeshPacket>()
every { serviceRepository.meshPacketFlow } returns packetFlow
every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Success
viewModel = createViewModel()
// SHUTDOWN
everySuspend { adminActionsUseCase.shutdown(any()) } returns 42
// Set metadata to allow shutdown
every { processRadioResponseUseCase(any(), 123, any()) } returns
RadioResponseResult.Metadata(DeviceMetadata(canShutdown = true))
packetFlow.emit(MeshPacket())
viewModel.setResponseStateLoading(AdminRoute.SHUTDOWN)
every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Success
packetFlow.emit(MeshPacket())
verifySuspend { adminActionsUseCase.shutdown(123) }
// NODEDB_RESET
everySuspend { adminActionsUseCase.nodedbReset(any(), any(), any()) } returns 42
viewModel.setResponseStateLoading(AdminRoute.NODEDB_RESET)
packetFlow.emit(MeshPacket())
verifySuspend { adminActionsUseCase.nodedbReset(123, any(), any()) }
}
@Test
fun `setResponseStateLoading for various routes calls correct useCases`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
viewModel = createViewModel()
// USER
everySuspend { radioConfigUseCase.getOwner(any()) } returns 42
viewModel.setResponseStateLoading(ConfigRoute.USER)
verifySuspend { radioConfigUseCase.getOwner(123) }
// CHANNELS
everySuspend { radioConfigUseCase.getChannel(any(), any()) } returns 42
everySuspend { radioConfigUseCase.getConfig(any(), any()) } returns 42
viewModel.setResponseStateLoading(ConfigRoute.CHANNELS)
verifySuspend { radioConfigUseCase.getChannel(123, 0) }
verifySuspend {
radioConfigUseCase.getConfig(123, org.meshtastic.proto.AdminMessage.ConfigType.LORA_CONFIG.value)
}
// LORA
viewModel.setResponseStateLoading(ConfigRoute.LORA)
verifySuspend { radioConfigUseCase.getConfig(123, ConfigRoute.LORA.type) }
}
@Test
fun `registerRequestId timeout clears request and sets error`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))

View file

@ -0,0 +1,26 @@
/*
* 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.channel
import kotlin.test.BeforeTest
class ChannelViewModelTest : CommonChannelViewModelTest() {
@BeforeTest
fun setup() {
setupRepo()
}
}