Refactor map layer management and navigation infrastructure (#4921)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-25 19:29:24 -05:00 committed by GitHub
parent b608a04ca4
commit a005231d94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 5408 additions and 3090 deletions

View file

@ -0,0 +1,26 @@
/*
* 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 org.meshtastic.core.repository.Location
/** Creates an Android [Location] for testing. */
actual fun createLocation(latitude: Double, longitude: Double, altitude: Double): Location = Location("fake").apply {
this.latitude = latitude
this.longitude = longitude
this.altitude = altitude
}

View file

@ -0,0 +1,24 @@
/*
* 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 androidx.test.core.app.ApplicationProvider
import org.meshtastic.core.common.ContextServices
actual fun setupTestContext() {
ContextServices.app = ApplicationProvider.getApplicationContext()
}

View file

@ -0,0 +1,49 @@
/*
* 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.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
/** Base class for fakes that provides common utilities for state management and reset capabilities. */
abstract class BaseFake {
private val resetActions = mutableListOf<() -> Unit>()
/** Creates a [MutableStateFlow] and registers it for automatic reset. */
protected fun <T> mutableStateFlow(initialValue: T): MutableStateFlow<T> {
val flow = MutableStateFlow(initialValue)
resetActions.add { flow.value = initialValue }
return flow
}
/** Creates a [MutableSharedFlow] and registers it for automatic reset (replay cache cleared). */
protected fun <T> mutableSharedFlow(replay: Int = 0): MutableSharedFlow<T> {
val flow = MutableSharedFlow<T>(replay = replay)
resetActions.add { flow.resetReplayCache() }
return flow
}
/** Registers a custom reset action (e.g. clearing a list of recorded calls). */
protected fun registerResetAction(action: () -> Unit) {
resetActions.add(action)
}
/** Resets all registered state flows and custom actions to their initial state. */
open fun reset() {
resetActions.forEach { it() }
}
}

View file

@ -0,0 +1,265 @@
/*
* 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 kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.AppPreferences
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.MapTileProviderPrefs
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.UiPrefs
class FakeAnalyticsPrefs : AnalyticsPrefs {
override val analyticsAllowed = MutableStateFlow(false)
override fun setAnalyticsAllowed(allowed: Boolean) {
analyticsAllowed.value = allowed
}
override val installId = MutableStateFlow("fake-install-id")
}
class FakeHomoglyphPrefs : HomoglyphPrefs {
override val homoglyphEncodingEnabled = MutableStateFlow(false)
override fun setHomoglyphEncodingEnabled(enabled: Boolean) {
homoglyphEncodingEnabled.value = enabled
}
}
class FakeFilterPrefs : FilterPrefs {
override val filterEnabled = MutableStateFlow(false)
override fun setFilterEnabled(enabled: Boolean) {
filterEnabled.value = enabled
}
override val filterWords = MutableStateFlow(emptySet<String>())
override fun setFilterWords(words: Set<String>) {
filterWords.value = words
}
}
class FakeCustomEmojiPrefs : CustomEmojiPrefs {
override val customEmojiFrequency = MutableStateFlow<String?>(null)
override fun setCustomEmojiFrequency(frequency: String?) {
customEmojiFrequency.value = frequency
}
}
@Suppress("TooManyFunctions")
class FakeUiPrefs : UiPrefs {
override val appIntroCompleted = MutableStateFlow(false)
override fun setAppIntroCompleted(completed: Boolean) {
appIntroCompleted.value = completed
}
override val theme = MutableStateFlow(0)
override fun setTheme(value: Int) {
theme.value = value
}
override val locale = MutableStateFlow("en")
override fun setLocale(languageTag: String) {
locale.value = languageTag
}
override val nodeSort = MutableStateFlow(0)
override fun setNodeSort(value: Int) {
nodeSort.value = value
}
override val includeUnknown = MutableStateFlow(true)
override fun setIncludeUnknown(value: Boolean) {
includeUnknown.value = value
}
override val excludeInfrastructure = MutableStateFlow(false)
override fun setExcludeInfrastructure(value: Boolean) {
excludeInfrastructure.value = value
}
override val onlyOnline = MutableStateFlow(false)
override fun setOnlyOnline(value: Boolean) {
onlyOnline.value = value
}
override val onlyDirect = MutableStateFlow(false)
override fun setOnlyDirect(value: Boolean) {
onlyDirect.value = value
}
override val showIgnored = MutableStateFlow(false)
override fun setShowIgnored(value: Boolean) {
showIgnored.value = value
}
override val excludeMqtt = MutableStateFlow(false)
override fun setExcludeMqtt(value: Boolean) {
excludeMqtt.value = value
}
override val hasShownNotPairedWarning = MutableStateFlow(false)
override fun setHasShownNotPairedWarning(shown: Boolean) {
hasShownNotPairedWarning.value = shown
}
override val showQuickChat = MutableStateFlow(true)
override fun setShowQuickChat(show: Boolean) {
showQuickChat.value = show
}
private val nodeLocationEnabled = mutableMapOf<Int, MutableStateFlow<Boolean>>()
override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean> =
nodeLocationEnabled.getOrPut(nodeNum) { MutableStateFlow(true) }
override fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean) {
nodeLocationEnabled.getOrPut(nodeNum) { MutableStateFlow(provide) }.value = provide
}
}
class FakeMapPrefs : MapPrefs {
override val mapStyle = MutableStateFlow(0)
override fun setMapStyle(style: Int) {
mapStyle.value = style
}
override val showOnlyFavorites = MutableStateFlow(false)
override fun setShowOnlyFavorites(show: Boolean) {
showOnlyFavorites.value = show
}
override val showWaypointsOnMap = MutableStateFlow(true)
override fun setShowWaypointsOnMap(show: Boolean) {
showWaypointsOnMap.value = show
}
override val showPrecisionCircleOnMap = MutableStateFlow(true)
override fun setShowPrecisionCircleOnMap(show: Boolean) {
showPrecisionCircleOnMap.value = show
}
override val lastHeardFilter = MutableStateFlow(0L)
override fun setLastHeardFilter(seconds: Long) {
lastHeardFilter.value = seconds
}
override val lastHeardTrackFilter = MutableStateFlow(0L)
override fun setLastHeardTrackFilter(seconds: Long) {
lastHeardTrackFilter.value = seconds
}
}
class FakeMapConsentPrefs : MapConsentPrefs {
private val consent = mutableMapOf<Int?, MutableStateFlow<Boolean>>()
override fun shouldReportLocation(nodeNum: Int?): StateFlow<Boolean> =
consent.getOrPut(nodeNum) { MutableStateFlow(false) }
override fun setShouldReportLocation(nodeNum: Int?, report: Boolean) {
consent.getOrPut(nodeNum) { MutableStateFlow(report) }.value = report
}
}
class FakeMapTileProviderPrefs : MapTileProviderPrefs {
override val customTileProviders = MutableStateFlow<String?>(null)
override fun setCustomTileProviders(providers: String?) {
customTileProviders.value = providers
}
}
class FakeRadioPrefs : RadioPrefs {
override val devAddr = MutableStateFlow<String?>(null)
override val devName = MutableStateFlow<String?>(null)
override fun setDevAddr(address: String?) {
devAddr.value = address
}
override fun setDevName(name: String?) {
devName.value = name
}
}
class FakeMeshPrefs : MeshPrefs {
override val deviceAddress = MutableStateFlow<String?>(null)
override fun setDeviceAddress(address: String?) {
deviceAddress.value = address
}
private val provideLocation = mutableMapOf<Int?, MutableStateFlow<Boolean>>()
override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow<Boolean> =
provideLocation.getOrPut(nodeNum) { MutableStateFlow(true) }
override fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) {
provideLocation.getOrPut(nodeNum) { MutableStateFlow(provide) }.value = provide
}
private val lastRequest = mutableMapOf<String?, MutableStateFlow<Int>>()
override fun getStoreForwardLastRequest(address: String?): StateFlow<Int> =
lastRequest.getOrPut(address) { MutableStateFlow(0) }
override fun setStoreForwardLastRequest(address: String?, timestamp: Int) {
lastRequest.getOrPut(address) { MutableStateFlow(timestamp) }.value = timestamp
}
}
class FakeAppPreferences : AppPreferences {
override val analytics = FakeAnalyticsPrefs()
override val homoglyph = FakeHomoglyphPrefs()
override val filter = FakeFilterPrefs()
override val meshLog = FakeMeshLogPrefs()
override val emoji = FakeCustomEmojiPrefs()
override val ui = FakeUiPrefs()
override val map = FakeMapPrefs()
override val mapConsent = FakeMapConsentPrefs()
override val mapTileProvider = FakeMapTileProviderPrefs()
override val radio = FakeRadioPrefs()
override val mesh = FakeMeshPrefs()
}

View file

@ -0,0 +1,173 @@
/*
* 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.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleService
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.ble.BluetoothState
import kotlin.time.Duration
import kotlin.uuid.Uuid
class FakeBleDevice(
override val address: String,
override val name: String? = "Fake Device",
initialState: BleConnectionState = BleConnectionState.Disconnected,
) : BaseFake(),
BleDevice {
private val _state = mutableStateFlow(initialState)
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
private val _isBonded = mutableStateFlow(false)
override val isBonded: Boolean
get() = _isBonded.value
override val isConnected: Boolean
get() = _state.value == BleConnectionState.Connected
override suspend fun readRssi(): Int = DEFAULT_RSSI
override suspend fun bond() {
_isBonded.value = true
}
fun setState(newState: BleConnectionState) {
_state.value = newState
}
companion object {
private const val DEFAULT_RSSI = -60
}
}
class FakeBleScanner :
BaseFake(),
BleScanner {
private val foundDevices = mutableSharedFlow<BleDevice>(replay = 10)
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> = flow {
emitAll(foundDevices)
}
fun emitDevice(device: BleDevice) {
foundDevices.tryEmit(device)
}
}
class FakeBleConnection :
BaseFake(),
BleConnection {
private val _device = mutableStateFlow<BleDevice?>(null)
override val device: BleDevice?
get() = _device.value
private val _deviceFlow = mutableSharedFlow<BleDevice?>(replay = 1)
override val deviceFlow: SharedFlow<BleDevice?> = _deviceFlow.asSharedFlow()
private val _connectionState = mutableSharedFlow<BleConnectionState>(replay = 1)
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
override suspend fun connect(device: BleDevice) {
_device.value = device
_deviceFlow.emit(device)
_connectionState.emit(BleConnectionState.Connecting)
if (device is FakeBleDevice) {
device.setState(BleConnectionState.Connecting)
}
_connectionState.emit(BleConnectionState.Connected)
if (device is FakeBleDevice) {
device.setState(BleConnectionState.Connected)
}
}
override suspend fun connectAndAwait(
device: BleDevice,
timeoutMs: Long,
onRegister: suspend () -> Unit,
): BleConnectionState {
connect(device)
onRegister()
return BleConnectionState.Connected
}
override suspend fun disconnect() {
val currentDevice = _device.value
_connectionState.emit(BleConnectionState.Disconnected)
if (currentDevice is FakeBleDevice) {
currentDevice.setState(BleConnectionState.Disconnected)
}
_device.value = null
_deviceFlow.emit(null)
}
override suspend fun <T> profile(
serviceUuid: Uuid,
timeout: Duration,
setup: suspend CoroutineScope.(BleService) -> T,
): T = CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined).setup(FakeBleService())
override fun maximumWriteValueLength(writeType: BleWriteType): Int = 512
}
class FakeBleService : BleService
class FakeBleConnectionFactory(private val fakeConnection: FakeBleConnection = FakeBleConnection()) :
BleConnectionFactory {
override fun create(scope: CoroutineScope, tag: String): BleConnection = fakeConnection
}
@Suppress("EmptyFunctionBlock")
class FakeBluetoothRepository :
BaseFake(),
BluetoothRepository {
private val _state = mutableStateFlow(BluetoothState(hasPermissions = true, enabled = true))
override val state: StateFlow<BluetoothState> = _state.asStateFlow()
override fun refreshState() {}
override fun isValid(bleAddress: String): Boolean = bleAddress.isNotBlank()
override fun isBonded(address: String): Boolean = _state.value.bondedDevices.any { it.address == address }
override suspend fun bond(device: BleDevice) {
val currentState = _state.value
if (!currentState.bondedDevices.contains(device)) {
_state.value = currentState.copy(bondedDevices = currentState.bondedDevices + device)
}
}
fun setBluetoothEnabled(enabled: Boolean) {
_state.value = _state.value.copy(enabled = enabled)
}
fun setHasPermissions(hasPermissions: Boolean) {
_state.value = _state.value.copy(hasPermissions = hasPermissions)
}
}

View file

@ -0,0 +1,55 @@
/*
* 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.StateFlow
import org.meshtastic.core.common.database.DatabaseManager
/** A test double for [DatabaseManager] that provides a simple implementation and tracks calls. */
class FakeDatabaseManager :
BaseFake(),
DatabaseManager {
private val _cacheLimit = mutableStateFlow(DEFAULT_CACHE_LIMIT)
override val cacheLimit: StateFlow<Int> = _cacheLimit
var lastSwitchedAddress: String? = null
val existingDatabases = mutableSetOf<String>()
init {
registerResetAction {
_cacheLimit.value = DEFAULT_CACHE_LIMIT
lastSwitchedAddress = null
existingDatabases.clear()
}
}
override fun getCurrentCacheLimit(): Int = _cacheLimit.value
override fun setCacheLimit(limit: Int) {
_cacheLimit.value = limit
}
override suspend fun switchActiveDatabase(address: String?) {
lastSwitchedAddress = address
}
override fun hasDatabaseFor(address: String?): Boolean = address != null && existingDatabases.contains(address)
companion object {
private const val DEFAULT_CACHE_LIMIT = 100
}
}

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 kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.getInMemoryDatabaseBuilder
/** A real [DatabaseProvider] that uses an in-memory database for testing. */
class FakeDatabaseProvider : DatabaseProvider {
private val db: MeshtasticDatabase = getInMemoryDatabaseBuilder().build()
private val _currentDb = MutableStateFlow(db)
override val currentDb: StateFlow<MeshtasticDatabase> = _currentDb
override suspend fun <T> withDb(block: suspend (MeshtasticDatabase) -> T): T? = block(db)
fun close() {
db.close()
}
}

View file

@ -0,0 +1,37 @@
/*
* 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.StateFlow
import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.proto.LocalStats
/** A test double for [LocalStatsDataSource] that provides an in-memory implementation. */
class FakeLocalStatsDataSource :
BaseFake(),
LocalStatsDataSource {
private val _localStatsFlow = mutableStateFlow(LocalStats())
override val localStatsFlow: StateFlow<LocalStats> = _localStatsFlow
override suspend fun setLocalStats(stats: LocalStats) {
_localStatsFlow.value = stats
}
override suspend fun clearLocalStats() {
_localStatsFlow.value = LocalStats()
}
}

View file

@ -0,0 +1,45 @@
/*
* 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.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.repository.Location
import org.meshtastic.core.repository.LocationRepository
/** A test double for [LocationRepository] that provides a manual location emission mechanism. */
class FakeLocationRepository : LocationRepository {
private val _receivingLocationUpdates = MutableStateFlow(false)
override val receivingLocationUpdates: StateFlow<Boolean> = _receivingLocationUpdates
private val _locations = MutableSharedFlow<Location>(replay = 1)
override fun getLocations(): Flow<Location> = _locations
fun setReceivingLocationUpdates(receiving: Boolean) {
_receivingLocationUpdates.value = receiving
}
suspend fun emitLocation(location: Location) {
_locations.emit(location)
}
}
/** Platform-specific factory for creating [Location] objects in tests. */
expect fun createLocation(latitude: Double, longitude: Double, altitude: Double = 0.0): Location

View file

@ -16,18 +16,19 @@
*/
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)
class FakeMeshLogPrefs :
BaseFake(),
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)
private val _loggingEnabled = mutableStateFlow(true)
override val loggingEnabled = _loggingEnabled
override fun setLoggingEnabled(enabled: Boolean) {

View file

@ -26,13 +26,26 @@ import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
/** A test double for [MeshLogRepository] that provides in-memory log storage. */
@Suppress("TooManyFunctions")
class FakeMeshLogRepository : MeshLogRepository {
private val logsFlow = MutableStateFlow<List<MeshLog>>(emptyList())
class FakeMeshLogRepository :
BaseFake(),
MeshLogRepository {
private val logsFlow = mutableStateFlow<List<MeshLog>>(emptyList())
val currentLogs: List<MeshLog>
get() = logsFlow.value
var deleteLogsOlderThanCalledDays: Int? = null
var lastDeletedOlderThan: Int? = null
private set
var deleteAllCalled = false
private set
override fun reset() {
super.reset()
lastDeletedOlderThan = null
deleteAllCalled = false
}
override fun getAllLogs(maxItem: Int): Flow<List<MeshLog>> = logsFlow.map { it.take(maxItem) }
@ -59,6 +72,7 @@ class FakeMeshLogRepository : MeshLogRepository {
override suspend fun deleteAll() {
logsFlow.value = emptyList()
deleteAllCalled = true
}
override suspend fun deleteLog(uuid: String) {
@ -70,7 +84,7 @@ class FakeMeshLogRepository : MeshLogRepository {
}
override suspend fun deleteLogsOlderThan(retentionDays: Int) {
deleteLogsOlderThanCalledDays = retentionDays
lastDeletedOlderThan = retentionDays
}
fun setLogs(logs: List<MeshLog>) {

View file

@ -0,0 +1,38 @@
/*
* 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
/**
* A container for all mesh-related fakes to simplify test setup.
*
* Instead of manually instantiating and wiring multiple fakes, you can use [FakeMeshService] to get a consistent set of
* test doubles.
*/
class FakeMeshService {
val nodeRepository = FakeNodeRepository()
val serviceRepository = FakeServiceRepository()
val radioController = FakeRadioController()
val radioInterfaceService = FakeRadioInterfaceService()
val notifications = FakeMeshServiceNotifications()
val transport = FakeRadioTransport()
val logRepository = FakeMeshLogRepository()
val packetRepository = FakePacketRepository()
val contactRepository = FakeContactRepository()
val locationRepository = FakeLocationRepository()
// Add more as they are implemented
}

View file

@ -0,0 +1,75 @@
/*
* 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 org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
/** A test double for [MeshServiceNotifications] that provides a no-op implementation. */
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun clearNotifications() {}
override fun initChannels() {}
override fun updateServiceStateNotification(
state: org.meshtastic.core.model.ConnectionState,
telemetry: Telemetry?,
): Any = Any()
override suspend fun updateMessageNotification(
contactKey: String,
name: String,
message: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
) {}
override suspend fun updateWaypointNotification(
contactKey: String,
name: String,
message: String,
waypointId: Int,
isSilent: Boolean,
) {}
override suspend fun updateReactionNotification(
contactKey: String,
name: String,
emoji: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
) {}
override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
override fun showNewNodeSeenNotification(node: Node) {}
override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
override fun showClientNotification(clientNotification: ClientNotification) {}
override fun cancelMessageNotification(contactKey: String) {}
override fun cancelLowBatteryNotification(node: Node) {}
override fun clearClientNotification(notification: ClientNotification) {}
}

View file

@ -41,21 +41,23 @@ import org.meshtastic.proto.User
* ```
*/
@Suppress("TooManyFunctions")
class FakeNodeRepository : NodeRepository {
class FakeNodeRepository :
BaseFake(),
NodeRepository {
private val _myNodeInfo = MutableStateFlow<MyNodeInfo?>(null)
private val _myNodeInfo = mutableStateFlow<MyNodeInfo?>(null)
override val myNodeInfo: StateFlow<MyNodeInfo?> = _myNodeInfo
private val _ourNodeInfo = MutableStateFlow<Node?>(null)
private val _ourNodeInfo = mutableStateFlow<Node?>(null)
override val ourNodeInfo: StateFlow<Node?> = _ourNodeInfo
private val _myId = MutableStateFlow<String?>(null)
private val _myId = mutableStateFlow<String?>(null)
override val myId: StateFlow<String?> = _myId
private val _localStats = MutableStateFlow(LocalStats())
private val _localStats = mutableStateFlow(LocalStats())
override val localStats: StateFlow<LocalStats> = _localStats
private val _nodeDBbyNum = MutableStateFlow<Map<Int, Node>>(emptyMap())
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 }
@ -82,18 +84,51 @@ class FakeNodeRepository : NodeRepository {
onlyDirect: Boolean,
): Flow<List<Node>> = _nodeDBbyNum.map { db ->
db.values
.asSequence()
.filter { filterNode(it, filter, includeUnknown, onlyOnline, onlyDirect) }
.toList()
.let { nodes -> if (filter.isBlank()) nodes else nodes.filter { it.user.long_name.contains(filter) } }
.sortedBy { it.num }
.let { nodes ->
when (sort) {
NodeSortOption.ALPHABETICAL -> nodes.sortedBy { it.user.long_name.lowercase() }
NodeSortOption.LAST_HEARD -> nodes.sortedByDescending { it.lastHeard }
NodeSortOption.DISTANCE -> nodes.sortedBy { it.position.latitude_i } // Simplified
NodeSortOption.HOPS_AWAY -> nodes.sortedBy { it.hopsAway }
NodeSortOption.CHANNEL -> nodes.sortedBy { it.channel }
NodeSortOption.VIA_MQTT -> nodes.sortedBy { if (it.viaMqtt) 0 else 1 }
NodeSortOption.VIA_FAVORITE -> nodes.sortedBy { if (it.isFavorite) 0 else 1 }
}
}
}
private fun filterNode(
node: Node,
filter: String,
includeUnknown: Boolean,
onlyOnline: Boolean,
onlyDirect: Boolean,
): Boolean {
val matchesFilter =
filter.isBlank() ||
node.user.long_name.contains(filter, ignoreCase = true) ||
node.user.id.contains(filter, ignoreCase = true)
val matchesUnknown = includeUnknown || !node.isUnknownUser
val matchesOnline = !onlyOnline || node.isOnline
val matchesDirect = !onlyDirect || node.hopsAway == 0
return matchesFilter && matchesUnknown && matchesOnline && matchesDirect
}
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 getUnknownNodes(): List<Node> = _nodeDBbyNum.value.values.filter { it.isUnknownUser }
override suspend fun clearNodeDB(preserveFavorites: Boolean) {
_nodeDBbyNum.value = emptyMap()
if (preserveFavorites) {
_nodeDBbyNum.value = _nodeDBbyNum.value.filter { it.value.isFavorite }
} else {
_nodeDBbyNum.value = emptyMap()
}
}
override suspend fun clearMyNodeInfo() {
@ -108,7 +143,10 @@ class FakeNodeRepository : NodeRepository {
_nodeDBbyNum.value = _nodeDBbyNum.value - nodeNums.toSet()
}
override suspend fun setNodeNotes(num: Int, notes: String) = Unit
override suspend fun setNodeNotes(num: Int, notes: String) {
val node = _nodeDBbyNum.value[num] ?: return
_nodeDBbyNum.value = _nodeDBbyNum.value + (num to node.copy(notes = notes))
}
override suspend fun upsert(node: Node) {
_nodeDBbyNum.value = _nodeDBbyNum.value + (node.num to node)
@ -119,7 +157,10 @@ class FakeNodeRepository : NodeRepository {
_nodeDBbyNum.value = nodes.associateBy { it.num }
}
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) = Unit
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {
val node = _nodeDBbyNum.value[nodeNum] ?: return
_nodeDBbyNum.value = _nodeDBbyNum.value + (nodeNum to node.copy(metadata = metadata))
}
// --- Helper methods for testing ---
@ -134,4 +175,8 @@ class FakeNodeRepository : NodeRepository {
fun setOurNode(node: Node?) {
_ourNodeInfo.value = node
}
fun setMyNodeInfo(info: MyNodeInfo?) {
_myNodeInfo.value = info
}
}

View file

@ -0,0 +1,40 @@
/*
* 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.NotificationPrefs
class FakeNotificationPrefs : NotificationPrefs {
override val messagesEnabled = MutableStateFlow(true)
override fun setMessagesEnabled(enabled: Boolean) {
messagesEnabled.value = enabled
}
override val nodeEventsEnabled = MutableStateFlow(true)
override fun setNodeEventsEnabled(enabled: Boolean) {
nodeEventsEnabled.value = enabled
}
override val lowBatteryEnabled = MutableStateFlow(true)
override fun setLowBatteryEnabled(enabled: Boolean) {
lowBatteryEnabled.value = enabled
}
}

View file

@ -16,7 +16,6 @@
*/
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
@ -25,34 +24,41 @@ 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 {
class FakeRadioController :
BaseFake(),
RadioController {
// Mutable state flows so we can manipulate them in our tests
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Connected)
private val _connectionState = mutableStateFlow<ConnectionState>(ConnectionState.Connected)
override val connectionState: StateFlow<ConnectionState> = _connectionState
private val _clientNotification = MutableStateFlow<ClientNotification?>(null)
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>()
var throwOnSend: Boolean = false
var lastSetDeviceAddress: String? = null
var beginEditSettingsCalled = false
var commitEditSettingsCalled = false
var startProvideLocationCalled = false
var stopProvideLocationCalled = false
init {
registerResetAction {
sentPackets.clear()
favoritedNodes.clear()
sentSharedContacts.clear()
throwOnSend = false
lastSetDeviceAddress = null
beginEditSettingsCalled = false
commitEditSettingsCalled = false
startProvideLocationCalled = false
stopProvideLocationCalled = false
}
}
override suspend fun sendMessage(packet: DataPacket) {
if (throwOnSend) error("Fake send failure")
@ -127,15 +133,23 @@ class FakeRadioController : RadioController {
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {}
override suspend fun beginEditSettings(destNum: Int) {}
override suspend fun beginEditSettings(destNum: Int) {
beginEditSettingsCalled = true
}
override suspend fun commitEditSettings(destNum: Int) {}
override suspend fun commitEditSettings(destNum: Int) {
commitEditSettingsCalled = true
}
override fun getPacketId(): Int = 1
override fun startProvideLocation() {}
override fun startProvideLocation() {
startProvideLocationCalled = true
}
override fun stopProvideLocation() {}
override fun stopProvideLocation() {
stopProvideLocationCalled = true
}
override fun setDeviceAddress(address: String) {
lastSetDeviceAddress = address

View file

@ -0,0 +1,92 @@
/*
* 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.CoroutineScope
import kotlinx.coroutines.MainScope
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.DeviceType
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.repository.RadioInterfaceService
/** A test double for [RadioInterfaceService] that provides an in-memory implementation. */
@Suppress("TooManyFunctions")
class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = MainScope()) : RadioInterfaceService {
override val supportedDeviceTypes: List<DeviceType> = emptyList()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState
private val _currentDeviceAddressFlow = MutableStateFlow<String?>(null)
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow
private val _receivedData = MutableSharedFlow<ByteArray>()
override val receivedData: SharedFlow<ByteArray> = _receivedData
private val _meshActivity = MutableSharedFlow<MeshActivity>()
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity
val sentToRadio = mutableListOf<ByteArray>()
var connectCalled = false
override fun isMockInterface(): Boolean = true
override fun sendToRadio(bytes: ByteArray) {
sentToRadio.add(bytes)
}
override fun connect() {
connectCalled = true
}
override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value
override fun setDeviceAddress(deviceAddr: String?): Boolean {
_currentDeviceAddressFlow.value = deviceAddr
return true
}
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "$interfaceId:$rest"
override fun onConnect() {
_connectionState.value = ConnectionState.Connected
}
override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
_connectionState.value = ConnectionState.Disconnected
}
override fun handleFromRadio(bytes: ByteArray) {
// In a real implementation, this would emit to receivedData
}
// --- Helper methods for testing ---
suspend fun emitFromRadio(bytes: ByteArray) {
_receivedData.emit(bytes)
}
fun setConnectionState(state: ConnectionState) {
_connectionState.value = state
}
}

View file

@ -0,0 +1,38 @@
/*
* 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 org.meshtastic.core.repository.RadioTransport
/** A test double for [RadioTransport] that tracks sent data. */
class FakeRadioTransport : RadioTransport {
val sentData = mutableListOf<ByteArray>()
var closeCalled = false
var keepAliveCalled = false
override fun handleSendToRadio(p: ByteArray) {
sentData.add(p)
}
override fun keepAlive() {
keepAliveCalled = true
}
override fun close() {
closeCalled = true
}
}

View file

@ -17,6 +17,7 @@
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.proto.User
@ -36,6 +37,7 @@ object TestDataFactory {
* @param longName User long name (default: "Test User")
* @param shortName User short name (default: "T")
* @param lastHeard Last heard timestamp in seconds (default: 0)
* @param hwModel Hardware model (default: UNSET)
* @return A Node instance with provided or default values
*/
fun createTestNode(
@ -44,18 +46,31 @@ object TestDataFactory {
longName: String = "Test User",
shortName: String = "T",
lastHeard: Int = 0,
hwModel: org.meshtastic.proto.HardwareModel = org.meshtastic.proto.HardwareModel.UNSET,
batteryLevel: Int? = 100,
): 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)
val user = User(id = userId, long_name = longName, short_name = shortName, hw_model = hwModel)
val metrics = org.meshtastic.proto.DeviceMetrics(battery_level = batteryLevel)
return Node(
num = num,
user = user,
lastHeard = lastHeard,
snr = 0f,
rssi = 0,
channel = 0,
deviceMetrics = metrics,
)
}
/**
* 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
*/
/** Creates a test [org.meshtastic.proto.MeshPacket] with default values. */
fun createTestPacket(
from: Int = 1,
to: Int = 0xffffffff.toInt(),
decoded: org.meshtastic.proto.Data? = null,
relayNode: Int = 0,
) = org.meshtastic.proto.MeshPacket(from = from, to = to, decoded = decoded, relay_node = relayNode)
/** Creates multiple test nodes with sequential IDs. */
fun createTestNodes(count: Int, baseNum: Int = 1): List<Node> = (0 until count).map { i ->
createTestNode(
num = baseNum + i,
@ -64,6 +79,32 @@ object TestDataFactory {
shortName = "T$i",
)
}
/** Creates a test [MyNodeInfo] with default values. */
fun createMyNodeInfo(
myNodeNum: Int = 1,
hasGPS: Boolean = false,
model: String? = "TBEAM",
firmwareVersion: String? = "2.5.0",
hasWifi: Boolean = false,
pioEnv: String? = null,
) = MyNodeInfo(
myNodeNum = myNodeNum,
hasGPS = hasGPS,
model = model,
firmwareVersion = firmwareVersion,
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 1L,
messageTimeoutMsec = 300000,
minAppVersion = 1,
maxChannels = 8,
hasWifi = hasWifi,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = "!$myNodeNum",
pioEnv = pioEnv,
)
}
/**

View file

@ -0,0 +1,20 @@
/*
* 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
/** Initializes platform-specific test context (e.g., Robolectric on Android). */
expect fun setupTestContext()

View file

@ -0,0 +1,140 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import app.cash.turbine.test
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FakeNodeRepositoryTest {
private val repository = FakeNodeRepository()
@Test
fun `getNodes sorting by name`() = runTest {
val nodes =
listOf(
Node(num = 1, user = User(long_name = "Charlie")),
Node(num = 2, user = User(long_name = "Alice")),
Node(num = 3, user = User(long_name = "Bob")),
)
repository.setNodes(nodes)
repository.getNodes(sort = NodeSortOption.ALPHABETICAL).test {
val result = awaitItem()
assertEquals("Alice", result[0].user.long_name)
assertEquals("Bob", result[1].user.long_name)
assertEquals("Charlie", result[2].user.long_name)
}
}
@Test
fun `getUnknownNodes returns nodes with UNSET hw_model`() = runTest {
val node1 = Node(num = 1, user = User(hw_model = org.meshtastic.proto.HardwareModel.UNSET))
val node2 = Node(num = 2, user = User(hw_model = org.meshtastic.proto.HardwareModel.TLORA_V2))
repository.setNodes(listOf(node1, node2))
val result = repository.getUnknownNodes()
assertEquals(1, result.size)
assertEquals(1, result[0].num)
}
@Test
fun `getNodes filtering by onlyOnline`() = runTest {
val node1 = Node(num = 1, lastHeard = 2000000000) // Online
val node2 = Node(num = 2, lastHeard = 0) // Offline
repository.setNodes(listOf(node1, node2))
repository.getNodes(onlyOnline = true).test {
val result = awaitItem()
assertEquals(1, result.size)
assertEquals(1, result[0].num)
}
}
@Test
fun `getNodes filtering by onlyDirect`() = runTest {
val node1 = Node(num = 1, hopsAway = 0) // Direct
val node2 = Node(num = 2, hopsAway = 1) // Indirect
repository.setNodes(listOf(node1, node2))
repository.getNodes(onlyDirect = true).test {
val result = awaitItem()
assertEquals(1, result.size)
assertEquals(1, result[0].num)
}
}
@Test
fun `insertMetadata updates node metadata`() = runTest {
val nodeNum = 1234
repository.upsert(Node(num = nodeNum))
val metadata = org.meshtastic.proto.DeviceMetadata(firmware_version = "2.5.0")
repository.insertMetadata(nodeNum, metadata)
val node = repository.nodeDBbyNum.value[nodeNum]
assertEquals("2.5.0", node?.metadata?.firmware_version)
}
@Test
fun `deleteNodes removes multiple nodes`() = runTest {
repository.setNodes(listOf(Node(num = 1), Node(num = 2), Node(num = 3)))
repository.deleteNodes(listOf(1, 2))
assertEquals(1, repository.nodeDBbyNum.value.size)
assertTrue(repository.nodeDBbyNum.value.containsKey(3))
}
@Test
fun `reset clears all state`() = runTest {
repository.setNodes(listOf(Node(num = 1)))
repository.setMyId("my-id")
repository.setNodeNotes(1, "note")
repository.reset()
assertTrue(repository.nodeDBbyNum.value.isEmpty())
assertEquals(null, repository.myId.value)
}
@Test
fun `setNodeNotes persists notes`() = runTest {
val nodeNum = 1234
repository.upsert(Node(num = nodeNum))
repository.setNodeNotes(nodeNum, "My Note")
val node = repository.nodeDBbyNum.value[nodeNum]
assertEquals("My Note", node?.notes)
}
@Test
fun `clearNodeDB preserves favorites`() = runTest {
val node1 = Node(num = 1, isFavorite = true)
val node2 = Node(num = 2, isFavorite = false)
repository.setNodes(listOf(node1, node2))
repository.clearNodeDB(preserveFavorites = true)
assertEquals(1, repository.nodeDBbyNum.value.size)
assertTrue(repository.nodeDBbyNum.value.containsKey(1))
}
}

View file

@ -0,0 +1,22 @@
/*
* 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 org.meshtastic.core.repository.Location
/** Creates a placeholder iOS [Location] for testing. */
actual fun createLocation(latitude: Double, longitude: Double, altitude: Double): Location = Location()

View file

@ -0,0 +1,19 @@
/*
* 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
actual fun setupTestContext() {}

View file

@ -0,0 +1,22 @@
/*
* 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 org.meshtastic.core.repository.Location
/** Creates a placeholder JVM [Location] for testing. */
actual fun createLocation(latitude: Double, longitude: Double, altitude: Double): Location = Location()

View file

@ -0,0 +1,20 @@
/*
* 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
@Suppress("EmptyFunctionBlock")
actual fun setupTestContext() {}