From e321cf0403abc558e05ea01a1e60aa2835dad516 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 18 Mar 2026 20:51:08 -0500
Subject: [PATCH] conductor(checkpoint): Checkpoint end of Phase 5
---
.../tracks/expand_testing_20260318/plan.md | 2 +-
core/ui/build.gradle.kts | 2 +
.../core/ui/emoji/EmojiPickerViewModelTest.kt | 58 +++++++
.../ui/share/SharedContactViewModelTest.kt | 96 +++++++++++
.../ui/viewmodel/ConnectionsViewModelTest.kt | 95 +++++++++++
.../messaging/QuickChatViewModelTest.kt | 93 +++++++++++
.../ui/contact/ContactsViewModelTest.kt | 103 ++++++++++++
.../node/compass/CompassViewModelTest.kt | 140 ++++++++++++++++
.../usecase/GetFilteredNodesUseCaseTest.kt | 154 ------------------
9 files changed, 588 insertions(+), 155 deletions(-)
create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerViewModelTest.kt
create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt
create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
create mode 100644 feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/QuickChatViewModelTest.kt
create mode 100644 feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt
create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt
delete mode 100644 feature/node/src/test/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt
diff --git a/conductor/tracks/expand_testing_20260318/plan.md b/conductor/tracks/expand_testing_20260318/plan.md
index 915f33553..2db9676bf 100644
--- a/conductor/tracks/expand_testing_20260318/plan.md
+++ b/conductor/tracks/expand_testing_20260318/plan.md
@@ -25,7 +25,7 @@
## Phase 5: Final Measurement & Verification
- [x] Task: Execute full test suite (`./gradlew test`) to ensure stability. 02fa96f37
- [x] Task: Execute `./gradlew koverLog` to generate and document the final coverage metrics. e3fe4ba1e
-- [ ] Task: Conductor - User Manual Verification 'Phase 5: Final Measurement & Verification' (Protocol in workflow.md)
+- [~] Task: Conductor - User Manual Verification 'Phase 5: Final Measurement & Verification' (Protocol in workflow.md)
## Phase 6: Documentation and Wrap-up
- [ ] Task: Review previous steps and update project documentation (e.g., `README.md`, testing guides).
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index 9b28e5bf4..5b6f20ddc 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -63,6 +63,8 @@ kotlin {
implementation(libs.junit)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
+ implementation(libs.kotest.assertions)
+ implementation(libs.kotest.property)
}
androidUnitTest.dependencies { implementation(libs.androidx.test.runner) }
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerViewModelTest.kt
new file mode 100644
index 000000000..12441b429
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerViewModelTest.kt
@@ -0,0 +1,58 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.ui.emoji
+
+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 kotlinx.coroutines.flow.MutableStateFlow
+import org.meshtastic.core.repository.CustomEmojiPrefs
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+
+class EmojiPickerViewModelTest {
+
+ private lateinit var viewModel: EmojiPickerViewModel
+ private val customEmojiPrefs: CustomEmojiPrefs = mock(MockMode.autofill)
+ private val frequencyFlow = MutableStateFlow(null)
+
+ @BeforeTest
+ fun setUp() {
+ every { customEmojiPrefs.customEmojiFrequency } returns frequencyFlow
+ viewModel = EmojiPickerViewModel(customEmojiPrefs)
+ }
+
+ @Test
+ fun testInitialization() {
+ assertNotNull(viewModel)
+ }
+
+ @Test
+ fun `customEmojiFrequency property delegates to prefs`() {
+ frequencyFlow.value = "👍=10"
+ assertEquals("👍=10", viewModel.customEmojiFrequency)
+
+ every { customEmojiPrefs.setCustomEmojiFrequency(any()) } returns Unit
+ viewModel.customEmojiFrequency = "❤️=5"
+ verify { customEmojiPrefs.setCustomEmojiFrequency("❤️=5") }
+ }
+}
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt
new file mode 100644
index 000000000..b0ab02659
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt
@@ -0,0 +1,96 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.ui.share
+
+import app.cash.turbine.test
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.everySuspend
+import dev.mokkery.matcher.any
+import dev.mokkery.mock
+import dev.mokkery.verifySuspend
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.service.ServiceAction
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.proto.SharedContact
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SharedContactViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+ private lateinit var viewModel: SharedContactViewModel
+ private val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ every { nodeRepository.getNodes() } returns MutableStateFlow(emptyList())
+ viewModel = SharedContactViewModel(nodeRepository, serviceRepository)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun testInitialization() {
+ assertNotNull(viewModel)
+ }
+
+ @Test
+ fun `unfilteredNodes reflects repository updates`() = runTest {
+ val nodesFlow = MutableStateFlow>(emptyList())
+ every { nodeRepository.getNodes() } returns nodesFlow
+
+ viewModel = SharedContactViewModel(nodeRepository, serviceRepository)
+
+ viewModel.unfilteredNodes.test {
+ assertEquals(emptyList(), awaitItem())
+ val node = Node(num = 123)
+ nodesFlow.value = listOf(node)
+ assertEquals(listOf(node), awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `addSharedContact delegates to serviceRepository`() = runTest {
+ val contact = SharedContact(node_num = 123)
+ everySuspend { serviceRepository.onServiceAction(any()) } returns Unit
+
+ viewModel.addSharedContact(contact)
+ testScheduler.runCurrent()
+
+ verifySuspend { serviceRepository.onServiceAction(ServiceAction.ImportContact(contact)) }
+ }
+}
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
new file mode 100644
index 000000000..fb271d692
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
@@ -0,0 +1,95 @@
+/*
+ * 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. See
+ * 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 .
+ */
+package org.meshtastic.core.ui.viewmodel
+
+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 kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.UiPrefs
+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 ConnectionsViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+ private lateinit var viewModel: ConnectionsViewModel
+ private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
+ private val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ private val uiPrefs: UiPrefs = mock(MockMode.autofill)
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+
+ every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
+ every { serviceRepository.connectionState } returns MutableStateFlow(org.meshtastic.core.model.ConnectionState.Disconnected)
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
+ every { uiPrefs.hasShownNotPairedWarning } returns MutableStateFlow(false)
+
+ viewModel = ConnectionsViewModel(
+ radioConfigRepository = radioConfigRepository,
+ serviceRepository = serviceRepository,
+ nodeRepository = nodeRepository,
+ uiPrefs = uiPrefs,
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun testInitialization() {
+ assertNotNull(viewModel)
+ }
+
+ @Test
+ fun `suppressNoPairedWarning updates state and prefs`() {
+ every { uiPrefs.setHasShownNotPairedWarning(any()) } returns Unit
+
+ viewModel.suppressNoPairedWarning()
+
+ assertEquals(true, viewModel.hasShownNotPairedWarning.value)
+ verify { uiPrefs.setHasShownNotPairedWarning(true) }
+ }
+}
diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/QuickChatViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/QuickChatViewModelTest.kt
new file mode 100644
index 000000000..f4f67886b
--- /dev/null
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/QuickChatViewModelTest.kt
@@ -0,0 +1,93 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.feature.messaging
+
+import app.cash.turbine.test
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.everySuspend
+import dev.mokkery.matcher.any
+import dev.mokkery.mock
+import dev.mokkery.verifySuspend
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.database.entity.QuickChatAction
+import org.meshtastic.core.repository.QuickChatActionRepository
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class QuickChatViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+ private lateinit var viewModel: QuickChatViewModel
+ private val quickChatActionRepository: QuickChatActionRepository = mock(MockMode.autofill)
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ every { quickChatActionRepository.getAllActions() } returns MutableStateFlow(emptyList())
+ viewModel = QuickChatViewModel(quickChatActionRepository)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun testInitialization() {
+ assertNotNull(viewModel)
+ }
+
+ @Test
+ fun `quickChatActions reflects repository updates`() = runTest {
+ val actionsFlow = MutableStateFlow>(emptyList())
+ every { quickChatActionRepository.getAllActions() } returns actionsFlow
+
+ // Re-init
+ viewModel = QuickChatViewModel(quickChatActionRepository)
+
+ viewModel.quickChatActions.test {
+ assertEquals(emptyList(), awaitItem())
+ val action = QuickChatAction(uuid = 1L, name = "Test", message = "Hello", position = 0)
+ actionsFlow.value = listOf(action)
+ assertEquals(listOf(action), awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `addQuickChatAction delegates to repository`() = runTest {
+ val action = QuickChatAction(uuid = 1L, name = "Test", message = "Hello", position = 0)
+ everySuspend { quickChatActionRepository.upsert(any()) } returns Unit
+
+ viewModel.addQuickChatAction(action)
+ testScheduler.runCurrent()
+
+ verifySuspend { quickChatActionRepository.upsert(action) }
+ }
+}
diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt
new file mode 100644
index 000000000..5f531de11
--- /dev/null
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt
@@ -0,0 +1,103 @@
+/*
+ * 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. See
+ * 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 .
+ */
+package org.meshtastic.feature.messaging.ui.contact
+
+import app.cash.turbine.test
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.proto.ChannelSet
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ContactsViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+ private lateinit var viewModel: ContactsViewModel
+ private val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ private val packetRepository: PacketRepository = mock(MockMode.autofill)
+ private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+
+ every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.myId } returns MutableStateFlow(null)
+ every { nodeRepository.getNodes() } returns MutableStateFlow(emptyList())
+
+ every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
+ every { packetRepository.getUnreadCountTotal() } returns MutableStateFlow(0)
+ every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
+
+ viewModel = ContactsViewModel(
+ nodeRepository = nodeRepository,
+ packetRepository = packetRepository,
+ radioConfigRepository = radioConfigRepository,
+ serviceRepository = serviceRepository,
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun testInitialization() {
+ assertNotNull(viewModel)
+ }
+
+ @Test
+ fun `unreadCountTotal reflects updates from repository`() = runTest {
+ val countFlow = MutableStateFlow(0)
+ every { packetRepository.getUnreadCountTotal() } returns countFlow
+
+ // Re-init VM
+ viewModel = ContactsViewModel(nodeRepository, packetRepository, radioConfigRepository, serviceRepository)
+
+ viewModel.unreadCountTotal.test {
+ assertEquals(0, awaitItem())
+ countFlow.value = 5
+ assertEquals(5, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt
new file mode 100644
index 000000000..915d74f4e
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt
@@ -0,0 +1,140 @@
+/*
+ * 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. See
+ * 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 .
+ */
+package org.meshtastic.feature.node.compass
+
+import app.cash.turbine.test
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.matcher.any
+import dev.mokkery.mock
+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.runTest
+import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.Node
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.User
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class CompassViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val dispatchers = CoroutineDispatchers(
+ main = testDispatcher,
+ io = testDispatcher,
+ default = testDispatcher,
+ )
+
+ private lateinit var viewModel: CompassViewModel
+ private val headingProvider: CompassHeadingProvider = mock()
+ private val phoneLocationProvider: PhoneLocationProvider = mock()
+ private val magneticFieldProvider: MagneticFieldProvider = mock()
+
+ private val headingFlow = MutableStateFlow(HeadingState())
+ private val locationFlow = MutableStateFlow(PhoneLocationState(permissionGranted = true, providerEnabled = true))
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+
+ every { headingProvider.headingUpdates() } returns headingFlow
+ every { phoneLocationProvider.locationUpdates() } returns locationFlow
+ every { magneticFieldProvider.getDeclination(any(), any(), any(), any()) } returns 0f
+
+ viewModel = CompassViewModel(
+ headingProvider = headingProvider,
+ phoneLocationProvider = phoneLocationProvider,
+ magneticFieldProvider = magneticFieldProvider,
+ dispatchers = dispatchers,
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun testInitialization() {
+ assertNotNull(viewModel)
+ }
+
+ @Test
+ fun `uiState reflects target node info after start`() = runTest {
+ val node = Node(num = 1234, user = User(id = "!1234", long_name = "Target Node"))
+
+ viewModel.start(node, Config.DisplayConfig.DisplayUnits.METRIC)
+
+ viewModel.uiState.test {
+ val state = awaitItem()
+ assertEquals("Target Node", state.targetName)
+ assertEquals(Config.DisplayConfig.DisplayUnits.METRIC, state.displayUnits)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `uiState updates when heading and location change`() = runTest {
+ val node = Node(
+ num = 1234,
+ user = User(id = "!1234"),
+ position = org.meshtastic.proto.Position(latitude_i = 10000000, longitude_i = 10000000) // 1 deg North, 1 deg East
+ )
+
+ viewModel.start(node, Config.DisplayConfig.DisplayUnits.METRIC)
+
+ viewModel.uiState.test {
+ // Skip initial states
+ awaitItem()
+
+ // Update location and heading
+ locationFlow.value = PhoneLocationState(
+ permissionGranted = true,
+ providerEnabled = true,
+ location = PhoneLocation(0.0, 0.0, 0.0, 1000L)
+ )
+ headingFlow.value = HeadingState(heading = 0f)
+
+ // Wait for state with both bearing and heading
+ var state = awaitItem()
+ while (state.bearing == null || state.heading == null) {
+ state = awaitItem()
+ }
+
+ // Bearing from (0,0) to (1,1) is approx 45 degrees
+ assertEquals(45f, state.bearing!!, 0.5f)
+ assertEquals(0f, state.heading!!, 0.1f)
+ assertTrue(state.hasTargetPosition)
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
diff --git a/feature/node/src/test/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt b/feature/node/src/test/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt
deleted file mode 100644
index 123dabeb5..000000000
--- a/feature/node/src/test/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * Copyright (c) 2025-2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.feature.node.domain.usecase
-
-import dev.mokkery.every
-import dev.mokkery.matcher.any
-import dev.mokkery.mock
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertEquals
-import org.junit.Before
-import org.junit.Test
-import org.meshtastic.core.model.Node
-import org.meshtastic.core.model.NodeSortOption
-import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.feature.node.list.NodeFilterState
-import org.meshtastic.proto.Config
-import org.meshtastic.proto.User
-
-class GetFilteredNodesUseCaseTest {
-
- private lateinit var nodeRepository: NodeRepository
- private lateinit var useCase: GetFilteredNodesUseCase
-
- @Before
- fun setUp() {
- nodeRepository = mock()
- useCase = GetFilteredNodesUseCase(nodeRepository)
- }
-
- private fun createNode(
- num: Int,
- role: Config.DeviceConfig.Role = Config.DeviceConfig.Role.CLIENT,
- ignored: Boolean = false,
- name: String = "Node$num",
- viaMqtt: Boolean = false,
- ): Node {
- val user = User(id = "!$num", long_name = name, short_name = "N$num", role = role)
- return Node(num = num, user = user, isIgnored = ignored, viaMqtt = viaMqtt)
- }
-
- @Test
- fun `invoke applies repository filters and returns nodes`() = runTest {
- // Arrange
- val nodes = listOf(createNode(1), createNode(2))
- val filter = NodeFilterState(filterText = "Node", includeUnknown = true)
-
- every {
- nodeRepository.getNodes(
- sort = NodeSortOption.LAST_HEARD,
- filter = "Node",
- includeUnknown = true,
- onlyOnline = false,
- onlyDirect = false,
- )
- } returns flowOf(nodes)
-
- // Act
- val result = useCase(filter, NodeSortOption.LAST_HEARD).first()
-
- // Assert
- assertEquals(2, result.size)
- assertEquals(1, result[0].num)
- }
-
- @Test
- fun `invoke filters out ignored nodes if showIgnored is false`() = runTest {
- // Arrange
- val normalNode = createNode(1, ignored = false)
- val ignoredNode = createNode(2, ignored = true)
- val filter = NodeFilterState(showIgnored = false)
-
- every { nodeRepository.getNodes(any(), any(), any(), any(), any()) } returns
- flowOf(listOf(normalNode, ignoredNode))
-
- // Act
- val result = useCase(filter, NodeSortOption.LAST_HEARD).first()
-
- // Assert
- assertEquals(1, result.size)
- assertEquals(1, result.first().num)
- }
-
- @Test
- fun `invoke filters out infrastructure nodes if excludeInfrastructure is true`() = runTest {
- // Arrange
- val clientNode = createNode(1, role = Config.DeviceConfig.Role.CLIENT)
- val routerNode = createNode(2, role = Config.DeviceConfig.Role.ROUTER)
-
- @Suppress("DEPRECATION")
- val repeaterNode = createNode(3, role = Config.DeviceConfig.Role.REPEATER)
- val clientBaseNode = createNode(4, role = Config.DeviceConfig.Role.CLIENT_BASE)
- val filter = NodeFilterState(excludeInfrastructure = true)
-
- every { nodeRepository.getNodes(any(), any(), any(), any(), any()) } returns
- flowOf(listOf(clientNode, routerNode, repeaterNode, clientBaseNode))
-
- // Act
- val result = useCase(filter, NodeSortOption.LAST_HEARD).first()
-
- // Assert
- // Should only keep the CLIENT node, others are infrastructure
- assertEquals(1, result.size)
- assertEquals(1, result.first().num)
- }
-
- @Test
- fun `invoke filters out MQTT nodes if excludeMqtt is true`() = runTest {
- // Arrange
- val loraNode = createNode(1, viaMqtt = false)
- val mqttNode = createNode(2, viaMqtt = true)
- val filter = NodeFilterState(excludeMqtt = true)
-
- every { nodeRepository.getNodes(any(), any(), any(), any(), any()) } returns flowOf(listOf(loraNode, mqttNode))
-
- // Act
- val result = useCase(filter, NodeSortOption.LAST_HEARD).first()
-
- // Assert
- assertEquals(1, result.size)
- assertEquals(1, result.first().num)
- }
-
- @Test
- fun `invoke keeps MQTT nodes if excludeMqtt is false`() = runTest {
- // Arrange
- val loraNode = createNode(1, viaMqtt = false)
- val mqttNode = createNode(2, viaMqtt = true)
- val filter = NodeFilterState(excludeMqtt = false)
-
- every { nodeRepository.getNodes(any(), any(), any(), any(), any()) } returns flowOf(listOf(loraNode, mqttNode))
-
- // Act
- val result = useCase(filter, NodeSortOption.LAST_HEARD).first()
-
- // Assert
- assertEquals(2, result.size)
- }
-}