mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
refactor(settings): Migrate preferences from SharedPreferences to DataStore
Replace FlowPreference<T> wrapping SharedPreferences with DataStoreValue<T> wrapping AndroidX DataStore. Includes SharedPreferencesMigration for preserving existing user data, kotlinx-serialization for complex types (replacing Moshi for preferences), and comprehensive unit tests for the new infrastructure.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
package eu.darken.capod.common.datastore
|
||||
|
||||
import com.squareup.moshi.Json as MoshiJson
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.theming.ThemeColor
|
||||
import eu.darken.capod.common.theming.ThemeMode
|
||||
import eu.darken.capod.common.theming.ThemeStyle
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesContainer
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.serializer
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
/**
|
||||
* Tests that verify @SerialName values match @Json(name=...) values,
|
||||
* ensuring Moshi-serialized SharedPreferences data can be read by kotlinx-serialization
|
||||
* after the DataStore migration.
|
||||
*/
|
||||
class DataStoreMigrationCompatTest : BaseTest() {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
/**
|
||||
* For each enum with both @SerialName and @Json annotations,
|
||||
* verify they produce the same string. This catches typos in @SerialName values.
|
||||
*/
|
||||
private inline fun <reified T : Enum<T>> verifyEnumSerialNameParity() {
|
||||
val enumClass = T::class.java
|
||||
for (constant in enumClass.enumConstants!!) {
|
||||
val field = enumClass.getField(constant.name)
|
||||
val moshiAnnotation = field.getAnnotation(MoshiJson::class.java)
|
||||
val serialNameAnnotation = field.getAnnotation(SerialName::class.java)
|
||||
|
||||
if (moshiAnnotation != null && serialNameAnnotation != null) {
|
||||
serialNameAnnotation.value shouldBe moshiAnnotation.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SerialName matches Json name - ThemeMode`() = verifyEnumSerialNameParity<ThemeMode>()
|
||||
|
||||
@Test
|
||||
fun `SerialName matches Json name - ThemeStyle`() = verifyEnumSerialNameParity<ThemeStyle>()
|
||||
|
||||
@Test
|
||||
fun `SerialName matches Json name - ThemeColor`() = verifyEnumSerialNameParity<ThemeColor>()
|
||||
|
||||
@Test
|
||||
fun `SerialName matches Json name - MonitorMode`() = verifyEnumSerialNameParity<MonitorMode>()
|
||||
|
||||
@Test
|
||||
fun `SerialName matches Json name - ScannerMode`() = verifyEnumSerialNameParity<ScannerMode>()
|
||||
|
||||
@Test
|
||||
fun `SerialName matches Json name - AutoConnectCondition`() = verifyEnumSerialNameParity<AutoConnectCondition>()
|
||||
|
||||
@Test
|
||||
fun `SerialName matches Json name - PodDevice Model`() = verifyEnumSerialNameParity<PodDevice.Model>()
|
||||
|
||||
@Test
|
||||
fun `Moshi-serialized ThemeMode string is readable by kotlinx`() {
|
||||
// Moshi stores enums as JSON strings like: "theme.mode.dark"
|
||||
val moshiOutput = "\"theme.mode.dark\""
|
||||
val result = json.decodeFromString(serializer<ThemeMode>(), moshiOutput)
|
||||
result shouldBe ThemeMode.DARK
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Moshi-serialized ScannerMode string is readable by kotlinx`() {
|
||||
val moshiOutput = "\"scanner.mode.balanced\""
|
||||
val result = json.decodeFromString(serializer<ScannerMode>(), moshiOutput)
|
||||
result shouldBe ScannerMode.BALANCED
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Moshi-serialized MonitorMode string is readable by kotlinx`() {
|
||||
val moshiOutput = "\"monitor.mode.automatic\""
|
||||
val result = json.decodeFromString(serializer<MonitorMode>(), moshiOutput)
|
||||
result shouldBe MonitorMode.AUTOMATIC
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all PodDevice Model values can be decoded from Moshi format`() {
|
||||
for (model in PodDevice.Model.entries) {
|
||||
val field = PodDevice.Model::class.java.getField(model.name)
|
||||
val moshiAnnotation = field.getAnnotation(MoshiJson::class.java)
|
||||
if (moshiAnnotation != null) {
|
||||
val moshiOutput = "\"${moshiAnnotation.name}\""
|
||||
val result = json.decodeFromString(serializer<PodDevice.Model>(), moshiOutput)
|
||||
result shouldBe model
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Moshi-serialized DeviceProfilesContainer JSON is readable by kotlinx`() {
|
||||
// This is what Moshi would produce for a container with one Apple profile
|
||||
val moshiJson = """
|
||||
{
|
||||
"profiles": [
|
||||
{
|
||||
"type": "apple",
|
||||
"id": "test-uuid",
|
||||
"label": "My AirPods Pro",
|
||||
"priority": 0,
|
||||
"model": "airpods.pro",
|
||||
"minimumSignalQuality": 0.15,
|
||||
"address": "AA:BB:CC:DD:EE:FF"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), moshiJson)
|
||||
result.profiles.size shouldBe 1
|
||||
|
||||
val profile = result.profiles[0] as AppleDeviceProfile
|
||||
profile.id shouldBe "test-uuid"
|
||||
profile.label shouldBe "My AirPods Pro"
|
||||
profile.model shouldBe PodDevice.Model.AIRPODS_PRO
|
||||
profile.minimumSignalQuality shouldBe 0.15f
|
||||
profile.address shouldBe "AA:BB:CC:DD:EE:FF"
|
||||
profile.identityKey shouldBe null
|
||||
profile.encryptionKey shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Moshi-serialized DeviceProfile with ByteArray fields is readable by kotlinx`() {
|
||||
// Base64 encoded: [0x01, 0x02, 0x03] = "AQID"
|
||||
val moshiJson = """
|
||||
{
|
||||
"profiles": [
|
||||
{
|
||||
"type": "apple",
|
||||
"id": "key-test",
|
||||
"label": "Keyed Profile",
|
||||
"priority": 1,
|
||||
"model": "airpods.gen2",
|
||||
"minimumSignalQuality": 0.2,
|
||||
"identityKey": "AQID",
|
||||
"encryptionKey": "BAUG"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), moshiJson)
|
||||
val profile = result.profiles[0] as AppleDeviceProfile
|
||||
profile.identityKey!!.toList() shouldBe listOf<Byte>(0x01, 0x02, 0x03)
|
||||
profile.encryptionKey!!.toList() shouldBe listOf<Byte>(0x04, 0x05, 0x06)
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package eu.darken.capod.common.datastore
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
|
||||
import eu.darken.capod.common.theming.ThemeMode
|
||||
import kotlinx.serialization.builtins.nullable
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesContainer
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import testhelpers.BaseTest
|
||||
import testhelpers.coroutine.runTest2
|
||||
import java.io.File
|
||||
import eu.darken.capod.common.datastore.value
|
||||
|
||||
class DataStoreValueSerializationTest : BaseTest() {
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: File
|
||||
|
||||
private var dsCounter = 0
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
private fun createDataStore() = PreferenceDataStoreFactory.create(
|
||||
produceFile = { File(tempDir, "test_${dsCounter++}.preferences_pb") }
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `enum round-trip - ThemeMode`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("theme", ThemeMode.SYSTEM, json)
|
||||
|
||||
pref.value() shouldBe ThemeMode.SYSTEM
|
||||
|
||||
pref.value(ThemeMode.DARK)
|
||||
pref.value() shouldBe ThemeMode.DARK
|
||||
|
||||
pref.value(ThemeMode.LIGHT)
|
||||
pref.value() shouldBe ThemeMode.LIGHT
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enum round-trip - PodDevice Model`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("model", PodDevice.Model.UNKNOWN, json)
|
||||
|
||||
PodDevice.Model.entries.forEach { model ->
|
||||
pref.value(model)
|
||||
pref.value() shouldBe model
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data class round-trip - DeviceProfilesContainer`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val container = DeviceProfilesContainer(
|
||||
profiles = listOf(
|
||||
AppleDeviceProfile(
|
||||
id = "test-id-1",
|
||||
label = "My AirPods",
|
||||
model = PodDevice.Model.AIRPODS_PRO,
|
||||
address = "AA:BB:CC:DD:EE:FF",
|
||||
)
|
||||
)
|
||||
)
|
||||
val pref = ds.createValue("profiles", DeviceProfilesContainer(), json)
|
||||
|
||||
pref.value(container)
|
||||
val result = pref.value()
|
||||
|
||||
result.profiles.size shouldBe 1
|
||||
val profile = result.profiles[0] as AppleDeviceProfile
|
||||
profile.id shouldBe "test-id-1"
|
||||
profile.label shouldBe "My AirPods"
|
||||
profile.model shouldBe PodDevice.Model.AIRPODS_PRO
|
||||
profile.address shouldBe "AA:BB:CC:DD:EE:FF"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onErrorFallbackToDefault returns default on corrupt JSON`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("theme", ThemeMode.SYSTEM, json, onErrorFallbackToDefault = true)
|
||||
|
||||
// Write corrupt JSON directly
|
||||
val corruptPref = ds.createValue("theme", "not valid json")
|
||||
corruptPref.value("{{{corrupt json")
|
||||
|
||||
// Now read it as ThemeMode - should fallback to default
|
||||
pref.value() shouldBe ThemeMode.SYSTEM
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onErrorFallbackToDefault returns default on unknown enum value`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("theme", ThemeMode.SYSTEM, json, onErrorFallbackToDefault = true)
|
||||
|
||||
// Write an unknown enum value
|
||||
val rawPref = ds.createValue("theme", "placeholder")
|
||||
rawPref.value("\"theme.mode.nonexistent\"")
|
||||
|
||||
pref.value() shouldBe ThemeMode.SYSTEM
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onErrorFallbackToDefault false - corrupt JSON throws`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("theme", ThemeMode.SYSTEM, json, onErrorFallbackToDefault = false)
|
||||
|
||||
val rawPref = ds.createValue("theme", "placeholder")
|
||||
rawPref.value("{{{corrupt")
|
||||
|
||||
shouldThrow<Exception> {
|
||||
pref.value()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ByteArray round-trip via explicit serializer`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val testBytes = byteArrayOf(0x01, 0x02, 0x03, 0xAA.toByte(), 0xFF.toByte())
|
||||
|
||||
val pref = ds.createValue(
|
||||
key = "bytes",
|
||||
defaultValue = null as ByteArray?,
|
||||
json = json,
|
||||
serializer = ByteArrayBase64Serializer.nullable,
|
||||
)
|
||||
|
||||
pref.value() shouldBe null
|
||||
|
||||
pref.value(testBytes)
|
||||
val result = pref.value()
|
||||
result!!.toList() shouldBe testBytes.toList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sealed interface polymorphic - DeviceProfile`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val profile: DeviceProfile = AppleDeviceProfile(
|
||||
id = "poly-test",
|
||||
label = "Test Profile",
|
||||
model = PodDevice.Model.AIRPODS_GEN2,
|
||||
identityKey = byteArrayOf(0x01, 0x02),
|
||||
encryptionKey = byteArrayOf(0x03, 0x04),
|
||||
)
|
||||
val container = DeviceProfilesContainer(profiles = listOf(profile))
|
||||
val pref = ds.createValue("profiles", DeviceProfilesContainer(), json)
|
||||
|
||||
pref.value(container)
|
||||
val result = pref.value()
|
||||
|
||||
result.profiles.size shouldBe 1
|
||||
val restored = result.profiles[0] as AppleDeviceProfile
|
||||
restored.id shouldBe "poly-test"
|
||||
restored.label shouldBe "Test Profile"
|
||||
restored.model shouldBe PodDevice.Model.AIRPODS_GEN2
|
||||
restored.identityKey!!.toList() shouldBe listOf<Byte>(0x01, 0x02)
|
||||
restored.encryptionKey!!.toList() shouldBe listOf<Byte>(0x03, 0x04)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package eu.darken.capod.common.datastore
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import testhelpers.BaseTest
|
||||
import testhelpers.coroutine.runTest2
|
||||
import java.io.File
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.datastore.value
|
||||
|
||||
class DataStoreValueTest : BaseTest() {
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: File
|
||||
|
||||
private var dsCounter = 0
|
||||
|
||||
private fun createDataStore() = PreferenceDataStoreFactory.create(
|
||||
produceFile = { File(tempDir, "test_${dsCounter++}.preferences_pb") }
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `read default value when key not set - String`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", "default_val")
|
||||
pref.value() shouldBe "default_val"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `read default value when key not set - Boolean`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", false)
|
||||
pref.value() shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `read default value when key not set - Int`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", 42)
|
||||
pref.value() shouldBe 42
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `read default value when key not set - Long`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", 123L)
|
||||
pref.value() shouldBe 123L
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `read default value when key not set - Float`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", 0.5f)
|
||||
pref.value() shouldBe 0.5f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write and read back String`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", "default")
|
||||
pref.value("new_value")
|
||||
pref.value() shouldBe "new_value"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write and read back Boolean`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", false)
|
||||
pref.value(true)
|
||||
pref.value() shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write and read back Int`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", 0)
|
||||
pref.value(99)
|
||||
pref.value() shouldBe 99
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write and read back Long`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", 0L)
|
||||
pref.value(Long.MAX_VALUE)
|
||||
pref.value() shouldBe Long.MAX_VALUE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write and read back Float`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", 0f)
|
||||
pref.value(3.14f)
|
||||
pref.value() shouldBe 3.14f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flow emits default then updated value`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", "initial")
|
||||
|
||||
pref.flow.first() shouldBe "initial"
|
||||
|
||||
pref.value("updated")
|
||||
|
||||
pref.flow.first() shouldBe "updated"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update returns old and new values`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", 10)
|
||||
|
||||
val result = pref.update { it + 5 }
|
||||
result shouldBe DataStoreValue.Updated(old = 10, new = 15)
|
||||
pref.value() shouldBe 15
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update transforms from current value`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", "hello")
|
||||
|
||||
pref.value("world")
|
||||
val result = pref.update { "$it!" }
|
||||
result shouldBe DataStoreValue.Updated(old = "world", new = "world!")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valueBlocking get returns current value`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", "blocking_default")
|
||||
pref.valueBlocking shouldBe "blocking_default"
|
||||
|
||||
pref.value("new_blocking")
|
||||
pref.valueBlocking shouldBe "new_blocking"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valueBlocking set writes value`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("test_key", 0)
|
||||
pref.valueBlocking = 42
|
||||
pref.value() shouldBe 42
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keyName returns the preference key name`() = runTest2 {
|
||||
val ds = createDataStore()
|
||||
val pref = ds.createValue("my.special.key", true)
|
||||
pref.keyName shouldBe "my.special.key"
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package eu.darken.capod.common.preferences
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.JsonDataException
|
||||
import com.squareup.moshi.Moshi
|
||||
import eu.darken.capod.common.theming.ThemeMode
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import testhelpers.json.toComparableJson
|
||||
import testhelpers.preferences.MockSharedPreferences
|
||||
|
||||
class FlowPreferenceMoshiTest : BaseTest() {
|
||||
|
||||
private val mockPreferences = MockSharedPreferences()
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class TestGson(
|
||||
val string: String = "",
|
||||
val boolean: Boolean = true,
|
||||
val float: Float = 1.0f,
|
||||
val int: Int = 1,
|
||||
val long: Long = 1L
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `reading and writing using manual reader and writer`() = runTest {
|
||||
val testData1 = TestGson(string = "teststring")
|
||||
val testData2 = TestGson(string = "update")
|
||||
val moshi = Moshi.Builder().build()
|
||||
FlowPreference<TestGson?>(
|
||||
preferences = mockPreferences,
|
||||
key = "testKey",
|
||||
rawReader = moshiReader(moshi, testData1),
|
||||
rawWriter = moshiWriter(moshi)
|
||||
).apply {
|
||||
value shouldBe testData1
|
||||
flow.first() shouldBe testData1
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
|
||||
update {
|
||||
it shouldBe testData1
|
||||
it!!.copy(string = "update")
|
||||
}
|
||||
|
||||
value shouldBe testData2
|
||||
flow.first() shouldBe testData2
|
||||
(mockPreferences.dataMapPeek.values.first() as String).toComparableJson() shouldBe """
|
||||
{
|
||||
"string":"update",
|
||||
"boolean":true,
|
||||
"float":1.0,
|
||||
"int":1,
|
||||
"long":1
|
||||
}
|
||||
""".toComparableJson()
|
||||
|
||||
update {
|
||||
it shouldBe testData2
|
||||
null
|
||||
}
|
||||
value shouldBe testData1
|
||||
flow.first() shouldBe testData1
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading and writing using autocreated reader and writer`() = runTest {
|
||||
val testData1 = TestGson(string = "teststring")
|
||||
val testData2 = TestGson(string = "update")
|
||||
val moshi = Moshi.Builder().build()
|
||||
|
||||
mockPreferences.createFlowPreference<TestGson?>(
|
||||
key = "testKey",
|
||||
defaultValue = testData1,
|
||||
moshi = moshi
|
||||
).apply {
|
||||
value shouldBe testData1
|
||||
flow.first() shouldBe testData1
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
|
||||
update {
|
||||
it shouldBe testData1
|
||||
it!!.copy(string = "update")
|
||||
}
|
||||
|
||||
value shouldBe testData2
|
||||
flow.first() shouldBe testData2
|
||||
(mockPreferences.dataMapPeek.values.first() as String).toComparableJson() shouldBe """
|
||||
{
|
||||
"string":"update",
|
||||
"boolean":true,
|
||||
"float":1.0,
|
||||
"int":1,
|
||||
"long":1
|
||||
}
|
||||
""".toComparableJson()
|
||||
|
||||
update {
|
||||
it shouldBe testData2
|
||||
null
|
||||
}
|
||||
value shouldBe testData1
|
||||
flow.first() shouldBe testData1
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enum serialization`() = runTest {
|
||||
val moshi = Moshi.Builder().build()
|
||||
val monitorMode = mockPreferences.createFlowPreference(
|
||||
"core.monitor.mode",
|
||||
MonitorMode.AUTOMATIC,
|
||||
moshi
|
||||
)
|
||||
|
||||
monitorMode.value shouldBe MonitorMode.AUTOMATIC
|
||||
monitorMode.update { MonitorMode.MANUAL }
|
||||
monitorMode.value shouldBe MonitorMode.MANUAL
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bad enum value throws without fallback`() = runTest {
|
||||
val moshi = Moshi.Builder().build()
|
||||
mockPreferences.edit().putString("theme.mode", "\"theme.mode.bogus\"").apply()
|
||||
|
||||
assertThrows<JsonDataException> {
|
||||
mockPreferences.createFlowPreference(
|
||||
key = "theme.mode",
|
||||
defaultValue = ThemeMode.SYSTEM,
|
||||
moshi = moshi,
|
||||
onErrorFallbackToDefault = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bad enum value returns default with fallback`() = runTest {
|
||||
val moshi = Moshi.Builder().build()
|
||||
mockPreferences.edit().putString("theme.mode", "\"theme.mode.bogus\"").apply()
|
||||
|
||||
val pref = mockPreferences.createFlowPreference(
|
||||
key = "theme.mode",
|
||||
defaultValue = ThemeMode.SYSTEM,
|
||||
moshi = moshi,
|
||||
onErrorFallbackToDefault = true,
|
||||
)
|
||||
|
||||
pref.value shouldBe ThemeMode.SYSTEM
|
||||
pref.flow.first() shouldBe ThemeMode.SYSTEM
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `corrupt json returns default with fallback`() = runTest {
|
||||
val moshi = Moshi.Builder().build()
|
||||
mockPreferences.edit().putString("theme.mode", "not-json-at-all").apply()
|
||||
|
||||
val pref = mockPreferences.createFlowPreference(
|
||||
key = "theme.mode",
|
||||
defaultValue = ThemeMode.DARK,
|
||||
moshi = moshi,
|
||||
onErrorFallbackToDefault = true,
|
||||
)
|
||||
|
||||
pref.value shouldBe ThemeMode.DARK
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid enum roundtrips with fallback enabled`() = runTest {
|
||||
val moshi = Moshi.Builder().build()
|
||||
val pref = mockPreferences.createFlowPreference(
|
||||
key = "theme.mode",
|
||||
defaultValue = ThemeMode.SYSTEM,
|
||||
moshi = moshi,
|
||||
onErrorFallbackToDefault = true,
|
||||
)
|
||||
|
||||
pref.value shouldBe ThemeMode.SYSTEM
|
||||
pref.update { ThemeMode.DARK }
|
||||
pref.value shouldBe ThemeMode.DARK
|
||||
pref.flow.first() shouldBe ThemeMode.DARK
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package eu.darken.capod.common.preferences
|
||||
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import testhelpers.preferences.MockSharedPreferences
|
||||
|
||||
class FlowPreferenceTest : BaseTest() {
|
||||
|
||||
private val mockPreferences = MockSharedPreferences()
|
||||
|
||||
@Test
|
||||
fun `reading and writing strings`() = runTest {
|
||||
mockPreferences.createFlowPreference<String?>(
|
||||
key = "testKey",
|
||||
defaultValue = "default"
|
||||
).apply {
|
||||
value shouldBe "default"
|
||||
flow.first() shouldBe "default"
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
|
||||
update {
|
||||
it shouldBe "default"
|
||||
"newvalue"
|
||||
}
|
||||
|
||||
value shouldBe "newvalue"
|
||||
flow.first() shouldBe "newvalue"
|
||||
mockPreferences.dataMapPeek.values.first() shouldBe "newvalue"
|
||||
|
||||
update {
|
||||
it shouldBe "newvalue"
|
||||
null
|
||||
}
|
||||
value shouldBe "default"
|
||||
flow.first() shouldBe "default"
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading and writing boolean`() = runTest {
|
||||
mockPreferences.createFlowPreference<Boolean?>(
|
||||
key = "testKey",
|
||||
defaultValue = true
|
||||
).apply {
|
||||
value shouldBe true
|
||||
flow.first() shouldBe true
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
|
||||
update {
|
||||
it shouldBe true
|
||||
false
|
||||
}
|
||||
|
||||
value shouldBe false
|
||||
flow.first() shouldBe false
|
||||
mockPreferences.dataMapPeek.values.first() shouldBe false
|
||||
|
||||
update {
|
||||
it shouldBe false
|
||||
null
|
||||
}
|
||||
value shouldBe true
|
||||
flow.first() shouldBe true
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading and writing long`() = runTest {
|
||||
mockPreferences.createFlowPreference<Long?>(
|
||||
key = "testKey",
|
||||
defaultValue = 9000L
|
||||
).apply {
|
||||
value shouldBe 9000L
|
||||
flow.first() shouldBe 9000L
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
|
||||
update {
|
||||
it shouldBe 9000L
|
||||
9001L
|
||||
}
|
||||
|
||||
value shouldBe 9001L
|
||||
flow.first() shouldBe 9001L
|
||||
mockPreferences.dataMapPeek.values.first() shouldBe 9001L
|
||||
|
||||
update {
|
||||
it shouldBe 9001L
|
||||
null
|
||||
}
|
||||
value shouldBe 9000L
|
||||
flow.first() shouldBe 9000L
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading and writing integer`() = runTest {
|
||||
mockPreferences.createFlowPreference<Long?>(
|
||||
key = "testKey",
|
||||
defaultValue = 123
|
||||
).apply {
|
||||
value shouldBe 123
|
||||
flow.first() shouldBe 123
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
|
||||
update {
|
||||
it shouldBe 123
|
||||
44
|
||||
}
|
||||
|
||||
value shouldBe 44
|
||||
flow.first() shouldBe 44
|
||||
mockPreferences.dataMapPeek.values.first() shouldBe 44
|
||||
|
||||
update {
|
||||
it shouldBe 44
|
||||
null
|
||||
}
|
||||
value shouldBe 123
|
||||
flow.first() shouldBe 123
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading and writing float`() = runTest {
|
||||
mockPreferences.createFlowPreference<Float?>(
|
||||
key = "testKey",
|
||||
defaultValue = 3.6f
|
||||
).apply {
|
||||
value shouldBe 3.6f
|
||||
flow.first() shouldBe 3.6f
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
|
||||
update {
|
||||
it shouldBe 3.6f
|
||||
15000f
|
||||
}
|
||||
|
||||
value shouldBe 15000f
|
||||
flow.first() shouldBe 15000f
|
||||
mockPreferences.dataMapPeek.values.first() shouldBe 15000f
|
||||
|
||||
update {
|
||||
it shouldBe 15000f
|
||||
null
|
||||
}
|
||||
value shouldBe 3.6f
|
||||
flow.first() shouldBe 3.6f
|
||||
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user