conductor(checkpoint): Checkpoint end of Phase 5

This commit is contained in:
James Rich 2026-03-18 20:51:08 -05:00
parent b585dcaad3
commit e321cf0403
9 changed files with 588 additions and 155 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}