From b7fd80bcbf65b4690a50685c9c98767355ac8808 Mon Sep 17 00:00:00 2001 From: Liam Sage Date: Wed, 26 Nov 2025 20:58:25 +0100 Subject: [PATCH] Add live update support and tests for translations Introduces coroutine-based live update handling in TranslationManager, allowing automatic cache refreshes on translation events. Updates JsonTranslationSource to close file readers properly. Adds comprehensive unit tests for translation loading, fallback, hooks, and live update scenarios. Also updates build.gradle.kts to include kotlinx-coroutines-test for testing. --- .gitignore | 1 + build.gradle.kts | 1 + .../translation/TranslationManager.kt | 96 ++++++++++ .../sources/JsonTranslationSource.kt | 5 +- .../translation/TranslationManagerTest.kt | 173 ++++++++++++++++++ .../klassicx/translation/TranslationsTest.kt | 37 ++++ .../sources/JsonTranslationSourceTest.kt | 35 ++++ 7 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 src/test/kotlin/cc/modlabs/klassicx/translation/TranslationManagerTest.kt create mode 100644 src/test/kotlin/cc/modlabs/klassicx/translation/TranslationsTest.kt create mode 100644 src/test/kotlin/cc/modlabs/klassicx/translation/sources/JsonTranslationSourceTest.kt diff --git a/.gitignore b/.gitignore index ce8c50f..cbfc752 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ bin/ ### Mac OS ### .DS_Store logs/latest.log +/.temp diff --git a/build.gradle.kts b/build.gradle.kts index 42d4da9..580ffb9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,6 +19,7 @@ repositories { dependencies { testImplementation(kotlin("test")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") diff --git a/src/main/kotlin/cc/modlabs/klassicx/translation/TranslationManager.kt b/src/main/kotlin/cc/modlabs/klassicx/translation/TranslationManager.kt index 2aa8099..1471b85 100644 --- a/src/main/kotlin/cc/modlabs/klassicx/translation/TranslationManager.kt +++ b/src/main/kotlin/cc/modlabs/klassicx/translation/TranslationManager.kt @@ -3,10 +3,18 @@ package cc.modlabs.klassicx.translation import cc.modlabs.klassicx.extensions.getInternalKlassicxLogger import cc.modlabs.klassicx.tools.TempStorage import cc.modlabs.klassicx.translation.interfaces.TranslationSource +import cc.modlabs.klassicx.translation.live.HelloEvent +import cc.modlabs.klassicx.translation.live.KeyCreatedEvent +import cc.modlabs.klassicx.translation.live.KeyDeletedEvent +import cc.modlabs.klassicx.translation.live.KeyUpdatedEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.util.concurrent.locks.ReadWriteLock import java.util.concurrent.locks.ReentrantReadWriteLock @@ -38,6 +46,17 @@ class TranslationManager( private val notFoundTranslations = mutableListOf() + // Coroutine machinery for optional live updates from the source + private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var liveJob: Job? = null + private val liveMutex = Mutex() + + init { + // Attempt to start live updates collection if the source supports it + // This is best-effort and will no-op if live updates are not available. + scope.launch { ensureLiveUpdatesStarted() } + } + /** * Retrieves all translations from the cache. * @@ -75,6 +94,9 @@ class TranslationManager( } } } + + // Ensure we are subscribed to live updates after an initial load is triggered + ensureLiveUpdatesStarted() } private suspend fun loadTranslationsForLanguage(languageCode: String) { @@ -140,6 +162,80 @@ class TranslationManager( } } + /** + * Replace translations for a language in the cache atomically. + */ + private fun setLanguage(name: String, translations: List) { + try { + lock.writeLock().lock() + cache = cache + (name to translations) + } finally { + lock.writeLock().unlock() + } + } + + /** + * Start collecting live updates if the source provides a Flow. + * Safe to call many times; only starts once. + */ + private suspend fun ensureLiveUpdatesStarted() { + liveMutex.withLock { + if (liveJob != null) return + val flow = source.liveUpdates() ?: run { + getInternalKlassicxLogger().info("Translation source does not provide live updates; continuing without WS.") + return + } + getInternalKlassicxLogger().info("Starting live translation updates listener…") + liveJob = scope.launch { + try { + flow.collect { evt -> + when (evt) { + is HelloEvent -> { + getInternalKlassicxLogger().info("LiveUpdates connected for translation ${evt.translationId} with permission ${evt.permission}") + } + is KeyUpdatedEvent -> { + // Refresh only the affected locale + try { + val language = evt.locale + getInternalKlassicxLogger().info("LiveUpdates: key_updated -> refreshing locale '$language'") + val fresh = source.getTranslations(language) + setLanguage(language, fresh) + } catch (t: Throwable) { + getInternalKlassicxLogger().error("Failed to refresh locale after key_updated", t) + } + } + is KeyCreatedEvent, is KeyDeletedEvent -> { + // Key set changed; refresh all locales currently present in cache + try { + val locales = try { + lock.readLock().lock() + cache.keys.toList() + } finally { + lock.readLock().unlock() + } + if (locales.isEmpty()) return@collect + getInternalKlassicxLogger().info("LiveUpdates: ${evt.type} -> refreshing locales ${locales.joinToString()}") + locales.forEach { lang -> + try { + val fresh = source.getTranslations(lang) + setLanguage(lang, fresh) + } catch (inner: Throwable) { + getInternalKlassicxLogger().error("Failed to refresh locale '$lang' after ${evt.type}", inner) + } + } + } catch (t: Throwable) { + getInternalKlassicxLogger().error("Failed bulk refresh after ${evt.type}", t) + } + } + } + } + } catch (t: Throwable) { + getInternalKlassicxLogger().error("Live updates listener terminated with error", t) + } + } + } + } + /** * Checks if the given name exists in the cache. * diff --git a/src/main/kotlin/cc/modlabs/klassicx/translation/sources/JsonTranslationSource.kt b/src/main/kotlin/cc/modlabs/klassicx/translation/sources/JsonTranslationSource.kt index ea4d534..72f1897 100644 --- a/src/main/kotlin/cc/modlabs/klassicx/translation/sources/JsonTranslationSource.kt +++ b/src/main/kotlin/cc/modlabs/klassicx/translation/sources/JsonTranslationSource.kt @@ -20,7 +20,10 @@ class JsonTranslationSource(private val directory: File) : TranslationSource { val gson = Gson() val type = object : TypeToken>() {}.type - val data: Map = gson.fromJson(langFile.reader(), type) + // Ensure the reader is closed to avoid file locks on Windows during tests + val data: Map = langFile.bufferedReader().use { reader -> + gson.fromJson(reader, type) + } return data.map { (key, value) -> Translation( language, diff --git a/src/test/kotlin/cc/modlabs/klassicx/translation/TranslationManagerTest.kt b/src/test/kotlin/cc/modlabs/klassicx/translation/TranslationManagerTest.kt new file mode 100644 index 0000000..173767b --- /dev/null +++ b/src/test/kotlin/cc/modlabs/klassicx/translation/TranslationManagerTest.kt @@ -0,0 +1,173 @@ +package cc.modlabs.klassicx.translation + +import cc.modlabs.klassicx.translation.interfaces.TranslationSource +import cc.modlabs.klassicx.translation.live.HelloEvent +import cc.modlabs.klassicx.translation.live.KeyCreatedEvent +import cc.modlabs.klassicx.translation.live.KeyDeletedEvent +import cc.modlabs.klassicx.translation.live.KeyUpdatedEvent +import cc.modlabs.klassicx.translation.live.LiveUpdateEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private class FakeTranslationSource( + private val translationId: String = "test", + initialLanguages: List = listOf("en_US"), +) : TranslationSource { + private val langs = initialLanguages.toMutableList() + + // backing store: lang -> key -> value + val store: MutableMap> = mutableMapOf() + + // shared flow to simulate live updates + private val updates = MutableSharedFlow(extraBufferCapacity = 64) + + override suspend fun getLanguages(): List = langs.toList() + + override suspend fun getTranslations(language: String): List = + store[language].orEmpty().map { (k, v) -> Translation(language, k, v) } + + override fun liveUpdates(): Flow = updates + + suspend fun emit(event: LiveUpdateEvent) { updates.emit(event) } + + fun put(lang: String, key: String, value: String) { + store.getOrPut(lang) { mutableMapOf() }[key] = value + if (lang !in langs) langs.add(lang) + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class TranslationManagerTest { + + private suspend fun assertEventuallyEquals( + expected: String, + supplier: suspend () -> String?, + timeoutMs: Long = 1500, + stepMs: Long = 25, + ) { + val start = System.nanoTime() + var last: String? = null + while ((System.nanoTime() - start) / 1_000_000 < timeoutMs) { + last = supplier() + if (last == expected) { + assertEquals(expected, last) + return + } + delay(stepMs) + } + assertEquals(expected, last) + } + + @Test + fun load_and_get_with_placeholders() = runTest { + val src = FakeTranslationSource() + src.put("en_US", "greet", "Hello %name%!") + val manager = TranslationManager(src) + + manager.loadTranslations() + advanceUntilIdle() + + val t = manager.get("en_US", "greet", mapOf("name" to "World")) + assertNotNull(t) + assertEquals("Hello World!", t.message) + } + + @Test + fun fallback_to_en_US_when_missing_language() = runTest { + val src = FakeTranslationSource() + src.put("en_US", "greet", "Hello") + val manager = TranslationManager(src) + + manager.loadTranslations() + advanceUntilIdle() + + val t = manager.get("de_DE", "greet") + assertNotNull(t) + assertEquals("Hello", t.message) + } + + @Test + fun contains_reports_loaded_language() = runTest { + val src = FakeTranslationSource() + src.put("en_US", "a", "A") + val manager = TranslationManager(src) + manager.loadTranslations() + advanceUntilIdle() + assertTrue(manager.contains("en_US")) + } + + @Test + fun returns_null_for_unknown_key_in_fallback_language() = runTest { + val src = FakeTranslationSource() + // no keys at all + val manager = TranslationManager(src) + manager.loadTranslations() + advanceUntilIdle() + val t = manager.get("en_US", "missing") + assertNull(t) + } + + @Test + fun live_update_key_updated_refreshes_single_locale() = runTest { + val src = FakeTranslationSource() + src.put("en_US", "greet", "Hello A") + val manager = TranslationManager(src) + manager.loadTranslations() + advanceUntilIdle() + + // verify baseline + assertEquals("Hello A", manager.get("en_US", "greet")!!.message) + + // update backing store and emit key_updated + src.put("en_US", "greet", "Hello B") + src.emit( + KeyUpdatedEvent( + translationId = "test", + keyId = "id-1", + locale = "en_US", + value = "Hello B", + ts = "2025-01-01T00:00:00Z" + ) + ) + + // allow collector to refresh (uses Default dispatcher, so poll) + assertEventuallyEquals(expected = "Hello B", supplier = { manager.get("en_US", "greet")?.message }) + } + + @Test + fun live_update_key_created_refreshes_all_cached_locales() = runTest { + val src = FakeTranslationSource(initialLanguages = listOf("en_US", "de_DE")) + src.put("en_US", "only_en", "EN") + src.put("de_DE", "only_de", "DE") + val manager = TranslationManager(src) + manager.loadTranslations() + advanceUntilIdle() + + // now add a new key to both languages in the store + src.put("en_US", "new_key", "VALUE_EN") + src.put("de_DE", "new_key", "WERT_DE") + + // emit key_created to trigger bulk refresh + src.emit( + KeyCreatedEvent( + translationId = "test", + keyId = "kid-1", + key = "new_key", + ts = "2025-01-01T00:00:01Z" + ) + ) + + // allow collector to refresh (uses Default dispatcher, so poll) + assertEventuallyEquals(expected = "VALUE_EN", supplier = { manager.get("en_US", "new_key")?.message }) + assertEventuallyEquals(expected = "WERT_DE", supplier = { manager.get("de_DE", "new_key")?.message }) + } +} diff --git a/src/test/kotlin/cc/modlabs/klassicx/translation/TranslationsTest.kt b/src/test/kotlin/cc/modlabs/klassicx/translation/TranslationsTest.kt new file mode 100644 index 0000000..2ba835a --- /dev/null +++ b/src/test/kotlin/cc/modlabs/klassicx/translation/TranslationsTest.kt @@ -0,0 +1,37 @@ +package cc.modlabs.klassicx.translation + +import cc.modlabs.klassicx.translation.interfaces.TranslationHook +import cc.modlabs.klassicx.translation.interfaces.TranslationSource +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private class SimpleSource : TranslationSource { + override suspend fun getLanguages(): List = listOf("en_US") + override suspend fun getTranslations(language: String): List = + listOf(Translation(language, "hello", "Hello")) +} + +class TranslationsTest { + + @Test + fun load_and_getTranslation_with_hook_applied() = runBlocking { + // Install a simple hook that appends an exclamation mark + Translations.registerTranslationHook(TranslationHook { _, _, _, result -> "$result!" }) + + Translations.load(SimpleSource()) + + // Wait until the translation is available (load is async on Default dispatcher) + var value: String? = null + repeat(60) { // up to ~1.2s + value = Translations.getTranslation("en_US", "hello") + if (value != null) return@repeat + delay(20) + } + + assertNotNull(value) + assertEquals("Hello!", value) + } +} diff --git a/src/test/kotlin/cc/modlabs/klassicx/translation/sources/JsonTranslationSourceTest.kt b/src/test/kotlin/cc/modlabs/klassicx/translation/sources/JsonTranslationSourceTest.kt new file mode 100644 index 0000000..4073c9e --- /dev/null +++ b/src/test/kotlin/cc/modlabs/klassicx/translation/sources/JsonTranslationSourceTest.kt @@ -0,0 +1,35 @@ +package cc.modlabs.klassicx.translation.sources + +import cc.modlabs.klassicx.translation.Translation +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class JsonTranslationSourceTest { + + @TempDir + lateinit var tmp: File + + @Test + fun discovers_languages_and_loads_translations() = runBlocking { + // create en_US.json and de_DE.json + File(tmp, "en_US.json").writeText("""{"greet":"Hello","bye":"Bye"}""") + File(tmp, "de_DE.json").writeText("""{"greet":"Hallo"}""") + + val src = JsonTranslationSource(tmp) + + val langs = src.getLanguages().sorted() + assertEquals(listOf("de_DE", "en_US"), langs) + + val en = src.getTranslations("en_US").associateBy(Translation::messageKey) + val de = src.getTranslations("de_DE").associateBy(Translation::messageKey) + + assertEquals("Hello", en["greet"]?.message) + assertEquals("Bye", en["bye"]?.message) + assertEquals("Hallo", de["greet"]?.message) + assertTrue(src.getTranslations("fr_FR").isEmpty()) + } +}