Merge main and common module to simplify.

This commit is contained in:
darken
2025-09-29 13:36:43 +02:00
parent deb012600d
commit be8f4919c8
278 changed files with 142 additions and 117 deletions
+29
View File
@@ -0,0 +1,29 @@
package testhelpers
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import io.mockk.unmockkAll
import org.junit.jupiter.api.AfterAll
import testhelpers.logging.JUnitLogger
open class BaseTest {
init {
Logging.clearAll()
Logging.install(JUnitLogger())
testClassName = this.javaClass.simpleName
}
companion object {
private var testClassName: String? = null
@JvmStatic
@AfterAll
fun onTestClassFinished() {
unmockkAll()
log(testClassName!!, VERBOSE) { "onTestClassFinished()" }
Logging.clearAll()
}
}
}
@@ -0,0 +1,3 @@
package testhelpers
class IsAUnitTest
@@ -0,0 +1,23 @@
package testhelpers.coroutine
import eu.darken.capod.common.coroutine.DispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlin.coroutines.CoroutineContext
class TestDispatcherProvider(private val context: CoroutineContext? = null) : DispatcherProvider {
override val Default: CoroutineContext
get() = context ?: Dispatchers.Unconfined
override val Main: CoroutineContext
get() = context ?: Dispatchers.Unconfined
override val MainImmediate: CoroutineContext
get() = context ?: Dispatchers.Unconfined
override val Unconfined: CoroutineContext
get() = context ?: Dispatchers.Unconfined
override val IO: CoroutineContext
get() = context ?: Dispatchers.Unconfined
}
fun CoroutineScope.asDispatcherProvider() = this.coroutineContext.asDispatcherProvider()
fun CoroutineContext.asDispatcherProvider() = TestDispatcherProvider(context = this)
@@ -0,0 +1,38 @@
package testhelpers.coroutine
import eu.darken.capod.common.debug.logging.asLog
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.reflect.KClass
fun runTest2(
autoCancel: Boolean = false,
context: CoroutineContext = EmptyCoroutineContext,
expectedError: KClass<out Throwable>? = null,
testBody: suspend TestScope.() -> Unit
) {
try {
val scope = TestScope(context = context)
try {
scope.runTest {
testBody()
if (autoCancel) scope.cancel("autoCancel")
}
} catch (e: Throwable) {
val isExpected = expectedError?.isInstance(e) ?: false
if (!isExpected) throw e
}
} catch (e: CancellationException) {
if (e.message == "autoCancel" && autoCancel) {
io.kotest.mpp.log { "Test was auto-cancelled ${e.asLog()}" }
} else {
throw e
}
}
}
@@ -0,0 +1,110 @@
package testhelpers.flow
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
fun <T> Flow<T>.test(
tag: String? = null,
scope: CoroutineScope
): TestCollector<T> = createTest(tag ?: "FlowTest").start(scope = scope)
fun <T> Flow<T>.createTest(
tag: String? = null
): TestCollector<T> = TestCollector(this, tag ?: "FlowTest")
class TestCollector<T>(
private val flow: Flow<T>,
private val tag: String
) {
private var error: Throwable? = null
private lateinit var job: Job
private val cache = MutableSharedFlow<T>(
replay = Int.MAX_VALUE,
extraBufferCapacity = Int.MAX_VALUE,
onBufferOverflow = BufferOverflow.SUSPEND
)
private var latestInternal: T? = null
private val collectedValuesMutex = Mutex()
private val collectedValues = mutableListOf<T>()
var silent = false
fun start(scope: CoroutineScope) = apply {
flow
.buffer(capacity = Int.MAX_VALUE)
.onStart { log(tag) { "Setting up." } }
.onCompletion { log(tag) { "Final." } }
.onEach {
collectedValuesMutex.withLock {
if (!silent) log(tag) { "Collecting: $it" }
latestInternal = it
collectedValues.add(it)
cache.emit(it)
}
}
.catch { e ->
log(tag, WARN) { "Caught error: ${e.asLog()}" }
error = e
}
.launchIn(scope)
.also { job = it }
}
fun emissions(): Flow<T> = cache
val latestValue: T?
get() = collectedValues.last()
val latestValues: List<T>
get() = collectedValues
fun await(
timeout: Long = 10_000,
condition: (List<T>, T) -> Boolean
): T = runBlocking {
withTimeout(timeMillis = timeout) {
emissions().first {
condition(collectedValues, it)
}
}
}
suspend fun awaitFinal(cancel: Boolean = false) = apply {
if (cancel) job.cancel()
try {
job.join()
} catch (e: Exception) {
error = e
}
}
suspend fun assertNoErrors() = apply {
awaitFinal()
require(error == null) { "Error was not null: $error" }
}
suspend fun cancelAndJoin() {
if (job.isCompleted) throw IllegalStateException("Flow is already canceled.")
job.cancelAndJoin()
}
}
@@ -0,0 +1,29 @@
package testhelpers.json
import com.squareup.moshi.JsonReader
import com.squareup.moshi.Moshi
import okio.Buffer
import okio.ByteString.Companion.encode
import okio.buffer
import okio.sink
import java.io.File
fun String.toComparableJson(): String {
val value = Buffer().use {
it.writeUtf8(this)
val reader = JsonReader.of(it)
reader.readJsonValue()
}
val adapter = Moshi.Builder().build().adapter(Any::class.java).indent(" ")
return adapter.toJson(value)
}
fun String.writeToFile(file: File) = encode().let { text ->
require(!file.exists())
file.parentFile?.mkdirs()
file.createNewFile()
file.sink().buffer().use { it.write(text) }
}
@@ -0,0 +1,26 @@
package testhelpers.livedata
import androidx.arch.core.executor.ArchTaskExecutor
import androidx.arch.core.executor.TaskExecutor
import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext
class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback {
override fun beforeEach(context: ExtensionContext?) {
ArchTaskExecutor.getInstance().setDelegate(
object : TaskExecutor() {
override fun executeOnDiskIO(runnable: Runnable) = runnable.run()
override fun postToMainThread(runnable: Runnable) = runnable.run()
override fun isMainThread(): Boolean = true
}
)
}
override fun afterEach(context: ExtensionContext?) {
ArchTaskExecutor.getInstance().setDelegate(null)
}
}
@@ -0,0 +1,13 @@
package testhelpers.logging
import eu.darken.capod.common.debug.logging.Logging
class JUnitLogger(private val minLogLevel: Logging.Priority = Logging.Priority.VERBOSE) : Logging.Logger {
override fun isLoggable(priority: Logging.Priority): Boolean = priority.intValue >= minLogLevel.intValue
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
println("${System.currentTimeMillis()} ${priority.shortLabel}/$tag: $message")
}
}
@@ -0,0 +1,21 @@
package testhelpers.preferences
import eu.darken.capod.common.preferences.FlowPreference
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
fun <T> mockFlowPreference(
defaultValue: T
): FlowPreference<T> {
val instance = mockk<FlowPreference<T>>()
val flow = MutableStateFlow(defaultValue)
every { instance.flow } answers { flow }
every { instance.value } answers { flow.value }
every { instance.update(any()) } answers {
val updateCall = arg<(T) -> T>(0)
flow.value = updateCall(flow.value)
}
return instance
}
@@ -0,0 +1,99 @@
package testhelpers.preferences
import android.content.SharedPreferences
class MockSharedPreferences : SharedPreferences {
private val listeners = mutableListOf<SharedPreferences.OnSharedPreferenceChangeListener>()
private val dataMap = mutableMapOf<String, Any>()
val dataMapPeek: Map<String, Any>
get() = dataMap.toMap()
override fun getAll(): MutableMap<String, *> = dataMap
override fun getString(key: String, defValue: String?): String? =
dataMap[key] as? String ?: defValue
override fun getStringSet(key: String, defValues: MutableSet<String>?): MutableSet<String> {
throw NotImplementedError()
}
override fun getInt(key: String, defValue: Int): Int =
dataMap[key] as? Int ?: defValue
override fun getLong(key: String, defValue: Long): Long =
dataMap[key] as? Long ?: defValue
override fun getFloat(key: String, defValue: Float): Float {
throw NotImplementedError()
}
override fun getBoolean(key: String, defValue: Boolean): Boolean =
dataMap[key] as? Boolean ?: defValue
override fun contains(key: String): Boolean = dataMap.contains(key)
override fun edit(): SharedPreferences.Editor = createEditor(dataMap.toMap()) { newData ->
dataMap.clear()
dataMap.putAll(newData)
}
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
listeners.add(listener)
}
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
listeners.remove(listener)
}
private fun createEditor(
toEdit: Map<String, Any>,
onSave: (Map<String, Any>) -> Unit
): SharedPreferences.Editor {
return object : SharedPreferences.Editor {
private val editorData = toEdit.toMutableMap()
override fun putString(key: String, value: String?): SharedPreferences.Editor = apply {
value?.let { editorData[key] = it } ?: editorData.remove(key)
}
override fun putStringSet(
key: String?,
values: MutableSet<String>?
): SharedPreferences.Editor {
throw NotImplementedError()
}
override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun remove(key: String): SharedPreferences.Editor = apply {
editorData.remove(key)
}
override fun clear(): SharedPreferences.Editor = apply {
editorData.clear()
}
override fun commit(): Boolean {
onSave(editorData)
return true
}
override fun apply() {
onSave(editorData)
}
}
}
}
@@ -0,0 +1,21 @@
package testhelpers.preferences
import androidx.core.content.edit
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class MockSharedPreferencesTest : BaseTest() {
private fun createInstance() = MockSharedPreferences()
@Test
fun `test boolean insertion`() {
val prefs = createInstance()
prefs.dataMapPeek shouldBe emptyMap()
prefs.getBoolean("key", true) shouldBe true
prefs.edit { putBoolean("key", false) }
prefs.getBoolean("key", true) shouldBe false
prefs.dataMapPeek["key"] shouldBe false
}
}