diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-08-03 15:16:39 +0900 |
|---|---|---|
| committer | Sho Sakuma <me@m1sk9.dev> | 2026-08-05 01:16:03 +0900 |
| commit | 576c057bb98dab27c2b97931fc5635603fe1bf57 (patch) | |
| tree | 115faef961f9fb8cf801370e77f33921f5b436b4 | |
| parent | daf9060561656f468a0ae366e5695f5eb74f48f7 (diff) | |
| download | LunaticChat-576c057bb98dab27c2b97931fc5635603fe1bf57.tar.gz LunaticChat-576c057bb98dab27c2b97931fc5635603fe1bf57.tar.bz2 LunaticChat-576c057bb98dab27c2b97931fc5635603fe1bf57.zip | |
refactor: read config.yml into the config classes directly
Every setting's default was written three times - in config.yml, in the
ConfigManager getter call, and on the data class - and copying the file
key by key is what made that necessary. They had already drifted:
checkForUpdates defaulted to false in ConfigManager while both config.yml
and the data class said true.
Worse, features.channelChat.messageLogging was documented in config.yml
with three settings and never parsed at all. ConfigManager did not build
it, so ChannelMessageLoggingConfig() always won and an operator editing
retentionDays or maxFileSizeMB changed nothing. Those settings now take
effect - the documented behaviour, but a real change for anyone whose file
disagrees with the defaults.
KAML deserializes the file straight into the tree, the same way player
settings and channel data are already read, so a default now lives only on
the data class. Two consequences worth stating:
- japaneseConversion.cache and .api are nested classes now, because the
data has to match the file rather than the file being flattened by hand
on the way in. The YAML is unchanged.
- api.retryAttempts is gone from config.yml. It was parsed and stored, but
never reached GoogleIMEClient or RomanjiConverter, so it documented a
knob that did nothing.
Unknown keys are ignored and a malformed file falls back to defaults with
a log line, so neither an old config nor a typo stops the server booting.
The tests parse real YAML instead of a mocked FileConfiguration, which
lets them cover what the mock could not: a partial file, a retired key, a
malformed document, and - the one that would have caught the drift above -
that the bundled config.yml equals the declared defaults.
Co-Authored-By: Claude <noreply@anthropic.com>
14 files changed, 247 insertions, 357 deletions
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt index f39e5d4..710123f 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt @@ -58,8 +58,7 @@ class LunaticChat : override fun onEnable() { saveDefaultConfig() - val configManager = ConfigManager() - configuration = configManager.loadConfiguration(config) + configuration = ConfigManager(logger).loadConfiguration(dataFolder.resolve("config.yml").readText()) if (configuration.debug) { logger.warning("LunaticChat is running in debug mode.") diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt index 9f1de87..bbfaab2 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt @@ -190,8 +190,8 @@ class ServiceInitializer( // Initialize conversion cache val cache = ConversionCache( - cacheFile = plugin.dataFolder.resolve(configuration.features.japaneseConversion.cacheFilePath).toPath(), - maxEntries = configuration.features.japaneseConversion.cacheMaxEntries, + cacheFile = plugin.dataFolder.resolve(configuration.features.japaneseConversion.cache.filePath).toPath(), + maxEntries = configuration.features.japaneseConversion.cache.maxEntries, logger = logger, ) cache.loadFromDisk() @@ -199,7 +199,7 @@ class ServiceInitializer( // Initialize Google IME API client val apiClient = GoogleIMEClient( - timeout = configuration.features.japaneseConversion.apiTimeout.milliseconds, + timeout = configuration.features.japaneseConversion.api.timeout.milliseconds, httpClient = httpClient.value, ) @@ -428,7 +428,7 @@ class ServiceInitializer( // would both be rejected by runAtFixedRate and leave the cache unsaved until the // server stopped. Fall back to the documented default rather than to one second, // which would rewrite the whole cache file every tick anyone chatted. - val configuredInterval = configuration.features.japaneseConversion.cacheSaveIntervalSeconds + val configuredInterval = configuration.features.japaneseConversion.cache.saveIntervalSeconds val intervalSeconds = if (configuredInterval > 0) { configuredInterval.toLong() diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt index 37764bf..cf4fd07 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt @@ -1,113 +1,38 @@ package dev.m1sk9.lunaticChat.paper.config -import dev.m1sk9.lunaticChat.paper.config.key.ChannelChatFeatureConfig -import dev.m1sk9.lunaticChat.paper.config.key.FeaturesConfig -import dev.m1sk9.lunaticChat.paper.config.key.JapaneseConversionFeatureConfig -import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig -import dev.m1sk9.lunaticChat.paper.config.key.QuickRepliesFeatureConfig -import dev.m1sk9.lunaticChat.paper.config.key.VelocityIntegrationConfig -import dev.m1sk9.lunaticChat.paper.i18n.Language -import org.bukkit.configuration.file.FileConfiguration +import com.charleskorn.kaml.Yaml +import com.charleskorn.kaml.YamlConfiguration +import java.util.logging.Logger /** - * Manages loading and parsing of plugin configuration. - * Converted from singleton to dependency injection pattern for better testability. + * Reads config.yml into [LunaticChatConfiguration]. + * + * The file is deserialized directly rather than copied key by key, so a default lives only on the + * data class. The hand-written mapper it replaced repeated every default in a second place, and + * they had already drifted - checkForUpdates disagreed with both config.yml and the data class, + * and the whole messageLogging block was documented but never read. */ -class ConfigManager { - fun loadConfiguration(configFile: FileConfiguration): LunaticChatConfiguration { - val loadedConfig = - LunaticChatConfiguration( - features = - FeaturesConfig( - quickReplies = - QuickRepliesFeatureConfig( - enabled = - configFile.getBoolean("features.quickReplies.enabled", true), - ), - japaneseConversion = - JapaneseConversionFeatureConfig( - enabled = configFile.getBoolean("features.japaneseConversion.enabled", false), - cacheMaxEntries = configFile.getInt("features.japaneseConversion.cache.maxEntries", 500), - cacheSaveIntervalSeconds = - configFile.getInt( - "features.japaneseConversion.cache.saveIntervalSeconds", - 300, - ), - cacheFilePath = - configFile.getString( - "features.japaneseConversion.cache.filePath", - "conversion_cache.json", - )!!, - apiTimeout = - configFile.getLong( - "features.japaneseConversion.api.timeout", - 3000, - ), - apiRetryAttempts = configFile.getInt("features.japaneseConversion.api.retryAttempts", 2), - ), - channelChat = - ChannelChatFeatureConfig( - enabled = configFile.getBoolean("features.channelChat.enabled", false), - maxChannelsPerServer = configFile.getInt("features.channelChat.maxChannelsPerServer", 0), - maxMembersPerChannel = configFile.getInt("features.channelChat.maxMembersPerChannel", 0), - maxMembershipPerPlayer = configFile.getInt("features.channelChat.maxMembershipPerPlayer", 0), - ), - velocityIntegration = - VelocityIntegrationConfig( - enabled = configFile.getBoolean("features.velocityIntegration.enabled", false), - crossServerGlobalChat = - configFile.getBoolean( - "features.velocityIntegration.crossServerGlobalChat", - false, - ), - crossServerDirectMessage = - configFile.getBoolean( - "features.velocityIntegration.crossServerDirectMessage", - false, - ), - serverName = - configFile.getString( - "features.velocityIntegration.serverName", - "Unknown", - ) ?: "Unknown", - messageDeduplicationCacheSize = - configFile.getInt( - "features.velocityIntegration.messageDeduplicationCacheSize", - 100, - ), - ), - ), - messageFormat = - MessageFormatConfig( - directMessageFormat = - configFile.getString( - "messageFormat.directMessageFormat", - "§7[§e{sender} §7>> §e{recipient}§7] §f{message}", - )!!, - channelMessageFormat = - configFile.getString( - "messageFormat.channelMessageFormat", - "§7[§b#{channel}§7] §e{sender}: §f{message}", - )!!, - crossServerGlobalChatFormat = - configFile.getString( - "messageFormat.crossServerGlobalChatFormat", - "§7[§6{server}§7] §e{sender}: §f{message}", - )!!, - ), - debug = configFile.getBoolean("debug", false), - checkForUpdates = configFile.getBoolean("checkForUpdates", false), - userSettingsFilePath = - configFile.getString( - "userSettingsFilePath", - "player-settings.yaml", - )!!, - language = - Language.fromCode( - configFile.getString("language", "en")!!, - ), - ) +class ConfigManager( + private val logger: Logger, +) { + private val yaml = + Yaml( + configuration = + YamlConfiguration( + // A config.yml from a newer build, or one carrying a key we have retired, + // should not stop the plugin from starting. + strictMode = false, + ), + ) - return loadedConfig - } + /** + * Parses [contents] as config.yml, falling back to defaults if it cannot be read. + */ + fun loadConfiguration(contents: String): LunaticChatConfiguration = + try { + yaml.decodeFromString(LunaticChatConfiguration.serializer(), contents) + } catch (e: Exception) { + logger.severe("Failed to read config.yml, falling back to defaults: ${e.message}") + LunaticChatConfiguration() + } } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt index 92bc157..170f9c2 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt @@ -3,12 +3,23 @@ package dev.m1sk9.lunaticChat.paper.config import dev.m1sk9.lunaticChat.paper.config.key.FeaturesConfig import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig import dev.m1sk9.lunaticChat.paper.i18n.Language +import dev.m1sk9.lunaticChat.paper.i18n.LanguageSerializer +import kotlinx.serialization.Serializable +/** + * The shape of config.yml. + * + * Every property carries the default that config.yml documents, and it is the only place that + * default is written: the file is deserialized straight into this tree, so a key the user has not + * set simply falls back here. + */ +@Serializable data class LunaticChatConfiguration( - val features: FeaturesConfig, - val messageFormat: MessageFormatConfig, + val features: FeaturesConfig = FeaturesConfig(), + val messageFormat: MessageFormatConfig = MessageFormatConfig(), val debug: Boolean = false, val userSettingsFilePath: String = "player-settings.yaml", val checkForUpdates: Boolean = true, + @Serializable(with = LanguageSerializer::class) val language: Language = Language.EN, ) diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt index 9a64428..88a2c0d 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable @Serializable data class ChannelChatFeatureConfig( - val enabled: Boolean, + val enabled: Boolean = false, val maxChannelsPerServer: Int = 0, val maxMembersPerChannel: Int = 0, val maxMembershipPerPlayer: Int = 0, diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/FeaturesConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/FeaturesConfig.kt index 044838d..69e1e1b 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/FeaturesConfig.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/FeaturesConfig.kt @@ -1,8 +1,11 @@ package dev.m1sk9.lunaticChat.paper.config.key +import kotlinx.serialization.Serializable + +@Serializable data class FeaturesConfig( - val quickReplies: QuickRepliesFeatureConfig, - val japaneseConversion: JapaneseConversionFeatureConfig, - val channelChat: ChannelChatFeatureConfig, - val velocityIntegration: VelocityIntegrationConfig, + val quickReplies: QuickRepliesFeatureConfig = QuickRepliesFeatureConfig(), + val japaneseConversion: JapaneseConversionFeatureConfig = JapaneseConversionFeatureConfig(), + val channelChat: ChannelChatFeatureConfig = ChannelChatFeatureConfig(), + val velocityIntegration: VelocityIntegrationConfig = VelocityIntegrationConfig(), ) diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt index c329085..061b152 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt @@ -1,10 +1,35 @@ package dev.m1sk9.lunaticChat.paper.config.key +import kotlinx.serialization.Serializable + +/** + * @property enabled Whether romaji-to-Japanese conversion is offered to players + * @property cache Conversion cache tuning + * @property api Google IME request tuning + */ +@Serializable data class JapaneseConversionFeatureConfig( - val enabled: Boolean, - val cacheMaxEntries: Int, - val cacheSaveIntervalSeconds: Int, - val cacheFilePath: String, - val apiTimeout: Long, - val apiRetryAttempts: Int, + val enabled: Boolean = false, + val cache: ConversionCacheConfig = ConversionCacheConfig(), + val api: ConversionApiConfig = ConversionApiConfig(), +) + +/** + * @property maxEntries Upper bound on cached word conversions + * @property saveIntervalSeconds How often the cache is flushed to disk + * @property filePath Cache file, relative to the plugin data folder + */ +@Serializable +data class ConversionCacheConfig( + val maxEntries: Int = 500, + val saveIntervalSeconds: Int = 300, + val filePath: String = "conversion_cache.json", +) + +/** + * @property timeout Per-request timeout in milliseconds + */ +@Serializable +data class ConversionApiConfig( + val timeout: Long = 3000, ) diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt index d31ec4e..481f259 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt @@ -1,5 +1,8 @@ package dev.m1sk9.lunaticChat.paper.config.key +import kotlinx.serialization.Serializable + +@Serializable data class MessageFormatConfig( val directMessageFormat: String = "§7[§e{sender} §7>> §e{recipient}§7] §f{message}", val channelMessageFormat: String = "§7[§b#{channel}§7] §e{sender}: §f{message}", diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt index cf0c00d..ed8a59a 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt @@ -1,5 +1,8 @@ package dev.m1sk9.lunaticChat.paper.config.key +import kotlinx.serialization.Serializable + +@Serializable data class QuickRepliesFeatureConfig( - val enabled: Boolean, + val enabled: Boolean = true, ) diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt index 2b2babb..897e946 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt @@ -1,5 +1,8 @@ package dev.m1sk9.lunaticChat.paper.config.key +import kotlinx.serialization.Serializable + +@Serializable data class VelocityIntegrationConfig( val enabled: Boolean = false, val crossServerGlobalChat: Boolean = false, diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageSerializer.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageSerializer.kt new file mode 100644 index 0000000..080f0d5 --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageSerializer.kt @@ -0,0 +1,28 @@ +package dev.m1sk9.lunaticChat.paper.i18n + +import kotlinx.serialization.KSerializer +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 + +/** + * Reads a [Language] from its code, as written in config.yml. + * + * Goes through [Language.fromCode] rather than the enum name so the lookup stays + * case-insensitive and an unrecognised code falls back to English, instead of failing the whole + * configuration over one typo. + */ +object LanguageSerializer : KSerializer<Language> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Language", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): Language = Language.fromCode(decoder.decodeString()) + + override fun serialize( + encoder: Encoder, + value: Language, + ) { + encoder.encodeString(value.code) + } +} diff --git a/platform-paper/src/main/resources/config.yml b/platform-paper/src/main/resources/config.yml index 111d90b..37bfdc6 100644 --- a/platform-paper/src/main/resources/config.yml +++ b/platform-paper/src/main/resources/config.yml @@ -46,8 +46,6 @@ features: api: # Specify the timeout duration (in milliseconds) for API requests to the Romanization conversion service. timeout: 3000 - # Specify the number of retry attempts for failed API requests to the Romanization conversion service. - retryAttempts: 2 channelChat: # If enabled, channel-based chat functionality will be activated. enabled: false diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt index 2d2a7c7..4bca21f 100644 --- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt +++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt @@ -6,6 +6,7 @@ import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelRole import dev.m1sk9.lunaticChat.engine.settings.PlayerChatSettings import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration import dev.m1sk9.lunaticChat.paper.config.key.ChannelChatFeatureConfig +import dev.m1sk9.lunaticChat.paper.config.key.ConversionCacheConfig import dev.m1sk9.lunaticChat.paper.config.key.FeaturesConfig import dev.m1sk9.lunaticChat.paper.config.key.JapaneseConversionFeatureConfig import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig @@ -86,11 +87,7 @@ object TestUtils { japaneseConversion = JapaneseConversionFeatureConfig( enabled = japaneseConversionEnabled, - cacheMaxEntries = 500, - cacheSaveIntervalSeconds = 300, - cacheFilePath = "test-conversion-cache.json", - apiTimeout = 3000, - apiRetryAttempts = 2, + cache = ConversionCacheConfig(filePath = "test-conversion-cache.json"), ), channelChat = ChannelChatFeatureConfig( diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt index 3f936f6..e76eeb9 100644 --- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt +++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt @@ -1,288 +1,183 @@ package dev.m1sk9.lunaticChat.paper.config +import dev.m1sk9.lunaticChat.paper.TestUtils import dev.m1sk9.lunaticChat.paper.i18n.Language -import io.mockk.every -import io.mockk.mockk -import org.bukkit.configuration.file.FileConfiguration import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotNull import kotlin.test.assertTrue /** - * Tests for ConfigManager to ensure proper configuration loading and parsing. + * Tests for ConfigManager. + * + * These parse real YAML rather than a mocked Bukkit FileConfiguration, so they exercise the same + * path the plugin uses at startup - including what happens to a file that is incomplete, stale or + * malformed. */ class ConfigManagerTest { - private fun createMockConfig(values: Map<String, Any> = emptyMap()): FileConfiguration { - val config = mockk<FileConfiguration>(relaxed = true) + private fun load(yaml: String) = ConfigManager(TestUtils.TestLogger()).loadConfiguration(yaml) - // Set up default values - every { config.getBoolean(any(), any()) } answers { - val key = firstArg<String>() - val default = secondArg<Boolean>() - (values[key] as? Boolean) ?: default - } - - every { config.getInt(any(), any()) } answers { - val key = firstArg<String>() - val default = secondArg<Int>() - (values[key] as? Int) ?: default - } - - every { config.getLong(any(), any()) } answers { - val key = firstArg<String>() - val default = secondArg<Long>() - (values[key] as? Long) ?: default - } - - every { config.getString(any(), any()) } answers { - val key = firstArg<String>() - val default = secondArg<String>() - (values[key] as? String) ?: default - } - - every { config.getString(any()) } answers { - val key = firstArg<String>() - values[key] as? String - } - - return config - } + /** The file shipped in resources, which is what a fresh install actually reads. */ + private val bundledConfig: String = + checkNotNull(javaClass.classLoader.getResourceAsStream("config.yml")) { + "config.yml missing from resources" + }.bufferedReader().use { it.readText() } @Test - fun `loadConfiguration should load all default values`() { - val configManager = ConfigManager() - val mockConfig = createMockConfig() - - val configuration = configManager.loadConfiguration(mockConfig) + fun `the bundled config parses`() { + val config = load(bundledConfig) - assertNotNull(configuration) - assertTrue(configuration.features.quickReplies.enabled) - assertFalse(configuration.features.japaneseConversion.enabled) - assertFalse(configuration.features.channelChat.enabled) - assertFalse(configuration.debug) - assertFalse(configuration.checkForUpdates) - assertEquals(Language.EN, configuration.language) + assertFalse(config.debug) + assertEquals("player-settings.yaml", config.userSettingsFilePath) + assertEquals(Language.EN, config.language) } @Test - fun `loadConfiguration should load quick replies configuration`() { - val configManager = ConfigManager() - val mockConfig = - createMockConfig( - mapOf( - "features.quickReplies.enabled" to false, - ), - ) - - val configuration = configManager.loadConfiguration(mockConfig) - - assertFalse(configuration.features.quickReplies.enabled) + fun `the bundled config agrees with the declared defaults`() { + // The data class is meant to be the single source of every default. If config.yml ships a + // different value for a key, one of the two is lying to the operator. + assertEquals(LunaticChatConfiguration(), load(bundledConfig)) } @Test - fun `loadConfiguration should load Japanese conversion configuration`() { - val configManager = ConfigManager() - val mockConfig = - createMockConfig( - mapOf( - "features.japaneseConversion.enabled" to true, - "features.japaneseConversion.cache.maxEntries" to 1000, - "features.japaneseConversion.cache.saveIntervalSeconds" to 600, - "features.japaneseConversion.cache.filePath" to "custom_cache.json", - "features.japaneseConversion.api.timeout" to 5000L, - "features.japaneseConversion.api.retryAttempts" to 3, - ), - ) - - val configuration = configManager.loadConfiguration(mockConfig) - - assertTrue(configuration.features.japaneseConversion.enabled) - assertEquals(1000, configuration.features.japaneseConversion.cacheMaxEntries) - assertEquals(600, configuration.features.japaneseConversion.cacheSaveIntervalSeconds) - assertEquals("custom_cache.json", configuration.features.japaneseConversion.cacheFilePath) - assertEquals(5000L, configuration.features.japaneseConversion.apiTimeout) - assertEquals(3, configuration.features.japaneseConversion.apiRetryAttempts) + fun `an empty document falls back to defaults`() { + assertEquals(LunaticChatConfiguration(), load("{}")) } @Test - fun `loadConfiguration should load channel chat configuration`() { - val configManager = ConfigManager() - val mockConfig = - createMockConfig( - mapOf( - "features.channelChat.enabled" to true, - "features.channelChat.maxChannelsPerServer" to 20, - "features.channelChat.maxMembersPerChannel" to 100, - "features.channelChat.maxMembershipPerPlayer" to 10, - ), - ) - - val configuration = configManager.loadConfiguration(mockConfig) + fun `a config missing a section keeps that section's defaults`() { + val config = load("debug: true") - assertTrue(configuration.features.channelChat.enabled) - assertEquals(20, configuration.features.channelChat.maxChannelsPerServer) - assertEquals(100, configuration.features.channelChat.maxMembersPerChannel) - assertEquals(10, configuration.features.channelChat.maxMembershipPerPlayer) + assertTrue(config.debug) + assertEquals(LunaticChatConfiguration().features, config.features) + assertEquals(LunaticChatConfiguration().messageFormat, config.messageFormat) } @Test - fun `loadConfiguration should load message format configuration`() { - val configManager = ConfigManager() - val customDMFormat = "DM: {sender} -> {recipient}: {message}" - val customChannelFormat = "[{channel}] {sender}: {message}" - val mockConfig = - createMockConfig( - mapOf( - "messageFormat.directMessageFormat" to customDMFormat, - "messageFormat.channelMessageFormat" to customChannelFormat, - ), + fun `values present in the file win over the defaults`() { + val config = + load( + """ + debug: true + checkForUpdates: false + userSettingsFilePath: "custom.yaml" + language: "ja" + """.trimIndent(), ) - val configuration = configManager.loadConfiguration(mockConfig) - - assertEquals(customDMFormat, configuration.messageFormat.directMessageFormat) - assertEquals(customChannelFormat, configuration.messageFormat.channelMessageFormat) + assertTrue(config.debug) + assertFalse(config.checkForUpdates) + assertEquals("custom.yaml", config.userSettingsFilePath) + assertEquals(Language.JA, config.language) } @Test - fun `loadConfiguration should load debug and update check settings`() { - val configManager = ConfigManager() - val mockConfig = - createMockConfig( - mapOf( - "debug" to true, - "checkForUpdates" to true, - ), - ) - - val configuration = configManager.loadConfiguration(mockConfig) - - assertTrue(configuration.debug) - assertTrue(configuration.checkForUpdates) + fun `an unknown language code falls back to English`() { + assertEquals(Language.EN, load("""language: "kl"""").language) } @Test - fun `loadConfiguration should load custom settings file path`() { - val configManager = ConfigManager() - val customPath = "custom-player-settings.yaml" - val mockConfig = - createMockConfig( - mapOf( - "userSettingsFilePath" to customPath, - ), - ) - - val configuration = configManager.loadConfiguration(mockConfig) - - assertEquals(customPath, configuration.userSettingsFilePath) + fun `a language code is matched regardless of case`() { + assertEquals(Language.JA, load("""language: "JA"""").language) } @Test - fun `loadConfiguration should load Japanese language`() { - val configManager = ConfigManager() - val mockConfig = - createMockConfig( - mapOf( - "language" to "ja", - ), + fun `nested japanese conversion settings are read`() { + val config = + load( + """ + features: + japaneseConversion: + enabled: true + cache: + maxEntries: 1000 + saveIntervalSeconds: 600 + filePath: "custom_cache.json" + api: + timeout: 5000 + """.trimIndent(), ) - val configuration = configManager.loadConfiguration(mockConfig) - - assertEquals(Language.JA, configuration.language) + val japanese = config.features.japaneseConversion + assertTrue(japanese.enabled) + assertEquals(1000, japanese.cache.maxEntries) + assertEquals(600, japanese.cache.saveIntervalSeconds) + assertEquals("custom_cache.json", japanese.cache.filePath) + assertEquals(5000L, japanese.api.timeout) } @Test - fun `loadConfiguration should handle unknown language code`() { - val configManager = ConfigManager() - val mockConfig = - createMockConfig( - mapOf( - "language" to "fr", // French not supported - ), + fun `channel message logging is read from the file`() { + // This block was documented in config.yml but never parsed, so editing it did nothing. + val config = + load( + """ + features: + channelChat: + enabled: true + messageLogging: + enabled: false + retentionDays: 7 + maxFileSizeMB: 20 + """.trimIndent(), ) - val configuration = configManager.loadConfiguration(mockConfig) - - // Should fall back to English - assertEquals(Language.EN, configuration.language) + val logging = config.features.channelChat.messageLogging + assertFalse(logging.enabled) + assertEquals(7, logging.retentionDays) + assertEquals(20, logging.maxFileSizeMB) } @Test - fun `loadConfiguration should use default values when keys are missing`() { - val configManager = ConfigManager() - val mockConfig = createMockConfig(emptyMap()) - - val configuration = configManager.loadConfiguration(mockConfig) + fun `velocity integration settings are read from the file`() { + val config = + load( + """ + features: + velocityIntegration: + enabled: true + crossServerGlobalChat: true + crossServerDirectMessage: true + serverName: "survival" + messageDeduplicationCacheSize: 250 + """.trimIndent(), + ) - // All features should have their defaults - assertTrue(configuration.features.quickReplies.enabled) - assertEquals(500, configuration.features.japaneseConversion.cacheMaxEntries) - assertEquals(300, configuration.features.japaneseConversion.cacheSaveIntervalSeconds) - assertEquals("conversion_cache.json", configuration.features.japaneseConversion.cacheFilePath) - assertEquals(3000L, configuration.features.japaneseConversion.apiTimeout) - assertEquals(2, configuration.features.japaneseConversion.apiRetryAttempts) - assertEquals(0, configuration.features.channelChat.maxChannelsPerServer) - assertEquals(0, configuration.features.channelChat.maxMembersPerChannel) - assertEquals(0, configuration.features.channelChat.maxMembershipPerPlayer) + val velocity = config.features.velocityIntegration + assertTrue(velocity.enabled) + assertTrue(velocity.crossServerGlobalChat) + assertTrue(velocity.crossServerDirectMessage) + assertEquals("survival", velocity.serverName) + assertEquals(250, velocity.messageDeduplicationCacheSize) } @Test - fun `loadConfiguration can be called multiple times`() { - val configManager = ConfigManager() - val mockConfig1 = - createMockConfig( - mapOf("debug" to true), - ) - val mockConfig2 = - createMockConfig( - mapOf("debug" to false), + fun `message formats are read from the file`() { + val config = + load( + """ + messageFormat: + directMessageFormat: "DM {sender} {message}" + channelMessageFormat: "CH {channel} {message}" + crossServerGlobalChatFormat: "GL {server} {message}" + """.trimIndent(), ) - val config1 = configManager.loadConfiguration(mockConfig1) - val config2 = configManager.loadConfiguration(mockConfig2) - - assertTrue(config1.debug) - assertFalse(config2.debug) + assertEquals("DM {sender} {message}", config.messageFormat.directMessageFormat) + assertEquals("CH {channel} {message}", config.messageFormat.channelMessageFormat) + assertEquals("GL {server} {message}", config.messageFormat.crossServerGlobalChatFormat) } @Test - fun `loadConfiguration should handle all features enabled`() { - val configManager = ConfigManager() - val mockConfig = - createMockConfig( - mapOf( - "features.quickReplies.enabled" to true, - "features.japaneseConversion.enabled" to true, - "features.channelChat.enabled" to true, - ), - ) + fun `a key this build no longer knows is ignored`() { + // An operator upgrading from a build that had extra keys must still be able to start. + val config = load("debug: true\nsomeRetiredOption: 42") - val configuration = configManager.loadConfiguration(mockConfig) - - assertTrue(configuration.features.quickReplies.enabled) - assertTrue(configuration.features.japaneseConversion.enabled) - assertTrue(configuration.features.channelChat.enabled) + assertTrue(config.debug) } @Test - fun `loadConfiguration should handle all features disabled`() { - val configManager = ConfigManager() - val mockConfig = - createMockConfig( - mapOf( - "features.quickReplies.enabled" to false, - "features.japaneseConversion.enabled" to false, - "features.channelChat.enabled" to false, - ), - ) - - val configuration = configManager.loadConfiguration(mockConfig) - - assertFalse(configuration.features.quickReplies.enabled) - assertFalse(configuration.features.japaneseConversion.enabled) - assertFalse(configuration.features.channelChat.enabled) + fun `a malformed file falls back to defaults instead of failing startup`() { + assertEquals(LunaticChatConfiguration(), load("features: [this is not a map")) } } |
