mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-28 18:52:42 -04:00
feat: Enhance test coverage (#4847)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
1
.github/workflows/reusable-check.yml
vendored
1
.github/workflows/reusable-check.yml
vendored
@@ -256,3 +256,4 @@ jobs:
|
||||
path: |
|
||||
**/build/outputs/androidTest-results
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
32
conductor/archive/expand_testing_20260318/plan.md
Normal file
32
conductor/archive/expand_testing_20260318/plan.md
Normal 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
|
||||
@@ -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.
|
||||
@@ -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/)*
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -71,6 +71,8 @@ kotlin {
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.kotest.assertions)
|
||||
implementation(libs.kotest.property)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
6
docs/testing/baseline_coverage.md
Normal file
6
docs/testing/baseline_coverage.md
Normal 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.
|
||||
18
docs/testing/final_coverage.md
Normal file
18
docs/testing/final_coverage.md
Normal 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.
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user