feat: Enhance test coverage (#4847)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-18 22:09:19 -05:00
committed by GitHub
parent 1b0dc75dfe
commit 06b9f8c77a
41 changed files with 1715 additions and 502 deletions

View File

@@ -256,3 +256,4 @@ jobs:
path: |
**/build/outputs/androidTest-results
retention-days: 7
if-no-files-found: ignore

View File

@@ -77,7 +77,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`.
- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main.
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `core:testing` shared fakes.
- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes.
- **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative.
### C. Namespacing

View File

@@ -77,7 +77,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`.
- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main.
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `core:testing` shared fakes.
- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes.
- **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative.
### C. Namespacing

View File

@@ -0,0 +1,32 @@
# Implementation Plan: Expand Testing Coverage
## Phase 1: Baseline Measurement [checkpoint: 6d9ad46]
- [x] Task: Execute `./gradlew koverLog` and record current project test coverage. 8bdd673a1
- [x] Task: Conductor - User Manual Verification 'Phase 1: Baseline Measurement' (Protocol in workflow.md) 6d9ad468c
## Phase 2: Feature ViewModel Migration to Turbine [checkpoint: 61b9595]
- [x] Task: Refactor `MetricsViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. 79e059286
- [x] Task: Refactor `MessageViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. b45697b53
- [x] Task: Refactor `RadioConfigViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. 33e10fc6c
- [x] Task: Refactor `NodeListViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. 33e10fc6c
- [x] Task: Refactor remaining `feature` ViewModels to use `Turbine` and `Mokkery`. 33e10fc6c
- [x] Task: Conductor - User Manual Verification 'Phase 2: Feature ViewModel Migration to Turbine' (Protocol in workflow.md) 61b959506
## Phase 3: Property-Based Parsing Tests (Kotest) [checkpoint: cb71c85]
- [x] Task: Add `Kotest` property-based tests for `StreamFrameCodec` in `core:network`. 2c8fd6a8f
- [x] Task: Add `Kotest` property-based tests for `PacketHandler` implementations in `core:data`. 7d56c3fef
- [x] Task: Add `Kotest` property-based tests for `TcpTransport` and/or `SerialTransport` in `core:network`. 2fd68d67e
- [x] Task: Conductor - User Manual Verification 'Phase 3: Property-Based Parsing Tests (Kotest)' (Protocol in workflow.md) cb71c8588
## Phase 4: Domain Logic Gap Fill [checkpoint: 5735aa1]
- [x] Task: Identify and fill testing gaps in `core:domain` use cases not fully covered during the initial Mokkery migration. 7b815130f
- [x] Task: Conductor - User Manual Verification 'Phase 4: Domain Logic Gap Fill' (Protocol in workflow.md) 5735aa148
## Phase 5: Final Measurement & Verification [checkpoint: e321cf0]
- [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
- [x] Task: Conductor - User Manual Verification 'Phase 5: Final Measurement & Verification' (Protocol in workflow.md) e321cf0
## Phase 6: Documentation and Wrap-up [checkpoint: d950e5e]
- [x] Task: Review previous steps and update project documentation (e.g., `README.md`, testing guides). b2c9d3e
- [x] Task: Conductor - User Manual Verification 'Phase 6: Documentation and Wrap-up' (Protocol in workflow.md) d950e5e

View File

@@ -33,4 +33,4 @@
- **Coroutines Testing:** Use `kotlinx-coroutines-test` for virtual time management in asynchronous flows.
- **Mocking Strategy:** Avoid JVM-specific mocking libraries. Prefer `Mokkery` or `Mockative` for multiplatform-compatible mocking interfaces, alongside handwritten fakes in `core:testing`.
- **Flow Assertions:** Use `Turbine` for testing multiplatform `Flow` emissions and state updates.
- **Property-Based Testing:** Consider evaluating `Kotest` for multiplatform data-driven and property-based testing scenarios if standard `kotlin.test` becomes insufficient.
- **Property-Based Testing:** Use `Kotest` for multiplatform data-driven and property-based testing scenarios.

View File

@@ -2,7 +2,3 @@
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
---
- [ ] **Track: Expand Testing Coverage**
*Link: [./tracks/expand_testing_20260318/](./tracks/expand_testing_20260318/)*

View File

@@ -1,32 +0,0 @@
# Implementation Plan: Expand Testing Coverage
## Phase 1: Baseline Measurement
- [ ] Task: Execute `./gradlew koverLog` and record current project test coverage.
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Baseline Measurement' (Protocol in workflow.md)
## Phase 2: Feature ViewModel Migration to Turbine
- [ ] Task: Refactor `MetricsViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`.
- [ ] Task: Refactor `MessageViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`.
- [ ] Task: Refactor `RadioConfigViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`.
- [ ] Task: Refactor `NodeListViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`.
- [ ] Task: Refactor remaining `feature` ViewModels to use `Turbine` and `Mokkery`.
- [ ] Task: Conductor - User Manual Verification 'Phase 2: Feature ViewModel Migration to Turbine' (Protocol in workflow.md)
## Phase 3: Property-Based Parsing Tests (Kotest)
- [ ] Task: Add `Kotest` property-based tests for `StreamFrameCodec` in `core:network`.
- [ ] Task: Add `Kotest` property-based tests for `PacketHandler` implementations in `core:data`.
- [ ] Task: Add `Kotest` property-based tests for `TcpTransport` and/or `SerialTransport` in `core:network`.
- [ ] Task: Conductor - User Manual Verification 'Phase 3: Property-Based Parsing Tests (Kotest)' (Protocol in workflow.md)
## Phase 4: Domain Logic Gap Fill
- [ ] Task: Identify and fill testing gaps in `core:domain` use cases not fully covered during the initial Mokkery migration.
- [ ] Task: Conductor - User Manual Verification 'Phase 4: Domain Logic Gap Fill' (Protocol in workflow.md)
## Phase 5: Final Measurement & Verification
- [ ] Task: Execute full test suite (`./gradlew test`) to ensure stability.
- [ ] Task: Execute `./gradlew koverLog` to generate and document the final coverage metrics.
- [ ] 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).
- [ ] Task: Conductor - User Manual Verification 'Phase 6: Documentation and Wrap-up' (Protocol in workflow.md)

View File

@@ -71,6 +71,8 @@ kotlin {
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.kotest.assertions)
implementation(libs.kotest.property)
}
}
}

View File

@@ -24,7 +24,6 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
@@ -34,6 +33,7 @@ import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.repository.TracerouteSnapshotRepository
import org.meshtastic.proto.MeshPacket
@Single

View File

@@ -27,22 +27,23 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.TracerouteSnapshotRepository
import org.meshtastic.proto.Position
@Single
class TracerouteSnapshotRepository(
class TracerouteSnapshotRepositoryImpl(
private val dbManager: DatabaseProvider,
private val dispatchers: CoroutineDispatchers,
) {
) : TracerouteSnapshotRepository {
fun getSnapshotPositions(logUuid: String): Flow<Map<Int, Position>> = dbManager.currentDb
override fun getSnapshotPositions(logUuid: String): Flow<Map<Int, Position>> = dbManager.currentDb
.flatMapLatest { it.tracerouteNodePositionDao().getByLogUuid(logUuid) }
.distinctUntilChanged()
.mapLatest { list -> list.associate { it.nodeNum to it.position } }
.flowOn(dispatchers.io)
.conflate()
suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map<Int, Position>) =
override suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map<Int, Position>) =
withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.tracerouteNodePositionDao()
dao.deleteByLogUuid(logUuid)

View File

@@ -22,6 +22,9 @@ import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verifySuspend
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.checkAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
@@ -39,6 +42,7 @@ import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.ToRadio
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertNotNull
class PacketHandlerImplTest {
@@ -70,13 +74,16 @@ class PacketHandlerImplTest {
handler.start(testScope)
}
@Test
fun testInitialization() {
assertNotNull(handler)
}
@Test
fun `sendToRadio with ToRadio sends immediately`() {
val toRadio = ToRadio(packet = MeshPacket(id = 123))
handler.sendToRadio(toRadio)
// No explicit assertion here in original test, but we could verify call
}
@Test
@@ -107,6 +114,17 @@ class PacketHandlerImplTest {
testScheduler.runCurrent()
}
@Test
fun `handleQueueStatus property test`() = runTest(testDispatcher) {
checkAll(Arb.int(0, 10), Arb.int(0, 32), Arb.int(0, 100000)) { res, free, packetId ->
val status = QueueStatus(res = res, free = free, mesh_packet_id = packetId)
// Ensure it doesn't crash on any input
handler.handleQueueStatus(status)
testScheduler.runCurrent()
}
}
@Test
fun `outgoing packets are logged with NODE_NUM_LOCAL`() = runTest(testDispatcher) {
val packet = MeshPacket(id = 123, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))

View File

@@ -0,0 +1,44 @@
/*
* 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.core.domain.usecase.settings
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.common.UiPreferences
import kotlin.test.BeforeTest
import kotlin.test.Test
class SetLocaleUseCaseTest {
private val uiPreferences: UiPreferences = mock()
private lateinit var useCase: SetLocaleUseCase
@BeforeTest
fun setUp() {
useCase = SetLocaleUseCase(uiPreferences)
}
@Test
fun `invoke calls setLocale on uiPreferences`() {
every { uiPreferences.setLocale(any()) } returns Unit
useCase("en")
verify { uiPreferences.setLocale("en") }
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.repository.NotificationPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
class SetNotificationSettingsUseCaseTest {
private val notificationPrefs: NotificationPrefs = mock()
private lateinit var useCase: SetNotificationSettingsUseCase
@BeforeTest
fun setUp() {
useCase = SetNotificationSettingsUseCase(notificationPrefs)
}
@Test
fun `setMessagesEnabled calls notificationPrefs`() {
every { notificationPrefs.setMessagesEnabled(any()) } returns Unit
useCase.setMessagesEnabled(true)
verify { notificationPrefs.setMessagesEnabled(true) }
}
@Test
fun `setNodeEventsEnabled calls notificationPrefs`() {
every { notificationPrefs.setNodeEventsEnabled(any()) } returns Unit
useCase.setNodeEventsEnabled(false)
verify { notificationPrefs.setNodeEventsEnabled(false) }
}
@Test
fun `setLowBatteryEnabled calls notificationPrefs`() {
every { notificationPrefs.setLowBatteryEnabled(any()) } returns Unit
useCase.setLowBatteryEnabled(true)
verify { notificationPrefs.setLowBatteryEnabled(true) }
}
}

View File

@@ -67,6 +67,10 @@ kotlin {
implementation(libs.okhttp3.logging.interceptor)
}
commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
commonTest.dependencies {
implementation(libs.kotlinx.coroutines.test)
implementation(libs.kotest.assertions)
implementation(libs.kotest.property)
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.radio
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import dev.mokkery.verify
import io.kotest.property.Arb
import io.kotest.property.arbitrary.byte
import io.kotest.property.arbitrary.byteArray
import io.kotest.property.arbitrary.int
import io.kotest.property.checkAll
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.network.transport.StreamFrameCodec
import org.meshtastic.core.repository.RadioInterfaceService
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue
class StreamInterfaceTest {
private val radioService: RadioInterfaceService = mock(MockMode.autofill)
private lateinit var fakeStream: FakeStreamInterface
class FakeStreamInterface(service: RadioInterfaceService) : StreamInterface(service) {
val sentBytes = mutableListOf<ByteArray>()
override fun sendBytes(p: ByteArray) {
sentBytes.add(p)
}
override fun flushBytes() {
/* no-op */
}
override fun keepAlive() {
/* no-op */
}
fun feed(b: Byte) = readChar(b)
public override fun connect() = super.connect()
}
@BeforeTest
fun setUp() {
every { radioService.serviceScope } returns TestScope()
}
@Test
fun `handleSendToRadio property test`() = runTest {
fakeStream = FakeStreamInterface(radioService)
checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload -> fakeStream.handleSendToRadio(payload) }
}
@Test
fun `readChar property test`() = runTest {
fakeStream = FakeStreamInterface(radioService)
checkAll(Arb.byteArray(Arb.int(0, 100), Arb.byte())) { data ->
data.forEach { fakeStream.feed(it) }
// Ensure no crash
}
}
@Test
fun `connect sends wake bytes`() {
fakeStream = FakeStreamInterface(radioService)
fakeStream.connect()
assertTrue(fakeStream.sentBytes.isNotEmpty())
assertTrue(fakeStream.sentBytes[0].contentEquals(StreamFrameCodec.WAKE_BYTES))
verify { radioService.onConnect() }
}
}

View File

@@ -16,6 +16,14 @@
*/
package org.meshtastic.core.network.transport
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.byte
import io.kotest.property.arbitrary.byteArray
import io.kotest.property.arbitrary.int
import io.kotest.property.checkAll
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@@ -56,6 +64,31 @@ class StreamFrameCodecTest {
assertEquals(listOf(0x55.toByte()), receivedPackets[0].toList())
}
@Test
fun `frameAndSend and processInputByte are inverse`() = runTest {
checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload ->
var received: ByteArray? = null
val codec = StreamFrameCodec(onPacketReceived = { received = it })
val bytes = mutableListOf<ByteArray>()
codec.frameAndSend(payload, sendBytes = { bytes.add(it) })
bytes.forEach { arr -> arr.forEach { codec.processInputByte(it) } }
received.shouldNotBeNull()
received.shouldBe(payload)
}
}
@Test
fun `processInputByte is robust against random noise`() = runTest {
checkAll(Arb.byteArray(Arb.int(0, 1000), Arb.byte())) { noise ->
val codec = StreamFrameCodec(onPacketReceived = { /* ignore */ })
noise.forEach { codec.processInputByte(it) }
// Should not crash
}
}
@Test
fun `processInputByte handles multiple packets sequentially`() {
val packet1 = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x11)

View File

@@ -0,0 +1,29 @@
/*
* 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.core.repository
import kotlinx.coroutines.flow.Flow
import org.meshtastic.proto.Position
/** Repository interface for managing snapshots of traceroute results. */
interface TracerouteSnapshotRepository {
/** Returns a reactive flow of positions associated with a specific traceroute log. */
fun getSnapshotPositions(logUuid: String): Flow<Map<Int, Position>>
/** Persists a set of positions for a traceroute log. */
suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map<Int, Position>)
}

View File

@@ -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) }

View File

@@ -52,9 +52,9 @@ open class AlertManager {
)
private val _currentAlert = MutableStateFlow<AlertData?>(null)
val currentAlert = _currentAlert.asStateFlow()
open val currentAlert = _currentAlert.asStateFlow()
fun showAlert(
open fun showAlert(
title: String? = null,
titleRes: StringResource? = null,
message: String? = null,
@@ -97,7 +97,7 @@ open class AlertManager {
)
}
fun dismissAlert() {
open fun dismissAlert() {
_currentAlert.value = null
}
}

View File

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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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.UnconfinedTestDispatcher
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 = UnconfinedTestDispatcher()
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(testDispatcher) {
val nodesFlow = MutableStateFlow<List<Node>>(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(testDispatcher) {
val contact = SharedContact(node_num = 123)
everySuspend { serviceRepository.onServiceAction(any()) } returns Unit
val job = viewModel.addSharedContact(contact)
job.join()
verifySuspend { serviceRepository.onServiceAction(ServiceAction.ImportContact(contact)) }
}
}

View File

@@ -0,0 +1,91 @@
/*
* 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.core.ui.viewmodel
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.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) }
}
}

View File

@@ -0,0 +1,6 @@
# Baseline Test Coverage Report
**Date:** Wednesday, March 18, 2026
**Overall Project Coverage:** 8.796%
**App Module Coverage:** 1.6404%
This baseline was captured using `./gradlew koverLog` at the start of the 'Expand Testing Coverage' track.

View File

@@ -0,0 +1,18 @@
# Final Test Coverage Report
**Date:** Wednesday, March 18, 2026
**Overall Project Coverage:** 10.2591% (Baseline: 8.796%)
**Absolute Increase:** +1.46%
## Module Highlights
| Module | Coverage | Notes |
| :--- | :--- | :--- |
| `core:domain` | 26.55% | UseCase gap fill complete. |
| `feature:intro` | 30.76% | ViewModel tests enabled. |
| `feature:map` | 33.33% | BaseMapViewModel tests refactored. |
| `feature:node` | 24.70% | Metrics, Detail, Compass, and Filter tests added/refactored. |
| `feature:connections` | 26.49% | ScannerViewModel verified. |
| `feature:messaging` | 18.54% | MessageViewModel verified. |
This report concludes the 'Expand Testing Coverage' track.
Significant improvements were made in ViewModel testability through interface extraction and Mokkery/Turbine migration.
Foundational logic in `core:network` was strengthened with Kotest property-based tests.

View File

@@ -16,39 +16,47 @@
*/
package org.meshtastic.feature.intro
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Bootstrap tests for IntroViewModel.
*
* Tests the intro navigation flow logic.
*/
class IntroViewModelTest {
/*
private lateinit var viewModel: IntroViewModel
private val viewModel = IntroViewModel()
@BeforeTest
fun setUp() {
viewModel = IntroViewModel()
}
@Test
fun testWelcomeNavigatesNextToBluetooth() {
val next = viewModel.getNextKey(Welcome, allPermissionsGranted = false)
"Welcome should navigate to Bluetooth" shouldBe Bluetooth, next
assertEquals(Bluetooth, next)
}
@Test
fun testBluetoothNavigatesToLocation() {
val next = viewModel.getNextKey(Bluetooth, allPermissionsGranted = false)
"Bluetooth should navigate to Location" shouldBe Location, next
assertEquals(Location, next)
}
@Test
fun testLocationNavigatesToNotifications() {
val next = viewModel.getNextKey(Location, allPermissionsGranted = false)
"Location should navigate to Notifications" shouldBe Notifications, next
assertEquals(Notifications, next)
}
@Test
fun testNotificationsWithPermissionNavigatesToCriticalAlerts() {
val next = viewModel.getNextKey(Notifications, allPermissionsGranted = true)
"Notifications should navigate to CriticalAlerts when permissions granted" shouldBe CriticalAlerts, next
assertEquals(CriticalAlerts, next)
}
@Test
@@ -62,6 +70,4 @@ class IntroViewModelTest {
val next = viewModel.getNextKey(CriticalAlerts, allPermissionsGranted = true)
assertNull(next, "CriticalAlerts should not navigate further")
}
*/
}

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
@@ -16,33 +16,53 @@
*/
package org.meshtastic.feature.map
/**
* Bootstrap tests for BaseMapViewModel.
*
* Tests map functionality using FakeNodeRepository and test data.
*/
import app.cash.turbine.test
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.UnconfinedTestDispatcher
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.MapPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@OptIn(ExperimentalCoroutinesApi::class)
class BaseMapViewModelTest {
/*
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var viewModel: BaseMapViewModel
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
private lateinit var mapPrefs: MapPrefs
private lateinit var packetRepository: PacketRepository
private val mapPrefs: MapPrefs = mock()
private val packetRepository: PacketRepository = mock()
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
radioController.setConnectionState(ConnectionState.Disconnected)
mapPrefs =
every { showOnlyFavorites } returns MutableStateFlow(false)
every { showWaypointsOnMap } returns MutableStateFlow(false)
every { showPrecisionCircleOnMap } returns MutableStateFlow(false)
every { lastHeardFilter } returns MutableStateFlow(0L)
every { lastHeardTrackFilter } returns MutableStateFlow(0L)
}
every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false)
every { mapPrefs.showWaypointsOnMap } returns MutableStateFlow(false)
every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(false)
every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L)
every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L)
every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList())
viewModel =
BaseMapViewModel(
@@ -53,41 +73,52 @@ class BaseMapViewModelTest {
)
}
@Test
fun testInitialization() = runTest {
setUp()
assertTrue(true, "BaseMapViewModel initialized successfully")
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testMyNodeInfoFlow() = runTest {
setUp()
val myNodeInfo = viewModel.myNodeInfo.value
assertTrue(myNodeInfo == null, "myNodeInfo starts as null")
fun testInitialization() {
assertNotNull(viewModel)
}
@Test
fun testNodesWithPositionStartsEmpty() = runTest {
setUp()
"nodesWithPosition should start empty" shouldBe emptyList<Any>(), viewModel.nodesWithPosition.value
fun testMyNodeInfoFlow() = runTest(testDispatcher) {
viewModel.myNodeInfo.test {
assertEquals(null, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testConnectionStateFlow() = runTest {
setUp()
radioController.setConnectionState(org.meshtastic.core.model.ConnectionState.Disconnected)
// isConnected should reflect radioController state
assertTrue(true, "Connection state flow is reactive")
fun testNodesWithPositionStartsEmpty() = runTest(testDispatcher) {
viewModel.nodesWithPosition.test {
assertEquals(emptyList(), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testNodeRepositoryIntegration() = runTest {
setUp()
fun testConnectionStateFlow() = runTest(testDispatcher) {
viewModel.isConnected.test {
// Initially reflects radioController state (which is Disconnected in FakeRadioController default)
assertEquals(false, awaitItem())
radioController.setConnectionState(ConnectionState.Connected)
assertEquals(true, awaitItem())
radioController.setConnectionState(ConnectionState.Disconnected)
assertEquals(false, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testNodeRepositoryIntegration() = runTest(testDispatcher) {
val testNodes = TestDataFactory.createTestNodes(3)
nodeRepository.setNodes(testNodes)
"Nodes added to repository" shouldBe 3, nodeRepository.nodeDBbyNum.value.size
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
}
*/
}

View File

@@ -33,6 +33,8 @@ import kotlinx.coroutines.test.advanceUntilIdle
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.model.ContactSettings
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
@@ -71,14 +73,10 @@ class MessageViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val connectionStateFlow =
MutableStateFlow<org.meshtastic.core.model.ConnectionState>(
org.meshtastic.core.model.ConnectionState.Disconnected,
)
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
private val showQuickChatFlow = MutableStateFlow(false)
private val customEmojiFrequencyFlow = MutableStateFlow<String?>(null)
private val contactSettingsFlow =
MutableStateFlow<Map<String, org.meshtastic.core.model.ContactSettings>>(emptyMap())
private val contactSettingsFlow = MutableStateFlow<Map<String, ContactSettings>>(emptyMap())
@BeforeTest
fun setUp() {
@@ -86,7 +84,7 @@ class MessageViewModelTest {
savedStateHandle = SavedStateHandle(mapOf("contactKey" to "0!12345678"))
nodeRepository = FakeNodeRepository()
connectionStateFlow.value = org.meshtastic.core.model.ConnectionState.Disconnected
connectionStateFlow.value = ConnectionState.Disconnected
showQuickChatFlow.value = false
customEmojiFrequencyFlow.value = null
contactSettingsFlow.value = emptyMap()
@@ -149,9 +147,9 @@ class MessageViewModelTest {
@Test
fun testConnectionState() = runTest {
viewModel.connectionState.test {
assertEquals(org.meshtastic.core.model.ConnectionState.Disconnected, awaitItem())
connectionStateFlow.value = org.meshtastic.core.model.ConnectionState.Connected
assertEquals(org.meshtastic.core.model.ConnectionState.Connected, awaitItem())
assertEquals(ConnectionState.Disconnected, awaitItem())
connectionStateFlow.value = ConnectionState.Connected
assertEquals(ConnectionState.Connected, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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.UnconfinedTestDispatcher
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 = UnconfinedTestDispatcher()
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(testDispatcher) {
val actionsFlow = MutableStateFlow<List<QuickChatAction>>(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(testDispatcher) {
val action = QuickChatAction(uuid = 1L, name = "Test", message = "Hello", position = 0)
everySuspend { quickChatActionRepository.upsert(any()) } returns Unit
val job = viewModel.addQuickChatAction(action)
job.join()
verifySuspend { quickChatActionRepository.upsert(action) }
}
}

View File

@@ -0,0 +1,100 @@
/*
* 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.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.UnconfinedTestDispatcher
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 = UnconfinedTestDispatcher()
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(testDispatcher) {
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()
}
}
}

View File

@@ -0,0 +1,136 @@
/*
* 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.detail
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.request_air_quality_metrics
import org.meshtastic.core.resources.request_device_metrics
import org.meshtastic.core.resources.request_environment_metrics
import org.meshtastic.core.resources.request_host_metrics
import org.meshtastic.core.resources.request_pax_metrics
import org.meshtastic.core.resources.request_power_metrics
import org.meshtastic.core.resources.requesting_from
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.user_info
@Single(binds = [NodeRequestActions::class])
class CommonNodeRequestActions constructor(private val radioController: RadioController) : NodeRequestActions {
private val _effects = MutableSharedFlow<NodeRequestEffect>()
override val effects: SharedFlow<NodeRequestEffect> = _effects.asSharedFlow()
private val _lastTracerouteTime = MutableStateFlow<Long?>(null)
override val lastTracerouteTime: StateFlow<Long?> = _lastTracerouteTime.asStateFlow()
private val _lastRequestNeighborTimes = MutableStateFlow<Map<Int, Long>>(emptyMap())
override val lastRequestNeighborTimes: StateFlow<Map<Int, Long>> = _lastRequestNeighborTimes.asStateFlow()
override fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting UserInfo for '$destNum'" }
radioController.requestUserInfo(destNum)
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName),
),
)
}
}
override fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting NeighborInfo for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestNeighborInfo(packetId, destNum)
_lastRequestNeighborTimes.update { it + (destNum to nowMillis) }
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName),
),
)
}
}
override fun requestPosition(scope: CoroutineScope, destNum: Int, longName: String, position: Position) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting position for '$destNum'" }
radioController.requestPosition(destNum, position)
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.position, longName),
),
)
}
}
override fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting telemetry for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTelemetry(packetId, destNum, type.ordinal)
val typeRes =
when (type) {
TelemetryType.DEVICE -> Res.string.request_device_metrics
TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics
TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics
TelemetryType.POWER -> Res.string.request_power_metrics
TelemetryType.LOCAL_STATS -> Res.string.signal_quality
TelemetryType.HOST -> Res.string.request_host_metrics
TelemetryType.PAX -> Res.string.request_pax_metrics
}
_effects.emit(
NodeRequestEffect.ShowFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)),
)
}
}
override fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting traceroute for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTraceroute(packetId, destNum)
_lastTracerouteTime.value = nowMillis
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName),
),
)
}
}
}

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
@@ -16,130 +16,35 @@
*/
package org.meshtastic.feature.node.detail
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.request_air_quality_metrics
import org.meshtastic.core.resources.request_device_metrics
import org.meshtastic.core.resources.request_environment_metrics
import org.meshtastic.core.resources.request_host_metrics
import org.meshtastic.core.resources.request_pax_metrics
import org.meshtastic.core.resources.request_power_metrics
import org.meshtastic.core.resources.requesting_from
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.user_info
sealed class NodeRequestEffect {
data class ShowFeedback(val text: UiText) : NodeRequestEffect()
}
@Single
class NodeRequestActions constructor(private val radioController: RadioController) {
/** Interface for high-level node request actions (e.g., requesting user info, position, telemetry). */
interface NodeRequestActions {
val effects: SharedFlow<NodeRequestEffect>
val lastTracerouteTime: StateFlow<Long?>
val lastRequestNeighborTimes: StateFlow<Map<Int, Long>>
private val _effects = MutableSharedFlow<NodeRequestEffect>()
val effects: SharedFlow<NodeRequestEffect> = _effects.asSharedFlow()
fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String)
private val _lastTracerouteTime = MutableStateFlow<Long?>(null)
val lastTracerouteTime: StateFlow<Long?> = _lastTracerouteTime.asStateFlow()
private val _lastRequestNeighborTimes = MutableStateFlow<Map<Int, Long>>(emptyMap())
val lastRequestNeighborTimes: StateFlow<Map<Int, Long>> = _lastRequestNeighborTimes.asStateFlow()
fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting UserInfo for '$destNum'" }
radioController.requestUserInfo(destNum)
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName),
),
)
}
}
fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting NeighborInfo for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestNeighborInfo(packetId, destNum)
_lastRequestNeighborTimes.update { it + (destNum to nowMillis) }
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName),
),
)
}
}
fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String)
fun requestPosition(
scope: CoroutineScope,
destNum: Int,
longName: String,
position: Position = Position(0.0, 0.0, 0),
) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting position for '$destNum'" }
radioController.requestPosition(destNum, position)
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.position, longName),
),
)
}
}
)
fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting telemetry for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTelemetry(packetId, destNum, type.ordinal)
fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType)
val typeRes =
when (type) {
TelemetryType.DEVICE -> Res.string.request_device_metrics
TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics
TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics
TelemetryType.POWER -> Res.string.request_power_metrics
TelemetryType.LOCAL_STATS -> Res.string.signal_quality
TelemetryType.HOST -> Res.string.request_host_metrics
TelemetryType.PAX -> Res.string.request_pax_metrics
}
_effects.emit(
NodeRequestEffect.ShowFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)),
)
}
}
fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting traceroute for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTraceroute(packetId, destNum)
_lastTracerouteTime.value = nowMillis
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName),
),
)
}
}
fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String)
}

View File

@@ -0,0 +1,237 @@
/*
* 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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Single
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.fallback_node_name
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.feature.node.detail.NodeDetailUiState
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
@Single(binds = [GetNodeDetailsUseCase::class])
class CommonGetNodeDetailsUseCase
constructor(
private val nodeRepository: NodeRepository,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
) : GetNodeDetailsUseCase {
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
override operator fun invoke(nodeId: Int): Flow<NodeDetailUiState> =
nodeRepository.effectiveLogNodeId(nodeId).flatMapLatest { effectiveNodeId ->
buildFlow(nodeId, effectiveNodeId)
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun buildFlow(nodeId: Int, effectiveNodeId: Int): Flow<NodeDetailUiState> {
val nodeFlow =
nodeRepository.nodeDBbyNum.map { it[nodeId] ?: Node.createFallback(nodeId, "") }.distinctUntilChanged()
// 1. Logs & Metrics Data
val metricsLogsFlow =
combine(
meshLogRepository.getTelemetryFrom(effectiveNodeId).onStart { emit(emptyList()) },
meshLogRepository.getMeshPacketsFrom(effectiveNodeId).onStart { emit(emptyList()) },
meshLogRepository.getMeshPacketsFrom(effectiveNodeId, PortNum.POSITION_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.PAXCOUNTER_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.TRACEROUTE_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.NEIGHBORINFO_APP.value).onStart {
emit(emptyList())
},
) { args: Array<List<Any?>> ->
@Suppress("UNCHECKED_CAST")
LogsGroup(
telemetry = args[0] as List<Telemetry>,
packets = args[1] as List<MeshPacket>,
posPackets = args[2] as List<MeshPacket>,
pax = args[3] as List<MeshLog>,
trRes = args[4] as List<MeshLog>,
niRes = args[5] as List<MeshLog>,
)
}
// 2. Identity & Config
val identityFlow =
combine(
nodeRepository.ourNodeInfo,
nodeRepository.myNodeInfo,
radioConfigRepository.deviceProfileFlow.onStart { emit(DeviceProfile()) },
) { ourNode, myInfo, profile ->
IdentityGroup(ourNode, myInfo, profile)
}
// 3. Metadata & Request Timestamps
val metadataFlow =
combine(
meshLogRepository
.getMyNodeInfo()
.map { it?.firmware_edition }
.distinctUntilChanged()
.onStart { emit(null) },
firmwareReleaseRepository.stableRelease,
firmwareReleaseRepository.alphaRelease,
nodeRequestActions.lastTracerouteTime,
nodeRequestActions.lastRequestNeighborTimes.map { it[nodeId] },
) { edition, stable, alpha, trTime, niTime ->
MetadataGroup(edition = edition, stable = stable, alpha = alpha, trTime = trTime, niTime = niTime)
}
// 4. Requests History (we still query request logs by the target nodeId)
val requestsFlow =
combine(
meshLogRepository.getRequestLogs(nodeId, PortNum.TRACEROUTE_APP).onStart { emit(emptyList()) },
meshLogRepository.getRequestLogs(nodeId, PortNum.NEIGHBORINFO_APP).onStart { emit(emptyList()) },
) { trReqs, niReqs ->
trReqs to niReqs
}
// Assemble final UI state
return combine(nodeFlow, metricsLogsFlow, identityFlow, metadataFlow, requestsFlow) {
node,
logs,
identity,
metadata,
requests,
->
val (trReqs, niReqs) = requests
val isLocal = node.num == identity.ourNode?.num
val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null
val hw = deviceHardwareRepository.getDeviceHardwareByModel(node.user.hw_model.value, pioEnv).getOrNull()
val moduleConfig = identity.profile.module_config
val displayUnits = identity.profile.config?.display?.units ?: Config.DisplayConfig.DisplayUnits.METRIC
val metricsState =
MetricsState(
node = node,
isLocal = isLocal,
deviceHardware = hw,
reportedTarget = pioEnv,
isManaged = identity.profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
deviceMetrics = logs.telemetry.filter { it.device_metrics != null },
powerMetrics = logs.telemetry.filter { it.power_metrics != null },
hostMetrics = logs.telemetry.filter { it.host_metrics != null },
signalMetrics = logs.packets.filter { it.isDirectSignal() },
positionLogs = logs.posPackets.mapNotNull { it.toPosition() },
paxMetrics = logs.pax,
tracerouteRequests = trReqs,
tracerouteResults = logs.trRes,
neighborInfoRequests = niReqs,
neighborInfoResults = logs.niRes,
firmwareEdition = metadata.edition,
latestStableFirmware = metadata.stable ?: FirmwareRelease(),
latestAlphaFirmware = metadata.alpha ?: FirmwareRelease(),
)
val environmentState =
EnvironmentMetricsState(environmentMetrics = logs.telemetry.filter { it.hasValidEnvironmentMetrics() })
val availableLogs = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
@Suppress("MagicNumber")
val nodeName =
node.user.long_name.takeIf { it.isNotBlank() }?.let { UiText.DynamicString(it) }
?: UiText.Resource(Res.string.fallback_node_name, node.user.id.takeLast(4))
NodeDetailUiState(
node = node,
nodeName = nodeName,
ourNode = identity.ourNode,
metricsState = metricsState,
environmentState = environmentState,
availableLogs = availableLogs,
lastTracerouteTime = metadata.trTime,
lastRequestNeighborsTime = metadata.niTime,
)
}
}
private data class LogsGroup(
val telemetry: List<Telemetry>,
val packets: List<MeshPacket>,
val posPackets: List<MeshPacket>,
val pax: List<MeshLog>,
val trRes: List<MeshLog>,
val niRes: List<MeshLog>,
)
private data class IdentityGroup(val ourNode: Node?, val myInfo: MyNodeInfo?, val profile: DeviceProfile)
private data class MetadataGroup(
val edition: FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,
val trTime: Long?,
val niTime: Long?,
)
}

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
@@ -16,222 +16,10 @@
*/
package org.meshtastic.feature.node.domain.usecase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Single
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.fallback_node_name
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.feature.node.detail.NodeDetailUiState
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
@Single
class GetNodeDetailsUseCase
constructor(
private val nodeRepository: NodeRepository,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
) {
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
operator fun invoke(nodeId: Int): Flow<NodeDetailUiState> =
nodeRepository.effectiveLogNodeId(nodeId).flatMapLatest { effectiveNodeId ->
buildFlow(nodeId, effectiveNodeId)
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun buildFlow(nodeId: Int, effectiveNodeId: Int): Flow<NodeDetailUiState> {
val nodeFlow =
nodeRepository.nodeDBbyNum.map { it[nodeId] ?: Node.createFallback(nodeId, "") }.distinctUntilChanged()
// 1. Logs & Metrics Data
val metricsLogsFlow =
combine(
meshLogRepository.getTelemetryFrom(effectiveNodeId).onStart { emit(emptyList()) },
meshLogRepository.getMeshPacketsFrom(effectiveNodeId).onStart { emit(emptyList()) },
meshLogRepository.getMeshPacketsFrom(effectiveNodeId, PortNum.POSITION_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.PAXCOUNTER_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.TRACEROUTE_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.NEIGHBORINFO_APP.value).onStart {
emit(emptyList())
},
) { args: Array<List<Any?>> ->
@Suppress("UNCHECKED_CAST")
LogsGroup(
telemetry = args[0] as List<Telemetry>,
packets = args[1] as List<MeshPacket>,
posPackets = args[2] as List<MeshPacket>,
pax = args[3] as List<MeshLog>,
trRes = args[4] as List<MeshLog>,
niRes = args[5] as List<MeshLog>,
)
}
// 2. Identity & Config
val identityFlow =
combine(
nodeRepository.ourNodeInfo,
nodeRepository.myNodeInfo,
radioConfigRepository.deviceProfileFlow.onStart { emit(DeviceProfile()) },
) { ourNode, myInfo, profile ->
IdentityGroup(ourNode, myInfo, profile)
}
// 3. Metadata & Request Timestamps
val metadataFlow =
combine(
meshLogRepository
.getMyNodeInfo()
.map { it?.firmware_edition }
.distinctUntilChanged()
.onStart { emit(null) },
firmwareReleaseRepository.stableRelease,
firmwareReleaseRepository.alphaRelease,
nodeRequestActions.lastTracerouteTime,
nodeRequestActions.lastRequestNeighborTimes.map { it[nodeId] },
) { edition, stable, alpha, trTime, niTime ->
MetadataGroup(edition = edition, stable = stable, alpha = alpha, trTime = trTime, niTime = niTime)
}
// 4. Requests History (we still query request logs by the target nodeId)
val requestsFlow =
combine(
meshLogRepository.getRequestLogs(nodeId, PortNum.TRACEROUTE_APP).onStart { emit(emptyList()) },
meshLogRepository.getRequestLogs(nodeId, PortNum.NEIGHBORINFO_APP).onStart { emit(emptyList()) },
) { trReqs, niReqs ->
trReqs to niReqs
}
// Assemble final UI state
return combine(nodeFlow, metricsLogsFlow, identityFlow, metadataFlow, requestsFlow) {
node,
logs,
identity,
metadata,
requests,
->
val (trReqs, niReqs) = requests
val isLocal = node.num == identity.ourNode?.num
val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null
val hw = deviceHardwareRepository.getDeviceHardwareByModel(node.user.hw_model.value, pioEnv).getOrNull()
val moduleConfig = identity.profile.module_config
val displayUnits = identity.profile.config?.display?.units ?: Config.DisplayConfig.DisplayUnits.METRIC
val metricsState =
MetricsState(
node = node,
isLocal = isLocal,
deviceHardware = hw,
reportedTarget = pioEnv,
isManaged = identity.profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
deviceMetrics = logs.telemetry.filter { it.device_metrics != null },
powerMetrics = logs.telemetry.filter { it.power_metrics != null },
hostMetrics = logs.telemetry.filter { it.host_metrics != null },
signalMetrics = logs.packets.filter { it.isDirectSignal() },
positionLogs = logs.posPackets.mapNotNull { it.toPosition() },
paxMetrics = logs.pax,
tracerouteRequests = trReqs,
tracerouteResults = logs.trRes,
neighborInfoRequests = niReqs,
neighborInfoResults = logs.niRes,
firmwareEdition = metadata.edition,
latestStableFirmware = metadata.stable ?: FirmwareRelease(),
latestAlphaFirmware = metadata.alpha ?: FirmwareRelease(),
)
val environmentState =
EnvironmentMetricsState(environmentMetrics = logs.telemetry.filter { it.hasValidEnvironmentMetrics() })
val availableLogs = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
@Suppress("MagicNumber")
val nodeName =
node.user.long_name.takeIf { it.isNotBlank() }?.let { UiText.DynamicString(it) }
?: UiText.Resource(Res.string.fallback_node_name, node.user.id.takeLast(4))
NodeDetailUiState(
node = node,
nodeName = nodeName,
ourNode = identity.ourNode,
metricsState = metricsState,
environmentState = environmentState,
availableLogs = availableLogs,
lastTracerouteTime = metadata.trTime,
lastRequestNeighborsTime = metadata.niTime,
)
}
}
private data class LogsGroup(
val telemetry: List<Telemetry>,
val packets: List<MeshPacket>,
val posPackets: List<MeshPacket>,
val pax: List<MeshLog>,
val trRes: List<MeshLog>,
val niRes: List<MeshLog>,
)
private data class IdentityGroup(val ourNode: Node?, val myInfo: MyNodeInfo?, val profile: DeviceProfile)
private data class MetadataGroup(
val edition: FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,
val trTime: Long?,
val niTime: Long?,
)
/** Use case for retrieving comprehensive details for a specific node. */
interface GetNodeDetailsUseCase {
operator fun invoke(nodeId: Int): Flow<NodeDetailUiState>
}

View File

@@ -44,7 +44,6 @@ import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
@@ -55,6 +54,7 @@ import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TracerouteSnapshotRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.traceroute

View File

@@ -0,0 +1,139 @@
/*
* 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.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

@@ -0,0 +1,118 @@
/*
* 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.node.detail
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
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.flow.emptyFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
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.repository.ServiceRepository
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
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
@OptIn(ExperimentalCoroutinesApi::class)
class NodeDetailViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var viewModel: NodeDetailViewModel
private val nodeManagementActions: NodeManagementActions = mock()
private val nodeRequestActions: NodeRequestActions = mock()
private val serviceRepository: ServiceRepository = mock()
private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { getNodeDetailsUseCase(any()) } returns emptyFlow()
every { nodeRequestActions.effects } returns kotlinx.coroutines.flow.MutableSharedFlow()
viewModel = createViewModel(1234)
}
private fun createViewModel(nodeId: Int?) = NodeDetailViewModel(
savedStateHandle = SavedStateHandle(if (nodeId != null) mapOf("destNum" to nodeId) else emptyMap()),
nodeManagementActions = nodeManagementActions,
nodeRequestActions = nodeRequestActions,
serviceRepository = serviceRepository,
getNodeDetailsUseCase = getNodeDetailsUseCase,
)
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testInitialization() {
assertNotNull(viewModel)
}
@Test
fun `uiState emits updates from useCase`() = runTest(testDispatcher) {
val node = Node(num = 1234, user = User(id = "!1234"))
val stateFlow = MutableStateFlow(NodeDetailUiState(node = node))
every { getNodeDetailsUseCase(1234) } returns stateFlow
val vm = createViewModel(1234)
vm.uiState.test {
// State from useCase (delivered immediately due to UnconfinedTestDispatcher)
val state = awaitItem()
assertEquals(1234, state.node?.num)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `handleNodeMenuAction delegates to nodeManagementActions for Mute`() = runTest(testDispatcher) {
val node = Node(num = 1234, user = User(id = "!1234"))
every { nodeManagementActions.requestMuteNode(any(), any()) } returns Unit
viewModel.handleNodeMenuAction(NodeMenuAction.Mute(node))
verify { nodeManagementActions.requestMuteNode(any(), node) }
}
@Test
fun `handleNodeMenuAction delegates to nodeRequestActions for Traceroute`() = runTest(testDispatcher) {
val node = Node(num = 1234, user = User(id = "!1234", long_name = "Test Node"))
every { nodeRequestActions.requestTraceroute(any(), any(), any()) } returns Unit
viewModel.handleNodeMenuAction(NodeMenuAction.TraceRoute(node))
verify { nodeRequestActions.requestTraceroute(any(), 1234, "Test Node") }
}
}

View File

@@ -16,28 +16,29 @@
*/
package org.meshtastic.feature.node.domain.usecase
import dev.mokkery.answering.returns
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
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class GetFilteredNodesUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var useCase: GetFilteredNodesUseCase
@Before
@BeforeTest
fun setUp() {
nodeRepository = mock()
useCase = GetFilteredNodesUseCase(nodeRepository)

View File

@@ -16,46 +16,172 @@
*/
package org.meshtastic.feature.node.metrics
class MetricsViewModelTest {
/*
import app.cash.turbine.test
import dev.mokkery.answering.calls
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okio.Buffer
import okio.BufferedSink
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TracerouteSnapshotRepository
import org.meshtastic.feature.node.detail.NodeDetailUiState
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.Position
import org.meshtastic.proto.Telemetry
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
private val dispatchers =
CoroutineDispatchers(
main = kotlinx.coroutines.Dispatchers.Unconfined,
io = kotlinx.coroutines.Dispatchers.Unconfined,
default = kotlinx.coroutines.Dispatchers.Unconfined,
)
class MetricsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
private val meshLogRepository: MeshLogRepository = mock()
private val serviceRepository: ServiceRepository = mock()
private val nodeRepository: NodeRepository = mock()
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository = mock()
private val nodeRequestActions: NodeRequestActions = mock()
private val alertManager: org.meshtastic.core.ui.util.AlertManager = mock()
private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
private val fileService: FileService = mock()
private lateinit var viewModel: MetricsViewModel
@Before
@BeforeTest
fun setUp() {
Dispatchers.setMain(dispatchers.main)
Dispatchers.setMain(testDispatcher)
viewModel =
MetricsViewModel(
destNum = 1234,
dispatchers = dispatchers,
meshLogRepository = meshLogRepository,
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
tracerouteSnapshotRepository = tracerouteSnapshotRepository,
nodeRequestActions = nodeRequestActions,
alertManager = alertManager,
getNodeDetailsUseCase = getNodeDetailsUseCase,
fileService = fileService,
)
// Default setup for flows
every { serviceRepository.tracerouteResponse } returns MutableStateFlow(null)
every { nodeRequestActions.effects } returns mock()
every { nodeRequestActions.lastTracerouteTime } returns MutableStateFlow(null)
every { nodeRequestActions.lastRequestNeighborTimes } returns MutableStateFlow(emptyMap())
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
// Mock the case where we get node details
every { getNodeDetailsUseCase(any()) } returns flowOf(NodeDetailUiState())
viewModel = createViewModel()
}
@After
private fun createViewModel(destNum: Int = 1234) = MetricsViewModel(
destNum = destNum,
dispatchers = dispatchers,
meshLogRepository = meshLogRepository,
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
tracerouteSnapshotRepository = tracerouteSnapshotRepository,
nodeRequestActions = nodeRequestActions,
alertManager = alertManager,
getNodeDetailsUseCase = getNodeDetailsUseCase,
fileService = fileService,
)
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test fun testInitialization() = runTest { assertNotNull(viewModel) }
@Test
fun testInitialization() {
assertNotNull(viewModel)
}
@Test
fun testSavePositionCSV() = runTest {
fun `state reflects updates from getNodeDetailsUseCase`() = runTest(testDispatcher) {
val nodeDetailFlow = MutableStateFlow(NodeDetailUiState())
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val vm = createViewModel()
vm.state.test {
assertEquals(MetricsState.Empty, awaitItem())
val newState = MetricsState(isFahrenheit = true)
nodeDetailFlow.value = NodeDetailUiState(metricsState = newState)
assertEquals(newState, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `availableTimeFrames filters based on oldest data`() = runTest(testDispatcher) {
val now = org.meshtastic.core.common.util.nowSeconds
val nodeDetailFlow = MutableStateFlow(NodeDetailUiState())
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val vm = createViewModel()
vm.availableTimeFrames.test {
// Skip initial values
var current = awaitItem()
// Provide data from 2 hours ago (7200 seconds)
// This should make ONE_HOUR available, but not TWENTY_FOUR_HOURS
val twoHoursAgo = now - 7200
nodeDetailFlow.value =
NodeDetailUiState(
node =
org.meshtastic.core.model.Node(num = 1234, user = org.meshtastic.proto.User(id = "!1234")),
environmentState =
EnvironmentMetricsState(environmentMetrics = listOf(Telemetry(time = twoHoursAgo.toInt()))),
)
// We might get multiple emissions as flows propagate
current = awaitItem()
while (current.size == TimeFrame.entries.size) { // Skip the initial "all" if it's still there
current = awaitItem()
}
assertTrue(current.contains(TimeFrame.ONE_HOUR))
assertTrue(!current.contains(TimeFrame.TWENTY_FOUR_HOURS), "Should not contain 24h for 2h old data")
// Provide data from 8 days ago
val eightDaysAgo = now - (8 * 24 * 3600)
nodeDetailFlow.value =
NodeDetailUiState(
node =
org.meshtastic.core.model.Node(num = 1234, user = org.meshtastic.proto.User(id = "!1234")),
environmentState =
EnvironmentMetricsState(
environmentMetrics = listOf(Telemetry(time = eightDaysAgo.toInt())),
),
)
current = awaitItem()
assertTrue(current.contains(TimeFrame.SEVEN_DAYS))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `savePositionCSV writes correct data`() = runTest(testDispatcher) {
val testPosition =
Position(
latitude_i = 123456789,
@@ -67,52 +193,37 @@ class MetricsViewModelTest {
time = 1700000000,
)
everySuspend { getNodeDetailsUseCase(any()) } returns
flowOf(NodeDetailUiState(metricsState = MetricsState(positionLogs = listOf(testPosition))))
// Re-init view model so it picks up the mocked flow
viewModel =
MetricsViewModel(
destNum = 1234,
dispatchers = dispatchers,
meshLogRepository = meshLogRepository,
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
tracerouteSnapshotRepository = tracerouteSnapshotRepository,
nodeRequestActions = nodeRequestActions,
alertManager = alertManager,
getNodeDetailsUseCase = getNodeDetailsUseCase,
fileService = fileService,
)
// Wait for state to populate
val collectionJob = backgroundScope.launch { viewModel.state.collect {} }
kotlinx.coroutines.yield()
advanceUntilIdle()
val uri = MeshtasticUri("content://test")
viewModel.savePositionCSV(uri)
advanceUntilIdle()
verifySuspend { fileService.write(uri, any()) }
val nodeDetailFlow =
MutableStateFlow(NodeDetailUiState(metricsState = MetricsState(positionLogs = listOf(testPosition))))
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val buffer = Buffer()
blockSlot.captured.invoke(buffer)
everySuspend { fileService.write(any(), any()) } calls
{ args ->
val block = args.arg<suspend (BufferedSink) -> Unit>(1)
block(buffer)
true
}
val csvOutput = buffer.readUtf8()
assertEquals(
"\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n",
csvOutput.substringBefore("\n") + "\n",
)
assert(csvOutput.contains("12.345")) { "Missing latitude in $csvOutput" }
assert(csvOutput.contains("-98.765")) { "Missing longitude in $csvOutput" }
assert(csvOutput.contains("\"100\",\"5\",\"10\",\"1.23\"\n")) { "Missing rest in $csvOutput" }
val vm = createViewModel()
// Wait for state to be collected so it's not Empty when savePositionCSV is called
vm.state.test {
awaitItem() // Empty
awaitItem() // with position
collectionJob.cancel()
val uri = MeshtasticUri("content://test")
vm.savePositionCSV(uri)
runCurrent()
verifySuspend { fileService.write(uri, any()) }
val csvOutput = buffer.readUtf8()
assertTrue(csvOutput.startsWith("\"date\",\"time\",\"latitude\",\"longitude\""))
assertTrue(csvOutput.contains("12.3456789"))
assertTrue(csvOutput.contains("-98.7654321"))
assertTrue(csvOutput.contains("\"100\",\"5\",\"10\",\"1.23\""))
cancelAndIgnoreRemainingEvents()
}
}
*/
}