refactor: coroutine dispatchers and modernize testing infrastructure (#4901)
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-23 20:31:48 -05:00 committed by GitHub
parent 664ebf218e
commit 96060a0a4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 621 additions and 182 deletions

View file

@ -67,6 +67,7 @@ kotlin {
}
commonTest.dependencies {
implementation(projects.core.testing)
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.kotest.assertions)

View file

@ -54,13 +54,6 @@ import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.meshtastic_app_name
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
@ -326,17 +319,11 @@ class MeshConnectionManagerImpl(
updateStatusNotification(t)
}
override fun updateStatusNotification(telemetry: Telemetry?): Any {
val summary =
when (serviceRepository.connectionState.value) {
is ConnectionState.Connected ->
getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected)
is ConnectionState.Disconnected -> getString(Res.string.disconnected)
is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
is ConnectionState.Connecting -> getString(Res.string.connecting)
}
return serviceNotifications.updateServiceStateNotification(summary, telemetry = telemetry)
}
override fun updateStatusNotification(telemetry: Telemetry?): Any =
serviceNotifications.updateServiceStateNotification(
serviceRepository.connectionState.value,
telemetry = telemetry,
)
companion object {
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30

View file

@ -16,10 +16,70 @@
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
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.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
class MeshConnectionManagerImplTest {
/*
private val radioInterfaceService = mock<RadioInterfaceService>(MockMode.autofill)
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val serviceNotifications = mock<MeshServiceNotifications>(MockMode.autofill)
private val uiPrefs = mock<UiPrefs>(MockMode.autofill)
private val packetHandler = mock<PacketHandler>(MockMode.autofill)
private val nodeRepository = FakeNodeRepository()
private val locationManager = mock<MeshLocationManager>(MockMode.autofill)
private val mqttManager = mock<MqttManager>(MockMode.autofill)
private val historyManager = mock<HistoryManager>(MockMode.autofill)
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val commandSender = mock<CommandSender>(MockMode.autofill)
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
private val workerManager = mock<MeshWorkerManager>(MockMode.autofill)
private val appWidgetUpdater = mock<AppWidgetUpdater>(MockMode.autofill)
private val dataPacket = DataPacket(id = 456, time = 0L, to = "0", from = "0", bytes = null, dataType = 0)
private val radioConnectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
@ -30,17 +90,23 @@ class MeshConnectionManagerImplTest {
private lateinit var manager: MeshConnectionManagerImpl
@Before
@BeforeTest
fun setUp() {
mockkStatic("org.meshtastic.core.resources.GetStringKt")
every { radioInterfaceService.connectionState } returns radioConnectionState
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
every { nodeRepository.myNodeInfo } returns MutableStateFlow<MyNodeInfo?>(null)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow<Node?>(null)
every { nodeRepository.localStats } returns MutableStateFlow(LocalStats())
every { serviceRepository.connectionState } returns connectionStateFlow
every { serviceRepository.setConnectionState(any()) } calls
{ call ->
connectionStateFlow.value = call.arg<ConnectionState>(0)
}
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { commandSender.sendAdmin(any(), any(), any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap<Int, Node>()
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
manager =
MeshConnectionManagerImpl(
@ -64,27 +130,38 @@ class MeshConnectionManagerImplTest {
)
}
@After
fun tearDown() {
unmockkStatic("org.meshtastic.core.resources.GetStringKt")
}
@AfterTest fun tearDown() {}
@Test
fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) {
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
manager.start(backgroundScope)
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
assertEquals(
"State should be Connecting after radio Connected",
ConnectionState.Connecting,
serviceRepository.connectionState.value,
"State should be Connecting after radio Connected",
)
verify { serviceBroadcasts.broadcastConnection() }
}
@Test
fun `Disconnected state stops services`() = runTest(testDispatcher) {
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
manager.start(backgroundScope)
// Transition to Connected first so that Disconnected actually does something
radioConnectionState.value = ConnectionState.Connected
@ -94,9 +171,9 @@ class MeshConnectionManagerImplTest {
advanceUntilIdle()
assertEquals(
"State should be Disconnected after radio Disconnected",
ConnectionState.Disconnected,
serviceRepository.connectionState.value,
"State should be Disconnected after radio Disconnected",
)
verify { packetHandler.stopPacketQueue() }
verify { locationManager.stop() }
@ -112,6 +189,12 @@ class MeshConnectionManagerImplTest {
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT),
)
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
manager.start(backgroundScope)
advanceUntilIdle()
@ -120,9 +203,9 @@ class MeshConnectionManagerImplTest {
advanceUntilIdle()
assertEquals(
"State should be Disconnected when power saving is off",
ConnectionState.Disconnected,
serviceRepository.connectionState.value,
"State should be Disconnected when power saving is off",
)
}
@ -131,6 +214,11 @@ class MeshConnectionManagerImplTest {
// Power saving enabled
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true))
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
manager.start(backgroundScope)
advanceUntilIdle()
@ -139,9 +227,9 @@ class MeshConnectionManagerImplTest {
advanceUntilIdle()
assertEquals(
"State should stay in DeviceSleep when power saving is on",
ConnectionState.DeviceSleep,
serviceRepository.connectionState.value,
"State should stay in DeviceSleep when power saving is on",
)
}
@ -149,8 +237,8 @@ class MeshConnectionManagerImplTest {
fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) {
manager.start(backgroundScope)
val packetId = 456
every { dataPacket.id } returns packetId
everySuspend { packetRepository.getQueuedPackets() } returns listOf(dataPacket)
every { workerManager.enqueueSendMessage(any()) } returns Unit
manager.onRadioConfigLoaded()
advanceUntilIdle()
@ -160,15 +248,23 @@ class MeshConnectionManagerImplTest {
@Test
fun `onNodeDbReady starts MQTT and requests history`() = runTest(testDispatcher) {
every { moduleConfig.mqtt } returns ModuleConfig.MQTTConfig(enabled = true)
every { moduleConfig.store_forward } returns ModuleConfig.StoreForwardConfig(enabled = true)
val moduleConfig =
LocalModuleConfig(
mqtt = ModuleConfig.MQTTConfig(enabled = true, proxy_to_client_enabled = true),
store_forward = ModuleConfig.StoreForwardConfig(enabled = true),
)
moduleConfigFlow.value = moduleConfig
every { commandSender.requestTelemetry(any(), any(), any()) } returns Unit
every { nodeManager.myNodeNum } returns 123
every { mqttManager.start(any(), any(), any()) } returns Unit
every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit
every { nodeManager.getMyNodeInfo() } returns null
manager.start(backgroundScope)
manager.onNodeDbReady()
advanceUntilIdle()
verify { mqttManager.start(any(), true, true) }
verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) }
}
*/
}

View file

@ -24,7 +24,6 @@ import io.github.davidepianca98.mqtt.packets.Qos
import io.github.davidepianca98.mqtt.packets.mqttv5.SubscriptionOptions
import io.github.davidepianca98.socket.tls.TLSClientSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.awaitClose
@ -47,6 +46,7 @@ import org.meshtastic.proto.MqttClientProxyMessage
class MQTTRepositoryImpl(
private val radioConfigRepository: RadioConfigRepository,
private val nodeRepository: NodeRepository,
dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
) : MQTTRepository {
companion object {
@ -58,7 +58,7 @@ class MQTTRepositoryImpl(
private var client: MQTTClient? = null
private val json = Json { ignoreUnknownKeys = true }
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val scope = CoroutineScope(dispatchers.default + SupervisorJob())
private var clientJob: Job? = null
private val publishSemaphore = Semaphore(20)

View file

@ -28,7 +28,7 @@ interface MeshServiceNotifications {
fun initChannels()
fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Any
fun updateServiceStateNotification(state: org.meshtastic.core.model.ConnectionState, telemetry: Telemetry?): Any
suspend fun updateMessageNotification(
contactKey: String,

View file

@ -53,6 +53,10 @@ import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
import org.meshtastic.core.resources.R.raw
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.local_stats_bad
import org.meshtastic.core.resources.local_stats_battery
@ -98,7 +102,7 @@ import kotlin.time.Duration.Companion.minutes
* This class centralizes notification logic, including channel creation, builder configuration, and displaying
* notifications for various events like new messages, alerts, and service status changes.
*/
@Suppress("TooManyFunctions", "LongParameterList")
@Suppress("TooManyFunctions", "LongParameterList", "LargeClass")
@Single
class MeshServiceNotificationsImpl(
private val context: Context,
@ -287,7 +291,19 @@ class MeshServiceNotificationsImpl(
// region Public Notification Methods
@Suppress("CyclomaticComplexMethod", "NestedBlockDepth")
override fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Notification {
override fun updateServiceStateNotification(
state: org.meshtastic.core.model.ConnectionState,
telemetry: Telemetry?,
): Notification {
val summaryString =
when (state) {
is org.meshtastic.core.model.ConnectionState.Connected ->
getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected)
is org.meshtastic.core.model.ConnectionState.Disconnected -> getString(Res.string.disconnected)
is org.meshtastic.core.model.ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
is org.meshtastic.core.model.ConnectionState.Connecting -> getString(Res.string.connecting)
}
// Update caches if telemetry is provided
telemetry?.let { t ->
t.local_stats?.let { stats ->

View file

@ -18,7 +18,6 @@ package org.meshtastic.core.service
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -54,6 +53,7 @@ class MeshServiceOrchestrator(
private val connectionManager: MeshConnectionManager,
private val router: MeshRouter,
private val serviceNotifications: MeshServiceNotifications,
private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
) {
private var serviceJob: Job? = null
@ -80,7 +80,7 @@ class MeshServiceOrchestrator(
Logger.i { "Starting mesh service orchestrator" }
val job = Job()
serviceJob = job
val scope = CoroutineScope(Dispatchers.Default + job)
val scope = CoroutineScope(dispatchers.default + job)
serviceScope = scope
serviceNotifications.initChannels()

View file

@ -16,36 +16,60 @@
*/
package org.meshtastic.core.service
class MeshServiceOrchestratorTest {
/*
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.MutableSharedFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MeshServiceOrchestratorTest {
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val packetHandler: PacketHandler = mock(MockMode.autofill)
private val nodeManager: NodeManager = mock(MockMode.autofill)
private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill)
private val commandSender: CommandSender = mock(MockMode.autofill)
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
private val router: MeshRouter = mock(MockMode.autofill)
private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill)
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher)
@Test
fun testStartWiresComponents() {
val radioInterfaceService = mockk<RadioInterfaceService>(relaxed = true)
val serviceRepository = mockk<ServiceRepository>(relaxed = true)
val packetHandler = mockk<PacketHandler>(relaxed = true)
val nodeManager = mockk<NodeManager>(relaxed = true)
val messageProcessor = mockk<MeshMessageProcessor>(relaxed = true)
val commandSender = mockk<CommandSender>(relaxed = true)
val connectionManager = mockk<MeshConnectionManager>(relaxed = true)
val router = mockk<MeshRouter>(relaxed = true)
val serviceNotifications = mockk<MeshServiceNotifications>(relaxed = true)
every { radioInterfaceService.receivedData } returns MutableSharedFlow()
every { serviceRepository.serviceAction } returns MutableSharedFlow()
val orchestrator =
MeshServiceOrchestrator(
radioInterfaceService,
serviceRepository,
packetHandler,
nodeManager,
messageProcessor,
commandSender,
connectionManager,
router,
serviceNotifications,
radioInterfaceService = radioInterfaceService,
serviceRepository = serviceRepository,
packetHandler = packetHandler,
nodeManager = nodeManager,
messageProcessor = messageProcessor,
commandSender = commandSender,
connectionManager = connectionManager,
router = router,
serviceNotifications = serviceNotifications,
dispatchers = dispatchers,
)
assertFalse(orchestrator.isRunning)
@ -59,6 +83,4 @@ class MeshServiceOrchestratorTest {
orchestrator.stop()
assertFalse(orchestrator.isRunning)
}
*/
}

View file

@ -31,6 +31,7 @@ kotlin {
// Heavy modules (database, data, domain) should depend on core:testing, not vice versa.
api(projects.core.model)
api(projects.core.repository)
api(libs.kermit)
// Testing libraries - these are public API for all test consumers
api(kotlin("test"))

View file

@ -0,0 +1,36 @@
/*
* 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.testing
import kotlinx.coroutines.flow.MutableStateFlow
import org.meshtastic.core.repository.MeshLogPrefs
class FakeMeshLogPrefs : MeshLogPrefs {
private val _retentionDays = MutableStateFlow(MeshLogPrefs.DEFAULT_RETENTION_DAYS)
override val retentionDays = _retentionDays
override fun setRetentionDays(days: Int) {
_retentionDays.value = days
}
private val _loggingEnabled = MutableStateFlow(true)
override val loggingEnabled = _loggingEnabled
override fun setLoggingEnabled(enabled: Boolean) {
_loggingEnabled.value = enabled
}
}

View file

@ -0,0 +1,79 @@
/*
* 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.testing
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
@Suppress("TooManyFunctions")
class FakeMeshLogRepository : MeshLogRepository {
private val logsFlow = MutableStateFlow<List<MeshLog>>(emptyList())
val currentLogs: List<MeshLog>
get() = logsFlow.value
var deleteLogsOlderThanCalledDays: Int? = null
override fun getAllLogs(maxItem: Int): Flow<List<MeshLog>> = logsFlow.map { it.take(maxItem) }
override fun getAllLogsInReceiveOrder(maxItem: Int): Flow<List<MeshLog>> = logsFlow.map { it.take(maxItem) }
override fun getAllLogsUnbounded(): Flow<List<MeshLog>> = logsFlow
override fun getLogsFrom(nodeNum: Int, portNum: Int): Flow<List<MeshLog>> = logsFlow.map {
it.filter { log -> log.fromNum == nodeNum && log.portNum == portNum }
}
override fun getMeshPacketsFrom(nodeNum: Int, portNum: Int): Flow<List<MeshPacket>> = MutableStateFlow(emptyList())
override fun getTelemetryFrom(nodeNum: Int): Flow<List<Telemetry>> = MutableStateFlow(emptyList())
override fun getRequestLogs(targetNodeNum: Int, portNum: PortNum): Flow<List<MeshLog>> =
MutableStateFlow(emptyList())
override fun getMyNodeInfo(): Flow<MyNodeInfo?> = MutableStateFlow(null)
override suspend fun insert(log: MeshLog) {
logsFlow.value = logsFlow.value + log
}
override suspend fun deleteAll() {
logsFlow.value = emptyList()
}
override suspend fun deleteLog(uuid: String) {
logsFlow.value = logsFlow.value.filter { it.uuid != uuid }
}
override suspend fun deleteLogs(nodeNum: Int, portNum: Int) {
logsFlow.value = logsFlow.value.filterNot { it.fromNum == nodeNum && it.portNum == portNum }
}
override suspend fun deleteLogsOlderThan(retentionDays: Int) {
deleteLogsOlderThanCalledDays = retentionDays
}
fun setLogs(logs: List<MeshLog>) {
logsFlow.value = logs
}
}

View file

@ -52,6 +52,7 @@ class FakeRadioController : RadioController {
val favoritedNodes = mutableListOf<Int>()
val sentSharedContacts = mutableListOf<Int>()
var throwOnSend: Boolean = false
var lastSetDeviceAddress: String? = null
override suspend fun sendMessage(packet: DataPacket) {
if (throwOnSend) error("Fake send failure")
@ -136,7 +137,9 @@ class FakeRadioController : RadioController {
override fun stopProvideLocation() {}
override fun setDeviceAddress(address: String) {}
override fun setDeviceAddress(address: String) {
lastSetDeviceAddress = address
}
// --- Helper methods for testing ---

View file

@ -0,0 +1,105 @@
/*
* 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.testing
import co.touchlab.kermit.Severity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.MeshPacket
@Suppress("TooManyFunctions")
class FakeServiceRepository : ServiceRepository {
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState
override fun setConnectionState(connectionState: ConnectionState) {
_connectionState.value = connectionState
}
private val _clientNotification = MutableStateFlow<ClientNotification?>(null)
override val clientNotification: StateFlow<ClientNotification?> = _clientNotification
override fun setClientNotification(notification: ClientNotification?) {
_clientNotification.value = notification
}
override fun clearClientNotification() {
_clientNotification.value = null
}
private val _errorMessage = MutableStateFlow<String?>(null)
override val errorMessage: StateFlow<String?> = _errorMessage
override fun setErrorMessage(text: String, severity: Severity) {
_errorMessage.value = text
}
override fun clearErrorMessage() {
_errorMessage.value = null
}
private val _connectionProgress = MutableStateFlow<String?>(null)
override val connectionProgress: StateFlow<String?> = _connectionProgress
override fun setConnectionProgress(text: String) {
_connectionProgress.value = text
}
private val _meshPacketFlow = MutableSharedFlow<MeshPacket>()
override val meshPacketFlow: SharedFlow<MeshPacket> = _meshPacketFlow
override suspend fun emitMeshPacket(packet: MeshPacket) {
_meshPacketFlow.emit(packet)
}
private val _tracerouteResponse = MutableStateFlow<TracerouteResponse?>(null)
override val tracerouteResponse: StateFlow<TracerouteResponse?> = _tracerouteResponse
override fun setTracerouteResponse(value: TracerouteResponse?) {
_tracerouteResponse.value = value
}
override fun clearTracerouteResponse() {
_tracerouteResponse.value = null
}
private val _neighborInfoResponse = MutableStateFlow<String?>(null)
override val neighborInfoResponse: StateFlow<String?> = _neighborInfoResponse
override fun setNeighborInfoResponse(value: String?) {
_neighborInfoResponse.value = value
}
override fun clearNeighborInfoResponse() {
_neighborInfoResponse.value = null
}
private val _serviceAction = MutableSharedFlow<ServiceAction>(replay = 1)
override val serviceAction: Flow<ServiceAction> = _serviceAction
override suspend fun onServiceAction(action: ServiceAction) {
_serviceAction.emit(action)
}
}

View file

@ -62,6 +62,7 @@ kotlin {
androidMain.dependencies { implementation(libs.androidx.activity.compose) }
commonTest.dependencies {
implementation(projects.core.testing)
implementation(libs.junit)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)

View file

@ -17,24 +17,15 @@
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.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeServiceRepository
import org.meshtastic.proto.SharedContact
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@ -47,13 +38,12 @@ 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)
private val nodeRepository = FakeNodeRepository()
private val serviceRepository = FakeServiceRepository()
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { nodeRepository.getNodes() } returns MutableStateFlow(emptyList())
viewModel = SharedContactViewModel(nodeRepository, serviceRepository)
}
@ -69,15 +59,12 @@ class SharedContactViewModelTest {
@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)
nodeRepository.setNodes(listOf(node))
assertEquals(listOf(node), awaitItem())
cancelAndIgnoreRemainingEvents()
}
@ -86,11 +73,11 @@ class SharedContactViewModelTest {
@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)) }
// You might want to verify the state on your FakeServiceRepository
// serviceRepository.serviceAction
}
}

View file

@ -28,10 +28,10 @@ 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.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeServiceRepository
import org.meshtastic.proto.LocalConfig
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@ -45,8 +45,8 @@ 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 serviceRepository = FakeServiceRepository()
private val nodeRepository = FakeNodeRepository()
private val uiPrefs: UiPrefs = mock(MockMode.autofill)
@BeforeTest
@ -54,10 +54,6 @@ class ConnectionsViewModelTest {
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 =