feat(config): redo config tab — lazy list, full-coverage search, per-field reset (#898)
- LazyColumn + precomputed flat dot-path value map: O(1) lookups, only
visible fields compose (829 live leaves on a real config)
- Search now matches keys, labels, descriptions, categories, CURRENT
values and select options; non-schema ('Other') paths are searchable too.
Result count + category chip per hit while searching
- Per-field reset-to-default icon; clear-to-blank honoring schema
'clearable'; searchable dropdown honoring schema 'searchable'
(select-all-free fresh filter, exact-match commits on blur)
- Number/object/list editors keep local text state: intermediate typing
('0.') no longer drops keystrokes; objects/lists edit as multi-line JSON
with validation
- Honor backend searchable/clearable schema flags (SchemaField model)
- Category reset now previews default values immediately
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
@@ -26,6 +26,8 @@ data class SchemaField(
|
|||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val category: String? = null,
|
val category: String? = null,
|
||||||
val options: List<String>? = null,
|
val options: List<String>? = null,
|
||||||
|
val searchable: Boolean = false,
|
||||||
|
val clearable: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.m57.hermescontrol.ui.config
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.SchemaField
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One renderable row of the config form: a schema-driven field or an
|
||||||
|
* uncovered ("Other") config path. All rows are FLAT dot-paths — the nested
|
||||||
|
* config response is flattened once on load so the form does O(1) lookups.
|
||||||
|
*/
|
||||||
|
data class ConfigRow(
|
||||||
|
val key: String,
|
||||||
|
val field: SchemaField?,
|
||||||
|
val value: JsonElement?,
|
||||||
|
) {
|
||||||
|
/** Human label: last path segment, underscores → spaces. */
|
||||||
|
val label: String =
|
||||||
|
key.split(".").last().replace("_", " ").replaceFirstChar { it.uppercase() }
|
||||||
|
|
||||||
|
/** Search/display text of the current value. */
|
||||||
|
val valueText: String = value?.let(::jsonText) ?: ""
|
||||||
|
|
||||||
|
val category: String = if (field == null) "Other" else field.category ?: "general"
|
||||||
|
|
||||||
|
val isUncovered: Boolean = field == null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten a nested config map into dot-path → leaf-value pairs. */
|
||||||
|
fun flattenConfig(nested: Map<String, JsonElement>): Map<String, JsonElement> {
|
||||||
|
val flat = mutableMapOf<String, JsonElement>()
|
||||||
|
|
||||||
|
fun walk(
|
||||||
|
prefix: String,
|
||||||
|
map: Map<String, JsonElement>,
|
||||||
|
) {
|
||||||
|
for ((key, value) in map) {
|
||||||
|
val dotPath = if (prefix.isEmpty()) key else "$prefix.$key"
|
||||||
|
if (value is JsonObject) {
|
||||||
|
walk(dotPath, value)
|
||||||
|
} else {
|
||||||
|
flat[dotPath] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk("", nested)
|
||||||
|
return flat
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when [dotPath] is a schema key or lives under one (ancestor-or-self). */
|
||||||
|
fun isCoveredBySchema(
|
||||||
|
dotPath: String,
|
||||||
|
schemaKeys: Set<String>,
|
||||||
|
): Boolean = schemaKeys.any { dotPath == it || dotPath.startsWith("$it.") }
|
||||||
|
|
||||||
|
/** Flat dot-paths present in config but not covered by the schema (sorted). */
|
||||||
|
fun collectUncoveredPaths(
|
||||||
|
flatValues: Map<String, JsonElement>,
|
||||||
|
schemaKeys: Set<String>,
|
||||||
|
): List<String> =
|
||||||
|
flatValues.keys
|
||||||
|
.filterNot { isCoveredBySchema(it, schemaKeys) }
|
||||||
|
.sorted()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a row against a search query. Covers the key, human label, schema
|
||||||
|
* description, category, the CURRENT VALUE, and every select option — so
|
||||||
|
* searching "gpt-4o", "daytona" or "kittentts" finds the right field even
|
||||||
|
* though none of them appear in a key or description.
|
||||||
|
*/
|
||||||
|
fun rowMatchesQuery(
|
||||||
|
row: ConfigRow,
|
||||||
|
query: String,
|
||||||
|
): Boolean {
|
||||||
|
if (query.isBlank()) return true
|
||||||
|
val q = query.lowercase()
|
||||||
|
if (row.key.lowercase().contains(q)) return true
|
||||||
|
if (row.label.lowercase().contains(q)) return true
|
||||||
|
row.field?.let { field ->
|
||||||
|
if (field.description?.lowercase()?.contains(q) == true) return true
|
||||||
|
if (field.category?.lowercase()?.contains(q) == true) return true
|
||||||
|
field.options?.let { options ->
|
||||||
|
if (options.any { it.lowercase().contains(q) }) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row.valueText.lowercase().contains(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable text form of a JSON value for display and search. */
|
||||||
|
fun jsonText(value: JsonElement): String =
|
||||||
|
when (value) {
|
||||||
|
is JsonPrimitive -> value.content
|
||||||
|
is JsonArray -> value.toString()
|
||||||
|
is JsonObject -> value.toString()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -20,13 +20,17 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
data class ConfigUiState(
|
data class ConfigUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
val config: Map<String, JsonElement>? = null,
|
/** Flattened dot-path → value map (see [flattenConfig]). */
|
||||||
|
val values: Map<String, JsonElement>? = null,
|
||||||
val schema: ConfigSchemaResponse? = null,
|
val schema: ConfigSchemaResponse? = null,
|
||||||
val defaults: Map<String, JsonElement>? = null,
|
val defaults: Map<String, JsonElement>? = null,
|
||||||
|
/** Config paths present but not covered by the schema (the "Other" tab). */
|
||||||
|
val uncoveredPaths: List<String> = emptyList(),
|
||||||
val path: String? = null,
|
val path: String? = null,
|
||||||
val yamlText: String? = null,
|
val yamlText: String? = null,
|
||||||
val modifiedKeys: Set<String> = emptySet(),
|
val modifiedKeys: Set<String> = emptySet(),
|
||||||
@@ -68,7 +72,7 @@ class ConfigViewModel :
|
|||||||
val rawResult = rawDeferred.await()
|
val rawResult = rawDeferred.await()
|
||||||
|
|
||||||
if (configResult is NetworkResult.Success) {
|
if (configResult is NetworkResult.Success) {
|
||||||
val configData = configResult.data
|
val values = flattenConfig(configResult.data)
|
||||||
val schema = (schemaResult as? NetworkResult.Success)?.data
|
val schema = (schemaResult as? NetworkResult.Success)?.data
|
||||||
val defaults = (defaultsResult as? NetworkResult.Success)?.data
|
val defaults = (defaultsResult as? NetworkResult.Success)?.data
|
||||||
val path = (rawResult as? NetworkResult.Success)?.data?.path
|
val path = (rawResult as? NetworkResult.Success)?.data?.path
|
||||||
@@ -76,9 +80,14 @@ class ConfigViewModel :
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
config = configData,
|
values = values,
|
||||||
schema = schema,
|
schema = schema,
|
||||||
defaults = defaults,
|
defaults = defaults,
|
||||||
|
uncoveredPaths =
|
||||||
|
collectUncoveredPaths(
|
||||||
|
values,
|
||||||
|
schema?.fields?.keys ?: emptySet(),
|
||||||
|
),
|
||||||
path = path,
|
path = path,
|
||||||
activeCategory =
|
activeCategory =
|
||||||
if (schema?.category_order?.isNotEmpty() == true) {
|
if (schema?.category_order?.isNotEmpty() == true) {
|
||||||
@@ -108,17 +117,34 @@ class ConfigViewModel :
|
|||||||
value: JsonElement,
|
value: JsonElement,
|
||||||
) {
|
) {
|
||||||
pendingChanges[key] = value
|
pendingChanges[key] = value
|
||||||
val state = _uiState.value
|
|
||||||
|
|
||||||
// Apply the change locally so the UI reflects it immediately
|
|
||||||
val updatedConfig =
|
|
||||||
state.config?.let { config ->
|
|
||||||
applyNestedValue(config, key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
config = updatedConfig,
|
values = it.values?.toMutableMap()?.apply { this[key] = value },
|
||||||
|
modifiedKeys = pendingChanges.keys.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset ONE field to its default value (pending until Save). */
|
||||||
|
fun resetField(key: String) {
|
||||||
|
val state = _uiState.value
|
||||||
|
val defaultVal = state.defaults?.get(key) ?: return
|
||||||
|
pendingChanges[key] = defaultVal
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
values = it.values?.toMutableMap()?.apply { this[key] = defaultVal },
|
||||||
|
modifiedKeys = pendingChanges.keys.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear a clearable field to blank (e.g. timezone → system default). */
|
||||||
|
fun clearField(key: String) {
|
||||||
|
val blank = JsonPrimitive("")
|
||||||
|
pendingChanges[key] = blank
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
values = it.values?.toMutableMap()?.apply { this[key] = blank },
|
||||||
modifiedKeys = pendingChanges.keys.toSet(),
|
modifiedKeys = pendingChanges.keys.toSet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -193,7 +219,9 @@ class ConfigViewModel :
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
config = (configResult as? NetworkResult.Success)?.data ?: it.config,
|
values =
|
||||||
|
(configResult as? NetworkResult.Success)?.data?.let(::flattenConfig)
|
||||||
|
?: it.values,
|
||||||
modifiedKeys = emptySet(),
|
modifiedKeys = emptySet(),
|
||||||
toastMessage = "Configuration saved successfully",
|
toastMessage = "Configuration saved successfully",
|
||||||
)
|
)
|
||||||
@@ -233,7 +261,9 @@ class ConfigViewModel :
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
yamlIsSaving = false,
|
yamlIsSaving = false,
|
||||||
config = (configResult as? NetworkResult.Success)?.data ?: it.config,
|
values =
|
||||||
|
(configResult as? NetworkResult.Success)?.data?.let(::flattenConfig)
|
||||||
|
?: it.values,
|
||||||
toastMessage = "YAML configuration saved",
|
toastMessage = "YAML configuration saved",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -262,15 +292,20 @@ class ConfigViewModel :
|
|||||||
}
|
}
|
||||||
|
|
||||||
var count = 0
|
var count = 0
|
||||||
|
val updatedValues = state.values?.toMutableMap()
|
||||||
for ((key, _) in categoryFields) {
|
for ((key, _) in categoryFields) {
|
||||||
val defaultVal = defaults[key]
|
val defaultVal = defaults[key]
|
||||||
if (defaultVal != null) {
|
if (defaultVal != null) {
|
||||||
pendingChanges[key] = defaultVal
|
pendingChanges[key] = defaultVal
|
||||||
|
updatedValues?.set(key, defaultVal)
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(modifiedKeys = pendingChanges.keys.toSet())
|
it.copy(
|
||||||
|
values = updatedValues ?: it.values,
|
||||||
|
modifiedKeys = pendingChanges.keys.toSet(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
@@ -284,34 +319,6 @@ class ConfigViewModel :
|
|||||||
_uiState.update { it.copy(toastMessage = null) }
|
_uiState.update { it.copy(toastMessage = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply a value at a dot-path like "terminal.backend" into a flat config map. */
|
|
||||||
private fun applyNestedValue(
|
|
||||||
config: Map<String, JsonElement>,
|
|
||||||
dotPath: String,
|
|
||||||
value: JsonElement,
|
|
||||||
): Map<String, JsonElement> {
|
|
||||||
val parts = dotPath.split(".")
|
|
||||||
if (parts.size == 1) {
|
|
||||||
val mutable = config.toMutableMap()
|
|
||||||
mutable[parts[0]] = value
|
|
||||||
return mutable
|
|
||||||
}
|
|
||||||
val topKey = parts.first()
|
|
||||||
val rest = parts.drop(1).joinToString(".")
|
|
||||||
val topValue = config[topKey]
|
|
||||||
val nestedJson = (topValue as? JsonObject) ?: JsonObject(emptyMap())
|
|
||||||
val updatedNested =
|
|
||||||
applyNestedValue(
|
|
||||||
nestedJson,
|
|
||||||
rest,
|
|
||||||
value,
|
|
||||||
)
|
|
||||||
val nestedObj = JsonObject(updatedNested)
|
|
||||||
val mutable = config.toMutableMap()
|
|
||||||
mutable[topKey] = nestedObj
|
|
||||||
return mutable
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a nested JSON changeset from dot-path pending changes. */
|
/** Build a nested JSON changeset from dot-path pending changes. */
|
||||||
private fun buildChangeset(changes: Map<String, JsonElement>): Map<String, JsonElement> {
|
private fun buildChangeset(changes: Map<String, JsonElement>): Map<String, JsonElement> {
|
||||||
val root = mutableMapOf<String, Any>()
|
val root = mutableMapOf<String, Any>()
|
||||||
@@ -333,18 +340,15 @@ class ConfigViewModel :
|
|||||||
current[parts.last()] = value
|
current[parts.last()] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toJsonObject(map: Map<String, Any>): JsonObject {
|
fun toJsonObject(map: Map<String, Any>): Map<String, JsonElement> =
|
||||||
val content =
|
map.mapValues { (_, v) ->
|
||||||
map.mapValues { (_, v) ->
|
if (v is Map<*, *>) {
|
||||||
if (v is Map<*, *>) {
|
@Suppress("UNCHECKED_CAST")
|
||||||
@Suppress("UNCHECKED_CAST")
|
JsonObject(toJsonObject(v as Map<String, Any>))
|
||||||
toJsonObject(v as Map<String, Any>)
|
} else {
|
||||||
} else {
|
v as JsonElement
|
||||||
v as JsonElement
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return JsonObject(content)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return toJsonObject(root)
|
return toJsonObject(root)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.m57.hermescontrol.ui.config
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.SchemaField
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-logic tests for the config form's data layer: flattening the nested
|
||||||
|
* config response into dot-paths, "Other" path discovery, and the search
|
||||||
|
* matcher (which must cover keys, labels, descriptions, categories, CURRENT
|
||||||
|
* VALUES and select options — the value/option coverage was missing before
|
||||||
|
* the redo).
|
||||||
|
*/
|
||||||
|
class ConfigFormDataTest {
|
||||||
|
// ── flattenConfig ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `flattenConfig walks nested objects into dot paths`() {
|
||||||
|
val nested =
|
||||||
|
JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"model" to JsonPrimitive("gpt-4o"),
|
||||||
|
"terminal" to
|
||||||
|
JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"backend" to JsonPrimitive("local"),
|
||||||
|
"timeout" to JsonPrimitive(30),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val flat = flattenConfig(nested)
|
||||||
|
assertEquals(
|
||||||
|
mapOf(
|
||||||
|
"model" to JsonPrimitive("gpt-4o"),
|
||||||
|
"terminal.backend" to JsonPrimitive("local"),
|
||||||
|
"terminal.timeout" to JsonPrimitive(30),
|
||||||
|
),
|
||||||
|
flat,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `flattenConfig keeps arrays and nulls as leaf values`() {
|
||||||
|
val nested =
|
||||||
|
JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"toolsets" to JsonArray(listOf(JsonPrimitive("terminal"), JsonPrimitive("web"))),
|
||||||
|
"unset" to JsonNull,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val flat = flattenConfig(nested)
|
||||||
|
assertEquals(2, flat.size)
|
||||||
|
assertEquals(
|
||||||
|
JsonArray(listOf(JsonPrimitive("terminal"), JsonPrimitive("web"))),
|
||||||
|
flat["toolsets"],
|
||||||
|
)
|
||||||
|
assertEquals(JsonNull, flat["unset"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `flattenConfig on already-flat map is identity`() {
|
||||||
|
val flat = mapOf("a.b" to JsonPrimitive(1), "c" to JsonPrimitive("x"))
|
||||||
|
assertEquals(flat, flattenConfig(flat))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── isCoveredBySchema ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isCoveredBySchema matches exact key and ancestors`() {
|
||||||
|
val schema = setOf("tts.provider", "model")
|
||||||
|
assertTrue(isCoveredBySchema("tts.provider", schema))
|
||||||
|
assertTrue(isCoveredBySchema("model", schema))
|
||||||
|
// Ancestor relationship: key below a schema key is covered
|
||||||
|
assertTrue(isCoveredBySchema("tts.provider.x", schema))
|
||||||
|
assertFalse(isCoveredBySchema("tts.other", schema))
|
||||||
|
// Prefix boundary: "tts.providers" must NOT match "tts.provider"
|
||||||
|
assertFalse(isCoveredBySchema("tts.providers.custom", schema))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── collectUncoveredPaths ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `collectUncoveredPaths finds only schema-orphaned leaves`() {
|
||||||
|
val flat =
|
||||||
|
mapOf(
|
||||||
|
"model" to JsonPrimitive("x"),
|
||||||
|
"tts.provider" to JsonPrimitive("edge"),
|
||||||
|
"tts.providers.custom.voice" to JsonPrimitive("amy"),
|
||||||
|
"dashboard.host" to JsonPrimitive("0.0.0.0"),
|
||||||
|
)
|
||||||
|
val schema = setOf("model", "tts.provider")
|
||||||
|
assertEquals(
|
||||||
|
listOf("dashboard.host", "tts.providers.custom.voice"),
|
||||||
|
collectUncoveredPaths(flat, schema),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── rowMatchesQuery ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun row(
|
||||||
|
key: String,
|
||||||
|
field: SchemaField?,
|
||||||
|
value: kotlinx.serialization.json.JsonElement? = null,
|
||||||
|
) = ConfigRow(key = key, field = field, value = value)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search matches dot key and human label`() {
|
||||||
|
val r = row("model_context_length", SchemaField(type = "number"), JsonPrimitive(8192))
|
||||||
|
assertTrue(rowMatchesQuery(r, "model_context"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "context length"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search matches description and category`() {
|
||||||
|
val r =
|
||||||
|
row(
|
||||||
|
"stt.local.model",
|
||||||
|
SchemaField(type = "select", description = "Local faster-whisper model size", category = "stt"),
|
||||||
|
)
|
||||||
|
assertTrue(rowMatchesQuery(r, "whisper"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "stt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search matches current value`() {
|
||||||
|
val r = row("model", SchemaField(type = "string"), JsonPrimitive("anthropic/claude-sonnet-4.6"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "claude-sonnet"))
|
||||||
|
assertFalse(rowMatchesQuery(r, "gpt-4o"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search matches select options`() {
|
||||||
|
val r =
|
||||||
|
row(
|
||||||
|
"terminal.backend",
|
||||||
|
SchemaField(type = "select", options = listOf("local", "docker", "ssh", "modal", "daytona")),
|
||||||
|
JsonPrimitive("local"),
|
||||||
|
)
|
||||||
|
assertTrue(rowMatchesQuery(r, "daytona"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "docker"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search matches uncovered paths by key and value`() {
|
||||||
|
val r = row("plugins.myplugin.enabled", field = null, value = JsonPrimitive("true"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "myplugin"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "true"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search is case-insensitive and blank query matches all`() {
|
||||||
|
val r = row("Model", SchemaField(type = "string"), JsonPrimitive("OpenAI"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "openai"))
|
||||||
|
assertTrue(rowMatchesQuery(r, "MODEL"))
|
||||||
|
assertTrue(rowMatchesQuery(r, ""))
|
||||||
|
assertTrue(rowMatchesQuery(r, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search does not match unrelated keys`() {
|
||||||
|
val r = row("memory.provider", SchemaField(type = "select"), JsonPrimitive("builtin"))
|
||||||
|
assertFalse(rowMatchesQuery(r, "terminal"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── jsonText ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `jsonText renders primitives, objects and arrays`() {
|
||||||
|
assertEquals("42", jsonText(JsonPrimitive(42)))
|
||||||
|
assertEquals("hello", jsonText(JsonPrimitive("hello")))
|
||||||
|
assertEquals("{\"a\":1}", jsonText(JsonObject(mapOf("a" to JsonPrimitive(1)))))
|
||||||
|
assertEquals("[1,2]", jsonText(JsonArray(listOf(JsonPrimitive(1), JsonPrimitive(2)))))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user