diff --git a/README.md b/README.md index 32a6f33..084b0e9 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ last notification it received. `src/main/resources/META-INF/services/org.speculum.core.ModuleFactory`. - On startup the Speculum app scans its `plugins/*.jar`, loads each in a `URLClassLoader`, and finds factories via the JDK `ServiceLoader`. +- The factory declares its config options through `settingsSchema()`, so the + Speculum admin console renders real controls for them. See + [Module settings](#module-settings). ## Requirements @@ -76,9 +79,48 @@ Start the Speculum app pointed at that `plugins/` folder (see the Speculum repo for run instructions). The example loads automatically and renders at `top_center`. +## Module settings + +The module reads two config keys, `greeting` and `tickStep`. Rather than leaving +the admin console to show them as raw key/value text rows, the factory declares +them with `settingsSchema()`: + +```kotlin +override fun settingsSchema(): List = + listOf( + SettingSpec( + key = "greeting", + label = "Greeting", + default = "Hello, Speculum!", + help = "Headline text shown at the top of the module.", + ), + SettingSpec( + key = "tickStep", + label = "Tick step", + type = SettingType.INT, + default = "1", + min = 1, + max = 100, + help = "How much the refresh counter advances on each refresh.", + ), + ) +``` + +The console knows nothing about this module by name — it renders whatever the +schema returns, served through the config-server's `GET /api/modules`. Types are +`STRING`, `INT`, `BOOL`, `ENUM`, `TEXT`, `URL`, `IP` and `CUSTOM`; `advanced = +true` folds a rarely-touched option away. Keep each `default` equal to the +fallback the module code itself reads, since that is what the console shows as +the hint for an unset key — `defaultConfig()` is a separate thing, the starting +config a freshly added module gets. + +The method has a default empty implementation, so a module that declares nothing +still works and simply keeps the raw key/value rows. Any key saved in a config +but absent from the schema also falls back to a raw row. + ## Bumping the API version `mirror-api` is released on every `v*` tag of the Speculum project; the version is the tag without its leading `v`. Update `mirrorApi` in [`gradle/libs.versions.toml`](gradle/libs.versions.toml) to compile against a -different release (current: `0.5.7`). +different release (current: `1.3.0`). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3879820..488cfa2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ compose = "1.12.1" composeMaterial3 = "1.9.0" # material3 is versioned independently of the Compose BOM coroutines = "1.11.0" # The published Speculum plugin API to compile against (release tag without "v"). -mirrorApi = "1.2.2" +mirrorApi = "1.3.0" # Quality gates. ktlint = "14.2.0" # gradle plugin org.jlleitschuh.gradle.ktlint detekt = "1.23.8" diff --git a/src/main/kotlin/org/speculum/modules/example/ExampleModuleFactory.kt b/src/main/kotlin/org/speculum/modules/example/ExampleModuleFactory.kt index a9398fe..e6f1493 100644 --- a/src/main/kotlin/org/speculum/modules/example/ExampleModuleFactory.kt +++ b/src/main/kotlin/org/speculum/modules/example/ExampleModuleFactory.kt @@ -1,13 +1,16 @@ package org.speculum.modules.example import org.speculum.config.ModuleConfig +import org.speculum.config.SettingSpec +import org.speculum.config.SettingType import org.speculum.core.MirrorModule import org.speculum.core.ModuleFactory /** * SPI entry point discovered by the host app via ServiceLoader (declared in * META-INF/services/org.speculum.core.ModuleFactory). Provides the module - * name, a constructor, and a default placement so it appears automatically. + * name, a constructor, a default placement so it appears automatically, and + * the schema the admin console renders its settings from. */ class ExampleModuleFactory : ModuleFactory { override val name: String = "example" @@ -21,4 +24,30 @@ class ExampleModuleFactory : ModuleFactory { refreshIntervalMs = 3000, config = mapOf("greeting" to "Loaded from JAR!", "tickStep" to "2"), ) + + /** + * Declares the `config` keys above so the Speculum admin console shows + * labelled, typed controls instead of raw key/value text rows. Each + * `default` is the fallback [ExampleModule] itself reads, which is what the + * console shows as a hint while the key is unset — so it is deliberately + * not the same as the starting value [defaultConfig] ships. + */ + override fun settingsSchema(): List = + listOf( + SettingSpec( + key = "greeting", + label = "Greeting", + default = "Hello, Speculum!", + help = "Headline text shown at the top of the module.", + ), + SettingSpec( + key = "tickStep", + label = "Tick step", + type = SettingType.INT, + default = "1", + min = 1, + max = 100, + help = "How much the refresh counter advances on each refresh.", + ), + ) } diff --git a/src/test/kotlin/org/speculum/modules/example/ExampleModuleFactoryTest.kt b/src/test/kotlin/org/speculum/modules/example/ExampleModuleFactoryTest.kt index 6ac64af..821037a 100644 --- a/src/test/kotlin/org/speculum/modules/example/ExampleModuleFactoryTest.kt +++ b/src/test/kotlin/org/speculum/modules/example/ExampleModuleFactoryTest.kt @@ -1,9 +1,12 @@ package org.speculum.modules.example import org.speculum.config.ModuleConfig +import org.speculum.config.SettingType +import org.speculum.config.undeclaredKeys import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertTrue class ExampleModuleFactoryTest { private val factory = ExampleModuleFactory() @@ -29,4 +32,36 @@ class ExampleModuleFactoryTest { assertEquals("Loaded from JAR!", config.config["greeting"]) assertEquals("2", config.config["tickStep"]) } + + @Test + fun `settingsSchema declares every key the default config ships`() { + val undeclared = factory.settingsSchema().undeclaredKeys(factory.defaultConfig().config) + + assertEquals(emptyList(), undeclared, "defaultConfig keys missing from settingsSchema") + } + + @Test + fun `settingsSchema describes the documented defaults and bounds`() { + val schema = factory.settingsSchema() + + assertEquals(listOf("greeting", "tickStep"), schema.map { it.key }) + + val greeting = schema.first { it.key == "greeting" } + assertEquals(SettingType.STRING, greeting.type) + assertEquals("Hello, Speculum!", greeting.default) + + val tickStep = schema.first { it.key == "tickStep" } + assertEquals(SettingType.INT, tickStep.type) + assertEquals("1", tickStep.default) + assertEquals(1, tickStep.min) + assertEquals(100, tickStep.max) + } + + @Test + fun `every schema entry is labelled and helpful`() { + for (spec in factory.settingsSchema()) { + assertTrue(spec.label.isNotBlank(), "${spec.key}: blank label") + assertTrue(spec.help.isNotBlank(), "${spec.key}: blank help") + } + } }