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()) + } +}