Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ bin/
### Mac OS ###
.DS_Store
logs/latest.log
/.temp
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -38,6 +46,17 @@ class TranslationManager(

private val notFoundTranslations = mutableListOf<String>()

// 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.
*
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -140,6 +162,80 @@ class TranslationManager(
}
}

/**
* Replace translations for a language in the cache atomically.
*/
private fun setLanguage(name: String, translations: List<Translation>) {
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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ class JsonTranslationSource(private val directory: File) : TranslationSource {

val gson = Gson()
val type = object : TypeToken<Map<String, String>>() {}.type
val data: Map<String, String> = gson.fromJson(langFile.reader(), type)
// Ensure the reader is closed to avoid file locks on Windows during tests
val data: Map<String, String> = langFile.bufferedReader().use { reader ->
gson.fromJson(reader, type)
}
return data.map { (key, value) ->
Translation(
language,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> = listOf("en_US"),
) : TranslationSource {
private val langs = initialLanguages.toMutableList()

// backing store: lang -> key -> value
val store: MutableMap<String, MutableMap<String, String>> = mutableMapOf()

// shared flow to simulate live updates
private val updates = MutableSharedFlow<LiveUpdateEvent>(extraBufferCapacity = 64)

override suspend fun getLanguages(): List<String> = langs.toList()

override suspend fun getTranslations(language: String): List<Translation> =
store[language].orEmpty().map { (k, v) -> Translation(language, k, v) }

override fun liveUpdates(): Flow<LiveUpdateEvent> = 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 })
}
}
Original file line number Diff line number Diff line change
@@ -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<String> = listOf("en_US")
override suspend fun getTranslations(language: String): List<Translation> =
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)
}
}
Loading
Loading