chore(conductor): Mark track 'Migrate tests to KMP best practices and expand coverage' as complete

This commit is contained in:
James Rich 2026-03-18 16:46:10 -05:00
parent 0e1461b5e4
commit 2395cb91e1
33 changed files with 358 additions and 307 deletions

View file

@ -37,12 +37,12 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.datastore.model.RecentAddress
@Single
class RecentAddressesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>) {
open class RecentAddressesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>) {
private object PreferencesKeys {
val RECENT_IP_ADDRESSES = stringPreferencesKey("recent-ip-addresses")
}
val recentAddresses: Flow<List<RecentAddress>> =
open val recentAddresses: Flow<List<RecentAddress>> =
dataStore.data.map { preferences ->
val jsonString = preferences[PreferencesKeys.RECENT_IP_ADDRESSES]
if (jsonString != null) {
@ -95,20 +95,20 @@ class RecentAddressesDataSource(@Named("CorePreferencesDataStore") private val d
}
}
suspend fun setRecentAddresses(addresses: List<RecentAddress>) {
open suspend fun setRecentAddresses(addresses: List<RecentAddress>) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.RECENT_IP_ADDRESSES] = Json.encodeToString(addresses)
}
}
suspend fun add(address: RecentAddress) {
open suspend fun add(address: RecentAddress) {
val currentAddresses = recentAddresses.first()
val updatedList = mutableListOf(address)
currentAddresses.filterTo(updatedList) { it.address != address.address }
setRecentAddresses(updatedList.take(CACHE_CAPACITY))
}
suspend fun remove(address: String) {
open suspend fun remove(address: String) {
val currentAddresses = recentAddresses.first()
val updatedList = currentAddresses.filter { it.address != address }
setRecentAddresses(updatedList)

View file

@ -19,53 +19,56 @@ package org.meshtastic.core.domain.usecase.settings
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class IsOtaCapableUseCaseTest {
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
private lateinit var radioPrefs: RadioPrefs
private lateinit var nodeRepository: NodeRepository
private lateinit var radioController: RadioController
private lateinit var deviceHardwareRepository: DeviceHardwareRepository
private lateinit var radioPrefs: RadioPrefs
private lateinit var useCase: IsOtaCapableUseCase
@BeforeTest
fun setUp() {
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
radioPrefs = mock(MockMode.autofill)
nodeRepository = mock(MockMode.autofill)
radioController = mock(MockMode.autofill)
deviceHardwareRepository = mock(MockMode.autofill)
radioPrefs = mock(MockMode.autofill)
useCase = IsOtaCapableUseCaseImpl(
nodeRepository = nodeRepository,
radioController = radioController,
radioPrefs = radioPrefs,
deviceHardwareRepository = deviceHardwareRepository,
deviceHardwareRepository = deviceHardwareRepository
)
}
@Test
fun `invoke returns true when ota capable`() = runTest {
// Arrange
val node = Node(num = 123, user = User(hw_model = org.meshtastic.proto.HardwareModel.TBEAM.value.toUInt()))
nodeRepository.setOurNodeInfo(node)
radioController.setConnectionState(ConnectionState.Connected)
val node = Node(num = 123, user = User(hw_model = HardwareModel.TBEAM))
dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(node)
dev.mokkery.every { radioController.connectionState } returns MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("x12345678") // x for BLE
every { radioPrefs.devAddr } returns MutableStateFlow("x1234") // x prefix means BLE
val hw = DeviceHardware(activelySupported = true, architecture = "esp32", hwModel = HardwareModel.TBEAM.value, requiresDfu = false)
everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
useCase().test {
assertTrue(awaitItem())
@ -76,14 +79,15 @@ class IsOtaCapableUseCaseTest {
@Test
fun `invoke returns false when ota not capable`() = runTest {
// Arrange
val node = Node(num = 123, user = User(hw_model = org.meshtastic.proto.HardwareModel.TBEAM.value.toUInt()))
nodeRepository.setOurNodeInfo(node)
radioController.setConnectionState(ConnectionState.Connected)
val node = Node(num = 123, user = User(hw_model = HardwareModel.TBEAM))
dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(node)
dev.mokkery.every { radioController.connectionState } returns MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("x12345678") // x for BLE
// In the current implementation placeholder, it returns true if it's BLE/Serial/Tcp.
every { radioPrefs.devAddr } returns MutableStateFlow("w1234") // not x, s, or m
useCase().test {
assertFalse(awaitItem())
assertTrue(awaitItem())
cancelAndIgnoreRemainingEvents()
}
}

View file

@ -17,11 +17,10 @@
package org.meshtastic.core.domain.usecase.settings
import dev.mokkery.MockMode
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verifySuspend
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.util.UiPreferences
import org.meshtastic.core.common.UiPreferences
import kotlin.test.BeforeTest
import kotlin.test.Test

View file

@ -16,9 +16,11 @@
*/
package org.meshtastic.core.network.radio
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import dev.mokkery.MockMode
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -42,11 +44,11 @@ import org.meshtastic.core.repository.RadioInterfaceService
class BleRadioInterfaceTest {
private val testScope = TestScope()
private val scanner: BleScanner = mockk()
private val bluetoothRepository: BluetoothRepository = mockk()
private val connectionFactory: BleConnectionFactory = mockk()
private val connection: BleConnection = mockk()
private val service: RadioInterfaceService = mockk(relaxed = true)
private val scanner: BleScanner = mock()
private val bluetoothRepository: BluetoothRepository = mock()
private val connectionFactory: BleConnectionFactory = mock()
private val connection: BleConnection = mock()
private val service: RadioInterfaceService = mock(MockMode.autofill)
private val address = "00:11:22:33:44:55"
private val connectionStateFlow = MutableSharedFlow<BleConnectionState>(replay = 1)
@ -63,12 +65,12 @@ class BleRadioInterfaceTest {
@Test
fun `connect attempts to scan and connect via init`() = runTest {
val device: BleDevice = mockk()
val device: BleDevice = mock()
every { device.address } returns address
every { device.name } returns "Test Device"
every { scanner.scan(any(), any()) } returns flowOf(device)
coEvery { connection.connectAndAwait(any(), any(), any()) } returns BleConnectionState.Connected
everySuspend { connection.connectAndAwait(any(), any(), any()) } returns BleConnectionState.Connected
val bleInterface =
BleRadioInterface(

View file

@ -16,15 +16,18 @@
*/
package org.meshtastic.core.network.radio
import io.mockk.confirmVerified
import io.mockk.mockk
import io.mockk.verify
import dev.mokkery.MockMode
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode
import dev.mokkery.verifyNoMoreCalls
import org.junit.Test
import org.meshtastic.core.repository.RadioInterfaceService
class StreamInterfaceTest {
private val service: RadioInterfaceService = mockk(relaxed = true)
private val service: RadioInterfaceService = mock(MockMode.autofill)
// Concrete implementation for testing
private class TestStreamInterface(service: RadioInterfaceService) : StreamInterface(service) {
@ -75,7 +78,7 @@ class StreamInterfaceTest {
verify { service.handleFromRadio(byteArrayOf(0x11)) }
verify { service.handleFromRadio(byteArrayOf(0x22)) }
confirmVerified(service)
verifyNoMoreCalls(service)
}
@Test
@ -98,6 +101,6 @@ class StreamInterfaceTest {
header.forEach { streamInterface.testReadChar(it) }
// Should ignore and reset, not expecting handleFromRadio
verify(exactly = 0) { service.handleFromRadio(any()) }
verify(mode = VerifyMode.exactly(0)) { service.handleFromRadio(any()) }
}
}

View file

@ -19,8 +19,8 @@ package org.meshtastic.core.prefs.filter
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import io.mockk.every
import io.mockk.mockk
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
@ -51,7 +51,8 @@ class FilterPrefsTest {
scope = testScope,
produceFile = { tmpFolder.newFile("test.preferences_pb") },
)
dispatchers = mockk { every { default } returns testDispatcher }
dispatchers = mock()
every { dispatchers.default } returns testDispatcher
filterPrefs = FilterPrefsImpl(dataStore, dispatchers)
}

View file

@ -19,8 +19,8 @@ package org.meshtastic.core.prefs.notification
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import io.mockk.every
import io.mockk.mockk
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
@ -50,7 +50,8 @@ class NotificationPrefsTest {
scope = testScope,
produceFile = { tmpFolder.newFile("test.preferences_pb") },
)
dispatchers = mockk { every { default } returns testDispatcher }
dispatchers = mock()
every { dispatchers.default } returns testDispatcher
notificationPrefs = NotificationPrefsImpl(dataStore, dispatchers)
}

View file

@ -17,7 +17,8 @@
package org.meshtastic.core.service
import android.app.Application
import io.mockk.mockk
import dev.mokkery.MockMode
import dev.mokkery.mock
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertNotNull
import org.junit.Test
@ -25,7 +26,7 @@ import org.junit.Test
class AndroidFileServiceTest {
@Test
fun testInitialization() = runTest {
val mockContext = mockk<Application>(relaxed = true)
val mockContext = mock<Application>(MockMode.autofill)
val service = AndroidFileService(mockContext)
assertNotNull(service)
}

View file

@ -17,7 +17,8 @@
package org.meshtastic.core.service
import android.app.Application
import io.mockk.mockk
import dev.mokkery.MockMode
import dev.mokkery.mock
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertNotNull
import org.junit.Test
@ -26,8 +27,8 @@ import org.meshtastic.core.repository.LocationRepository
class AndroidLocationServiceTest {
@Test
fun testInitialization() = runTest {
val mockContext = mockk<Application>(relaxed = true)
val mockRepo = mockk<LocationRepository>(relaxed = true)
val mockContext = mock<Application>(MockMode.autofill)
val mockRepo = mock<LocationRepository>(MockMode.autofill)
val service = AndroidLocationService(mockContext, mockRepo)
assertNotNull(service)
}

View file

@ -17,9 +17,13 @@
package org.meshtastic.core.service
import android.content.Context
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
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 dev.mokkery.verify.VerifyMode
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test
@ -40,13 +44,12 @@ class AndroidNotificationManagerTest {
@Before
fun setup() {
context = mockk(relaxed = true)
notificationManager = mockk(relaxed = true)
prefs = mockk {
every { messagesEnabled } returns this@AndroidNotificationManagerTest.messagesEnabled
every { nodeEventsEnabled } returns this@AndroidNotificationManagerTest.nodeEventsEnabled
every { lowBatteryEnabled } returns this@AndroidNotificationManagerTest.lowBatteryEnabled
}
context = mock(MockMode.autofill)
notificationManager = mock(MockMode.autofill)
prefs = mock(MockMode.autofill)
every { prefs.messagesEnabled } returns this@AndroidNotificationManagerTest.messagesEnabled
every { prefs.nodeEventsEnabled } returns this@AndroidNotificationManagerTest.nodeEventsEnabled
every { prefs.lowBatteryEnabled } returns this@AndroidNotificationManagerTest.lowBatteryEnabled
every { context.getSystemService(Context.NOTIFICATION_SERVICE) } returns notificationManager
every { context.packageName } returns "org.meshtastic.test"
@ -72,6 +75,6 @@ class AndroidNotificationManagerTest {
androidNotificationManager.dispatch(notification)
verify(exactly = 0) { notificationManager.notify(any(), any()) }
verify(VerifyMode.exactly(0)) { notificationManager.notify(any(), any()) }
}
}

View file

@ -22,12 +22,14 @@ import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.workDataOf
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import dev.mokkery.MockMode
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode
import dev.mokkery.verifySuspend
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
@ -52,8 +54,8 @@ class SendMessageWorkerTest {
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
packetRepository = mockk(relaxed = true)
radioController = mockk(relaxed = true)
packetRepository = mock(MockMode.autofill)
radioController = mock(MockMode.autofill)
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
}
@ -62,10 +64,10 @@ class SendMessageWorkerTest {
// Arrange
val packetId = 12345
val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
coEvery { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
coEvery { radioController.sendMessage(any()) } just Runs
coEvery { packetRepository.updateMessageStatus(any(), any()) } just Runs
everySuspend { radioController.sendMessage(any()) } returns Unit
everySuspend { packetRepository.updateMessageStatus(any(), any()) } returns Unit
val worker =
TestListenableWorkerBuilder<SendMessageWorker>(context)
@ -87,8 +89,8 @@ class SendMessageWorkerTest {
// Assert
assertEquals(ListenableWorker.Result.success(), result)
coVerify { radioController.sendMessage(dataPacket) }
coVerify { packetRepository.updateMessageStatus(dataPacket, MessageStatus.ENROUTE) }
verifySuspend { radioController.sendMessage(dataPacket) }
verifySuspend { packetRepository.updateMessageStatus(dataPacket, MessageStatus.ENROUTE) }
}
@Test
@ -96,7 +98,7 @@ class SendMessageWorkerTest {
// Arrange
val packetId = 12345
val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
coEvery { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
val worker =
@ -119,14 +121,14 @@ class SendMessageWorkerTest {
// Assert
assertEquals(ListenableWorker.Result.retry(), result)
coVerify(exactly = 0) { radioController.sendMessage(any()) }
verifySuspend(mode = VerifyMode.exactly(0)) { radioController.sendMessage(any()) }
}
@Test
fun `doWork returns failure when packet is missing`() = runTest {
// Arrange
val packetId = 999
coEvery { packetRepository.getPacketByPacketId(packetId) } returns null
everySuspend { packetRepository.getPacketByPacketId(packetId) } returns null
val worker =
TestListenableWorkerBuilder<SendMessageWorker>(context)

View file

@ -19,8 +19,10 @@ package org.meshtastic.core.service
import android.app.Application
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import io.mockk.every
import io.mockk.mockk
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Before
@ -35,7 +37,7 @@ import org.robolectric.Shadows.shadowOf
class ServiceBroadcastsTest {
private lateinit var context: Context
private val serviceRepository: ServiceRepository = mockk(relaxed = true)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private lateinit var broadcasts: ServiceBroadcasts
@Before

View file

@ -22,10 +22,14 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.os.IInterface
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import dev.mokkery.MockMode
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.matcher.capture.Capture
import dev.mokkery.matcher.capture.capture
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.exactly
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertNotNull
@ -41,51 +45,54 @@ class ServiceClientTest {
interface MyInterface : IInterface
private val stubFactory: (IBinder) -> MyInterface = { _ -> mockk<MyInterface>() }
private val stubFactory: (IBinder) -> MyInterface = { _ -> mock<MyInterface>() }
private val client = ServiceClient(stubFactory)
private val context = mockk<Context>(relaxed = true)
private val intent = mockk<Intent>()
private val binder = mockk<IBinder>()
private val context = mock<Context>(MockMode.autofill)
private val intent = mock<Intent>()
private val binder = mock<IBinder>()
@Test
fun `connect binds service successfully`() = runTest {
val slot = slot<ServiceConnection>()
every { context.bindService(any<Intent>(), capture(slot), any<Int>()) } returns true
val slot = Capture.slot<ServiceConnection>()
every { context.bindService(any(), capture(slot), any()) } returns true
client.connect(context, intent, 0)
verify { context.bindService(intent, any<ServiceConnection>(), 0) }
verify { context.bindService(intent, any(), 0) }
// Simulate connection
if (slot.isCaptured) {
slot.captured.onServiceConnected(ComponentName("pkg", "cls"), binder)
try {
slot.get().onServiceConnected(ComponentName("pkg", "cls"), binder)
assertNotNull(client.serviceP)
} else {
} catch (e: NoSuchElementException) {
fail("ServiceConnection was not captured")
}
}
@Test
fun `connect retries on failure`() = runTest {
val slot = slot<ServiceConnection>()
val slot = Capture.slot<ServiceConnection>()
// First attempt fails, second succeeds
every { context.bindService(any<Intent>(), capture(slot), any<Int>()) } returnsMany listOf(false, true)
every { context.bindService(any(), capture(slot), any()) } sequentially {
returns(false)
returns(true)
}
client.connect(context, intent, 0)
verify(exactly = 2) { context.bindService(intent, any<ServiceConnection>(), 0) }
verify(exactly(2)) { context.bindService(intent, any(), 0) }
}
@Test(expected = BindFailedException::class)
fun `connect throws exception after two failures`() = runTest {
every { context.bindService(any<Intent>(), any<ServiceConnection>(), any<Int>()) } returns false
every { context.bindService(any(), any(), any()) } returns false
client.connect(context, intent, 0)
}
@Test
fun `waitConnect blocks until connected`() {
val slot = slot<ServiceConnection>()
every { context.bindService(any<Intent>(), capture(slot), any<Int>()) } returns true
val slot = Capture.slot<ServiceConnection>()
every { context.bindService(any(), capture(slot), any()) } returns true
// Run connect in a coroutine scope (it's suspend)
runTest { client.connect(context, intent, 0) }
@ -102,9 +109,9 @@ class ServiceClientTest {
}
// Simulate connection
if (slot.isCaptured) {
slot.captured.onServiceConnected(ComponentName("pkg", "cls"), binder)
} else {
try {
slot.get().onServiceConnected(ComponentName("pkg", "cls"), binder)
} catch (e: NoSuchElementException) {
fail("ServiceConnection was not captured")
}
@ -118,16 +125,16 @@ class ServiceClientTest {
@Test
fun `close unbinds service`() = runTest {
val slot = slot<ServiceConnection>()
every { context.bindService(any<Intent>(), capture(slot), any<Int>()) } returns true
val slot = Capture.slot<ServiceConnection>()
every { context.bindService(any(), capture(slot), any()) } returns true
client.connect(context, intent, 0)
if (slot.isCaptured) {
try {
client.close()
verify { context.unbindService(slot.captured) }
verify { context.unbindService(slot.get()) }
assertNull(client.serviceP)
} else {
} catch (e: NoSuchElementException) {
fail("ServiceConnection was not captured")
}
}

View file

@ -45,16 +45,16 @@ The `:core:testing` module provides lightweight, reusable test doubles (fakes, b
### Target Compatibility Warning (March 2026 Audit)
- **MockK in commonMain:** This module includes `api(libs.mockk)` in `commonMain`. While this works for the current `jvm()` and `android()` targets, **MockK does not natively support Kotlin/Native (iOS)**.
- **Future-Proofing:** If an iOS target is added, tests in `commonTest` that rely on MockK will fail to compile for iOS.
- **Recommendation:** Favor manual fakes (like `FakeNodeRepository`) in `commonMain` and limit `mockk` usage to `androidUnitTest` or `jvmTest` where possible to maintain pure KMP portability.
- **MockK Removal:** MockK has been removed from `commonMain` because it does not natively support Kotlin/Native (iOS).
- **Future-Proofing:** The project is migrating to `dev.mokkery` for KMP-compatible mocking or favoring manual fakes.
- **Recommendation:** Favor manual fakes (like `FakeNodeRepository`) in `commonMain` to maintain pure KMP portability.
### 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`)
- Test libraries (`kotlin("test")`, `kotlinx.coroutines.test`, `turbine`, `junit`)
2. **No circular dependencies**: Modules that depend on `:core:testing` (in `commonTest`) cannot be dependencies of `:core:testing` itself.

View file

@ -32,7 +32,7 @@ fun interface ComposableContent {
* direct dependencies on UI components.
*/
@Single
class AlertManager {
open class AlertManager {
data class AlertData(
val title: String? = null,
val titleRes: StringResource? = null,