mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Refactor map layer management and navigation infrastructure (#4921)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
b608a04ca4
commit
a005231d94
142 changed files with 5408 additions and 3090 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue