feat: introduce Desktop target and expand Kotlin Multiplatform (KMP) architecture (#4761)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-12 16:14:49 -05:00 committed by GitHub
parent f4364cff9a
commit ac6bb5479b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
386 changed files with 17089 additions and 4590 deletions

188
core/testing/README.md Normal file
View file

@ -0,0 +1,188 @@
/*
* 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/>.
*/
# `:core:testing` — Shared Test Doubles and Utilities
## Purpose
The `:core:testing` module provides lightweight, reusable test doubles (fakes, builders, factories) and testing utilities for **all** KMP modules. This module **consolidates testing dependencies** into a single, well-controlled location to:
- **Reduce duplication**: Shared fakes (e.g., `FakeNodeRepository`, `FakeRadioController`) used across multiple modules.
- **Keep dependency graph clean**: All test doubles and libraries are defined once; modules depend on `:core:testing` instead of scattered test deps.
- **Enable KMP-wide test patterns**: Every module (`commonTest`, `androidUnitTest`, JVM tests) can reuse the same fakes.
- **Maintain purity**: Core business logic modules (e.g., `core:domain`, `core:data`) depend on `:core:testing` via `commonTest`, avoiding test-code leakage into production.
## Dependency Strategy
```
┌─────────────────────────────────────┐
│ core:testing │
│ (only deps: core:model, │
│ core:repository, test libs) │
└──────────────┬──────────────────────┘
│ (commonTest dependency)
┌──────┴─────────────┬────────────────────┐
│ │ │
core:domain feature:messaging feature:node
core:data feature:settings feature:firmware
(etc.) (etc.)
```
### Key Design Rules
1. **`:core:testing` has NO dependencies on heavy modules**: It only depends on:
- `core:model` — Domain types (Node, User, etc.)
- `core:repository` — Interfaces (NodeRepository, etc.)
- Test libraries (`kotlin("test")`, `mockk`, `kotlinx.coroutines.test`, `turbine`, `junit`)
2. **No circular dependencies**: Modules that depend on `:core:testing` (in `commonTest`) cannot be dependencies of `:core:testing` itself.
3. **`:core:testing` is NOT part of the app bundle**: It's declared in `commonTest` sourceSet only, so it never appears in release APKs or final JARs.
## What's Included
### Test Doubles (Fakes)
#### `FakeRadioController`
A no-op implementation of `RadioController` for unit tests. Tracks method calls and state changes.
```kotlin
val radioController = FakeRadioController()
radioController.setConnectionState(ConnectionState.Connected)
assertEquals(1, radioController.sentPackets.size)
```
#### `FakeNodeRepository`
An in-memory implementation of `NodeRepository` for isolated testing.
```kotlin
val nodeRepo = FakeNodeRepository()
nodeRepo.setNodes(TestDataFactory.createTestNodes(5))
assertEquals(5, nodeRepo.nodeDBbyNum.value.size)
```
### Test Builders & Factories
#### `TestDataFactory`
Factory methods for creating domain objects with sensible defaults.
```kotlin
val node = TestDataFactory.createTestNode(num = 42, longName = "Alice")
val nodes = TestDataFactory.createTestNodes(10)
```
### Test Utilities
#### Flow collection helper
```kotlin
val emissions = flow { emit(1); emit(2) }.toList()
assertEquals(listOf(1, 2), emissions)
```
## Usage Examples
### Testing a ViewModel (in `feature:messaging/src/commonTest`)
```kotlin
class MessageViewModelTest {
private val nodeRepository = FakeNodeRepository()
@Test
fun testLoadsNodesCorrectly() = runTest {
nodeRepository.setNodes(TestDataFactory.createTestNodes(3))
val viewModel = createViewModel(nodeRepository)
assertEquals(3, viewModel.nodeCount.value)
}
}
```
### Testing a UseCase (in `core:domain/src/commonTest`)
```kotlin
class SendMessageUseCaseTest {
private val radioController = FakeRadioController()
@Test
fun testSendsPacket() = runTest {
val useCase = SendMessageUseCase(radioController)
useCase.sendMessage(testPacket)
assertEquals(1, radioController.sentPackets.size)
}
}
```
## Adding New Test Doubles
When adding a new fake to `:core:testing`:
1. **Implement the interface** from `core:model` or `core:repository`.
2. **Track side effects** (e.g., `sentPackets`, `calledMethods`) for test assertions.
3. **Provide test helpers** (e.g., `setNodes()`, `clear()`) to manipulate state.
4. **Document with examples** in the class KDoc.
Example:
```kotlin
/**
* A test double for [SomeRepository].
*/
class FakeSomeRepository : SomeRepository {
val callHistory = mutableListOf<String>()
override suspend fun doSomething(value: String) {
callHistory.add(value)
}
// Test helpers
fun getCallCount() = callHistory.size
fun clear() = callHistory.clear()
}
```
## Dependency Maintenance
### When adding a new module:
- If it has `commonTest` tests, add `implementation(projects.core.testing)` to its `commonTest.dependencies`.
- Do NOT add heavy modules (e.g., `core:database`) to `:core:testing`'s dependencies.
### When a test needs a mock:
- Check `:core:testing` first for an existing fake.
- If none exists, consider adding it there (if it's reusable) vs. using `mockk()` inline.
### When updating interfaces:
- Update corresponding fakes in `:core:testing` to match new method signatures.
- Keep fakes no-op; don't replicate business logic.
## Files
```
core/testing/
├── build.gradle.kts # Lightweight, minimal dependencies
├── README.md # This file
└── src/commonMain/kotlin/org/meshtastic/core/testing/
├── FakeRadioController.kt # RadioController test double
├── FakeNodeRepository.kt # NodeRepository test double
└── TestDataFactory.kt # Builders and factories
```
## See Also
- `AGENTS.md` §3B: KMP platform purity guidelines (relevant for test code).
- `docs/kmp-status.md`: KMP module status and targets.
- `.github/copilot-instructions.md`: Build and test commands.

View file

@ -0,0 +1,45 @@
/*
* 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/>.
*/
plugins { alias(libs.plugins.meshtastic.kmp.library) }
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.testing"
androidResources.enable = false
}
sourceSets {
commonMain.dependencies {
// Core KMP models and contracts for creating test fakes
// NOTE: Only api() core:model and core:repository to keep dependency graph clean.
// Heavy modules (database, data, domain) should depend on core:testing, not vice versa.
api(projects.core.model)
api(projects.core.repository)
// Testing libraries - these are public API for all test consumers
api(kotlin("test"))
api(libs.mockk)
api(libs.kotlinx.coroutines.test)
api(libs.turbine)
api(libs.junit)
}
}
}

View file

@ -0,0 +1,93 @@
/*
* 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.core.testing
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import org.meshtastic.core.model.DataPacket
/**
* A test double for message/packet repository operations.
*
* Tracks sent packets and provides test helpers for messaging scenarios.
*/
class FakePacketRepository {
val sentPackets = mutableListOf<DataPacket>()
private val _packetsFlow = MutableStateFlow<List<DataPacket>>(emptyList())
val packetsFlow: Flow<List<DataPacket>> = _packetsFlow
suspend fun sendPacket(packet: DataPacket) {
sentPackets.add(packet)
_packetsFlow.value = sentPackets.toList()
}
fun getPacketCount() = sentPackets.size
fun clear() {
sentPackets.clear()
_packetsFlow.value = emptyList()
}
}
/**
* A test double for contact management operations.
*
* Maintains a list of contacts and provides helpers for contact-related tests.
*/
class FakeContactRepository {
data class Contact(val userId: String, val name: String, val lastMessageTime: Long = 0)
private val contacts = mutableMapOf<String, Contact>()
private val _contactsFlow = MutableStateFlow<List<Contact>>(emptyList())
val contactsFlow: Flow<List<Contact>> = _contactsFlow
suspend fun addContact(contact: Contact) {
contacts[contact.userId] = contact
_contactsFlow.value = contacts.values.toList()
}
suspend fun removeContact(userId: String) {
contacts.remove(userId)
_contactsFlow.value = contacts.values.toList()
}
suspend fun getContact(userId: String): Contact? = contacts[userId]
suspend fun updateContactLastMessage(userId: String, time: Long) {
contacts[userId]?.let { existing ->
contacts[userId] = existing.copy(lastMessageTime = time)
_contactsFlow.value = contacts.values.toList()
}
}
fun getContactCount() = contacts.size
fun getAllContacts() = contacts.values.toList()
fun clear() {
contacts.clear()
_contactsFlow.value = emptyList()
}
}
/** Test helper for creating test contact objects. */
fun createTestContact(
userId: String = "!test001",
name: String = "Test Contact",
lastMessageTime: Long = 0,
): FakeContactRepository.Contact =
FakeContactRepository.Contact(userId = userId, name = name, lastMessageTime = lastMessageTime)

View file

@ -0,0 +1,137 @@
/*
* 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.core.testing
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.User
/**
* A test double for [NodeRepository] that provides an in-memory implementation.
*
* Tracks node operations and exposes mutable state for assertions in tests.
*
* Example:
* ```kotlin
* val nodeRepository = FakeNodeRepository()
* nodeRepository.setNodes(TestDataFactory.createTestNodes(3))
* assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
* ```
*/
@Suppress("TooManyFunctions")
class FakeNodeRepository : NodeRepository {
private val _myNodeInfo = MutableStateFlow<MyNodeInfo?>(null)
override val myNodeInfo: StateFlow<MyNodeInfo?> = _myNodeInfo
private val _ourNodeInfo = MutableStateFlow<Node?>(null)
override val ourNodeInfo: StateFlow<Node?> = _ourNodeInfo
private val _myId = MutableStateFlow<String?>(null)
override val myId: StateFlow<String?> = _myId
private val _localStats = MutableStateFlow(LocalStats())
override val localStats: StateFlow<LocalStats> = _localStats
private val _nodeDBbyNum = MutableStateFlow<Map<Int, Node>>(emptyMap())
override val nodeDBbyNum: StateFlow<Map<Int, Node>> = _nodeDBbyNum
override val onlineNodeCount: Flow<Int> = _nodeDBbyNum.map { it.size }
override val totalNodeCount: Flow<Int> = _nodeDBbyNum.map { it.size }
override fun updateLocalStats(stats: LocalStats) {
_localStats.value = stats
}
override fun effectiveLogNodeId(nodeNum: Int): Flow<Int> = MutableStateFlow(0)
override fun getNode(userId: String): Node =
_nodeDBbyNum.value.values.find { it.user.id == userId } ?: Node(num = 0, user = User(id = userId))
override fun getUser(nodeNum: Int): User = _nodeDBbyNum.value[nodeNum]?.user ?: User()
override fun getUser(userId: String): User = _nodeDBbyNum.value.values.find { it.user.id == userId }?.user ?: User()
override fun getNodes(
sort: NodeSortOption,
filter: String,
includeUnknown: Boolean,
onlyOnline: Boolean,
onlyDirect: Boolean,
): Flow<List<Node>> = _nodeDBbyNum.map { db ->
db.values
.toList()
.let { nodes -> if (filter.isBlank()) nodes else nodes.filter { it.user.long_name.contains(filter) } }
.sortedBy { it.num }
}
override suspend fun getNodesOlderThan(lastHeard: Int): List<Node> =
_nodeDBbyNum.value.values.filter { it.lastHeard < lastHeard }
override suspend fun getUnknownNodes(): List<Node> = emptyList()
override suspend fun clearNodeDB(preserveFavorites: Boolean) {
_nodeDBbyNum.value = emptyMap()
}
override suspend fun clearMyNodeInfo() {
_myNodeInfo.value = null
}
override suspend fun deleteNode(num: Int) {
_nodeDBbyNum.value = _nodeDBbyNum.value - num
}
override suspend fun deleteNodes(nodeNums: List<Int>) {
_nodeDBbyNum.value = _nodeDBbyNum.value - nodeNums.toSet()
}
override suspend fun setNodeNotes(num: Int, notes: String) = Unit
override suspend fun upsert(node: Node) {
_nodeDBbyNum.value = _nodeDBbyNum.value + (node.num to node)
}
override suspend fun installConfig(mi: MyNodeInfo, nodes: List<Node>) {
_myNodeInfo.value = mi
_nodeDBbyNum.value = nodes.associateBy { it.num }
}
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) = Unit
// --- Helper methods for testing ---
fun setNodes(nodes: List<Node>) {
_nodeDBbyNum.value = nodes.associateBy { it.num }
}
fun setMyId(id: String) {
_myId.value = id
}
fun setOurNode(node: Node?) {
_ourNodeInfo.value = node
}
}

View file

@ -0,0 +1,144 @@
/*
* 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.core.testing
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.proto.ClientNotification
/**
* A test double for [RadioController] that provides a no-op implementation and tracks calls for assertions in tests.
*
* Use this in place of mocking the entire RadioController interface when you need fine-grained control over connection
* state and packet tracking.
*
* Example:
* ```kotlin
* val radioController = FakeRadioController()
* radioController.setConnectionState(ConnectionState.Connected)
* // ... perform test ...
* assertEquals(1, radioController.sentPackets.size)
* ```
*/
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
class FakeRadioController : RadioController {
// Mutable state flows so we can manipulate them in our tests
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Connected)
override val connectionState: StateFlow<ConnectionState> = _connectionState
private val _clientNotification = MutableStateFlow<ClientNotification?>(null)
override val clientNotification: StateFlow<ClientNotification?> = _clientNotification
// Track sent packets to assert in tests
val sentPackets = mutableListOf<DataPacket>()
val favoritedNodes = mutableListOf<Int>()
val sentSharedContacts = mutableListOf<Int>()
override suspend fun sendMessage(packet: DataPacket) {
sentPackets.add(packet)
}
override fun clearClientNotification() {
_clientNotification.value = null
}
override suspend fun favoriteNode(nodeNum: Int) {
favoritedNodes.add(nodeNum)
}
override suspend fun sendSharedContact(nodeNum: Int) {
sentSharedContacts.add(nodeNum)
}
override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {}
override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) {}
override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {}
override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {}
override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) {}
override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) {}
override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) {}
override suspend fun setRingtone(destNum: Int, ringtone: String) {}
override suspend fun setCannedMessages(destNum: Int, messages: String) {}
override suspend fun getOwner(destNum: Int, packetId: Int) {}
override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {}
override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {}
override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {}
override suspend fun getRingtone(destNum: Int, packetId: Int) {}
override suspend fun getCannedMessages(destNum: Int, packetId: Int) {}
override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {}
override suspend fun reboot(destNum: Int, packetId: Int) {}
override suspend fun rebootToDfu(nodeNum: Int) {}
override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
override suspend fun shutdown(destNum: Int, packetId: Int) {}
override suspend fun factoryReset(destNum: Int, packetId: Int) {}
override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {}
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {}
override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) {}
override suspend fun requestUserInfo(destNum: Int) {}
override suspend fun requestTraceroute(requestId: Int, destNum: Int) {}
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {}
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {}
override suspend fun beginEditSettings(destNum: Int) {}
override suspend fun commitEditSettings(destNum: Int) {}
override fun getPacketId(): Int = 1
override fun startProvideLocation() {}
override fun stopProvideLocation() {}
override fun setDeviceAddress(address: String) {}
// --- Helper methods for testing ---
fun setConnectionState(state: ConnectionState) {
_connectionState.value = state
}
}

View file

@ -0,0 +1,84 @@
/*
* 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 org.meshtastic.core.model.Node
import org.meshtastic.proto.User
/**
* Factory for creating test domain objects.
*
* Provides sensible defaults that can be overridden for specific test needs.
*/
@Suppress("MagicNumber") // test data padding
object TestDataFactory {
/**
* Creates a test [Node] with default values.
*
* @param num Node number (default: 1)
* @param userId User ID in hex format (default: "!test0001")
* @param longName User long name (default: "Test User")
* @param shortName User short name (default: "T")
* @param lastHeard Last heard timestamp in seconds (default: 0)
* @return A Node instance with provided or default values
*/
fun createTestNode(
num: Int = 1,
userId: String = "!test0001",
longName: String = "Test User",
shortName: String = "T",
lastHeard: Int = 0,
): Node {
val user = User(id = userId, long_name = longName, short_name = shortName)
return Node(num = num, user = user, lastHeard = lastHeard, snr = 0f, rssi = 0, channel = 0)
}
/**
* Creates multiple test nodes with sequential IDs.
*
* @param count Number of nodes to create
* @param baseNum Starting node number (default: 1)
* @return A list of Node instances
*/
fun createTestNodes(count: Int, baseNum: Int = 1): List<Node> = (0 until count).map { i ->
createTestNode(
num = baseNum + i,
userId = "!test${(baseNum + i).toString().padStart(4, '0')}",
longName = "Test User $i",
shortName = "T$i",
)
}
}
/**
* Collects all emissions from a Flow into a list.
*
* Useful for asserting on Flow values in tests.
*
* Example:
* ```kotlin
* val values = flow { emit(1); emit(2) }.toList()
* assertEquals(listOf(1, 2), values)
* ```
*/
suspend inline fun <T> Flow<T>.toList(): List<T> {
val result = mutableListOf<T>()
collect { result.add(it) }
return result
}