Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,60 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import kotlinx.serialization.json.JsonDecoder
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lenient boolean serializer that decodes both JSON booleans (`true`/`false`)
|
||||||
|
* and raw SQLite numeric booleans (`1`/`0`), as well as string representations (`"true"`/`"1"`).
|
||||||
|
*
|
||||||
|
* This guards against backend versions (e.g. Hermes Agent <= 0.19.0) where database
|
||||||
|
* boolean flags like `pinned` are returned as raw integer values (issue #966).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
object LenientNullableBooleanSerializer : KSerializer<Boolean?> {
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("LenientNullableBoolean", PrimitiveKind.BOOLEAN)
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): Boolean? {
|
||||||
|
if (decoder is JsonDecoder) {
|
||||||
|
val element = decoder.decodeJsonElement()
|
||||||
|
if (element is JsonPrimitive) {
|
||||||
|
element.booleanOrNull?.let { return it }
|
||||||
|
element.intOrNull?.let { return it != 0 }
|
||||||
|
val str = element.content.trim().lowercase()
|
||||||
|
return when (str) {
|
||||||
|
"true", "1" -> true
|
||||||
|
"false", "0" -> false
|
||||||
|
"null", "" -> null
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return decoder.decodeBoolean()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun serialize(
|
||||||
|
encoder: Encoder,
|
||||||
|
value: Boolean?,
|
||||||
|
) {
|
||||||
|
if (value == null) {
|
||||||
|
encoder.encodeNull()
|
||||||
|
} else {
|
||||||
|
encoder.encodeBoolean(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SessionListResponse(
|
data class SessionListResponse(
|
||||||
@@ -25,6 +80,8 @@ data class SessionInfo(
|
|||||||
val terminal_backend: String? = null,
|
val terminal_backend: String? = null,
|
||||||
// Durable "keep" flag: pinned sessions are exempt from the auto-archive
|
// Durable "keep" flag: pinned sessions are exempt from the auto-archive
|
||||||
// sweep and are surfaced first in the history list (sorted client-side).
|
// sweep and are surfaced first in the history list (sorted client-side).
|
||||||
|
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
|
||||||
|
@Serializable(with = LenientNullableBooleanSerializer::class)
|
||||||
val pinned: Boolean? = null,
|
val pinned: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -271,6 +271,47 @@ class ModelSerializationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSessionInfoDeserialization_pinnedToleratesIntAndBooleanAndString() {
|
||||||
|
// Backend returns integer 0 (SQLite style)
|
||||||
|
val jsonIntZero = """{"id": "s1", "pinned": 0}"""
|
||||||
|
val s1 = json.decodeFromString<SessionInfo>(jsonIntZero)
|
||||||
|
assertEquals(false, s1.pinned)
|
||||||
|
|
||||||
|
// Backend returns integer 1
|
||||||
|
val jsonIntOne = """{"id": "s2", "pinned": 1}"""
|
||||||
|
val s2 = json.decodeFromString<SessionInfo>(jsonIntOne)
|
||||||
|
assertEquals(true, s2.pinned)
|
||||||
|
|
||||||
|
// Backend returns boolean false
|
||||||
|
val jsonBoolFalse = """{"id": "s3", "pinned": false}"""
|
||||||
|
val s3 = json.decodeFromString<SessionInfo>(jsonBoolFalse)
|
||||||
|
assertEquals(false, s3.pinned)
|
||||||
|
|
||||||
|
// Backend returns boolean true
|
||||||
|
val jsonBoolTrue = """{"id": "s4", "pinned": true}"""
|
||||||
|
val s4 = json.decodeFromString<SessionInfo>(jsonBoolTrue)
|
||||||
|
assertEquals(true, s4.pinned)
|
||||||
|
|
||||||
|
// Backend returns null or omitted
|
||||||
|
val jsonNull = """{"id": "s5", "pinned": null}"""
|
||||||
|
val s5 = json.decodeFromString<SessionInfo>(jsonNull)
|
||||||
|
assertNull(s5.pinned)
|
||||||
|
|
||||||
|
val jsonOmitted = """{"id": "s6"}"""
|
||||||
|
val s6 = json.decodeFromString<SessionInfo>(jsonOmitted)
|
||||||
|
assertNull(s6.pinned)
|
||||||
|
|
||||||
|
// Backend returns string "1" / "0" / "true" / "false"
|
||||||
|
val jsonStrOne = """{"id": "s7", "pinned": "1"}"""
|
||||||
|
val s7 = json.decodeFromString<SessionInfo>(jsonStrOne)
|
||||||
|
assertEquals(true, s7.pinned)
|
||||||
|
|
||||||
|
val jsonStrFalse = """{"id": "s8", "pinned": "false"}"""
|
||||||
|
val s8 = json.decodeFromString<SessionInfo>(jsonStrFalse)
|
||||||
|
assertEquals(false, s8.pinned)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() {
|
fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() {
|
||||||
val jsonStr = "{}"
|
val jsonStr = "{}"
|
||||||
|
|||||||
Reference in New Issue
Block a user