Custom i18n module with HOCON translation files, CLDR-aware pluralization for 40+ locales, namespace::group.item key resolution, :name/:Name/:NAME parameter interpolation, and locale fallback chains. No effect system deps.
297 lines
7.2 KiB
Scala
297 lines
7.2 KiB
Scala
package summer.phrasebook
|
|
|
|
import java.nio.file.Path
|
|
|
|
class PhrasebookTranslatorSpec extends munit.FunSuite:
|
|
|
|
val langDir: Path =
|
|
Path.of(getClass.getClassLoader.getResource("lang/en/lang.conf").toURI).getParent.getParent
|
|
|
|
val overridesDir: Path =
|
|
Path.of(getClass.getClassLoader.getResource("overrides/en/lang.conf").toURI).getParent.getParent
|
|
|
|
def makeTranslator(): PhrasebookTranslator =
|
|
val t = new PhrasebookTranslator(
|
|
initialLocale = LocaleTag("en"),
|
|
initialFallback = Some(LocaleTag("en")),
|
|
)
|
|
t.addNamespace("test.plugin", langDir)
|
|
t
|
|
|
|
override def beforeEach(context: BeforeEach): Unit =
|
|
KeyParser.clearCache()
|
|
|
|
// --- Basic get ---
|
|
|
|
test("get simple translation") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.get("test.plugin::lang.labels.pluginName"),
|
|
"Blog",
|
|
)
|
|
}
|
|
|
|
test("get nested translation") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.get("test.plugin::lang.post.create_title"),
|
|
"Create Post",
|
|
)
|
|
}
|
|
|
|
test("get returns raw key when not found") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.get("test.plugin::lang.nonexistent.key"),
|
|
"test.plugin::lang.nonexistent.key",
|
|
)
|
|
}
|
|
|
|
test("get from different group (validation)") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.get("test.plugin::validation.required"),
|
|
"The :attribute field is required.",
|
|
)
|
|
}
|
|
|
|
// --- Parameter replacement ---
|
|
|
|
test("get with replacements") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.get("test.plugin::lang.welcome", Map("name" -> "Alice")),
|
|
"Welcome, Alice!",
|
|
)
|
|
}
|
|
|
|
test("get with multiple replacements and case variants") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.get("test.plugin::lang.greeting", Map("name" -> "alice", "count" -> "3")),
|
|
"Hello Alice, you have 3 new messages",
|
|
)
|
|
}
|
|
|
|
test("get with uppercase replacement") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.get("test.plugin::lang.shout", Map("name" -> "alice")),
|
|
"ATTENTION ALICE!",
|
|
)
|
|
}
|
|
|
|
test("validation message with attribute replacement") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.get("test.plugin::validation.required", Map("attribute" -> "email")),
|
|
"The email field is required.",
|
|
)
|
|
}
|
|
|
|
// --- Locale switching ---
|
|
|
|
test("get translation in Polish") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.get("test.plugin::lang.labels.posts", locale = Some(LocaleTag("pl"))),
|
|
"Wpisy",
|
|
)
|
|
}
|
|
|
|
test("get translation in German") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.get("test.plugin::lang.labels.posts", locale = Some(LocaleTag("de"))),
|
|
"Beiträge",
|
|
)
|
|
}
|
|
|
|
test("setLocale changes default locale") {
|
|
val t = makeTranslator()
|
|
t.setLocale(LocaleTag("pl"))
|
|
assertEquals(
|
|
t.get("test.plugin::lang.post.create_title"),
|
|
"Utwórz wpis",
|
|
)
|
|
}
|
|
|
|
// --- Fallback ---
|
|
|
|
test("falls back to English when key missing in Polish") {
|
|
val t = makeTranslator()
|
|
// Polish has no "shout" key, should fall back to English
|
|
assertEquals(
|
|
t.get("test.plugin::lang.shout", Map("name" -> "alice"), locale = Some(LocaleTag("pl"))),
|
|
"ATTENTION ALICE!",
|
|
)
|
|
}
|
|
|
|
test("falls back to English when key missing in German") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.get("test.plugin::lang.post.edit_title", locale = Some(LocaleTag("de"))),
|
|
"Edit Post",
|
|
)
|
|
}
|
|
|
|
// --- Pluralization via choice ---
|
|
|
|
test("choice English singular") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.choice("test.plugin::lang.posts_count", 1),
|
|
"1 post",
|
|
)
|
|
}
|
|
|
|
test("choice English plural") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.choice("test.plugin::lang.posts_count", 5),
|
|
"5 posts",
|
|
)
|
|
}
|
|
|
|
test("choice Polish forms") {
|
|
val t = makeTranslator()
|
|
// 1 wpis (one)
|
|
assertEquals(
|
|
t.choice("test.plugin::lang.posts_count", 1, locale = Some(LocaleTag("pl"))),
|
|
"1 wpis",
|
|
)
|
|
// 2 wpisy (few)
|
|
assertEquals(
|
|
t.choice("test.plugin::lang.posts_count", 2, locale = Some(LocaleTag("pl"))),
|
|
"2 wpisy",
|
|
)
|
|
// 5 wpisów (many)
|
|
assertEquals(
|
|
t.choice("test.plugin::lang.posts_count", 5, locale = Some(LocaleTag("pl"))),
|
|
"5 wpisów",
|
|
)
|
|
// 22 wpisy (few: n%10=2, n%100=22)
|
|
assertEquals(
|
|
t.choice("test.plugin::lang.posts_count", 22, locale = Some(LocaleTag("pl"))),
|
|
"22 wpisy",
|
|
)
|
|
}
|
|
|
|
test("choice with explicit range syntax") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.choice("test.plugin::lang.items_range", 0),
|
|
"No items",
|
|
)
|
|
assertEquals(
|
|
t.choice("test.plugin::lang.items_range", 1),
|
|
"One item",
|
|
)
|
|
assertEquals(
|
|
t.choice("test.plugin::lang.items_range", 3),
|
|
"A few items",
|
|
)
|
|
assertEquals(
|
|
t.choice("test.plugin::lang.items_range", 10),
|
|
"Many items",
|
|
)
|
|
}
|
|
|
|
// --- has ---
|
|
|
|
test("has returns true for existing key") {
|
|
val t = makeTranslator()
|
|
assert(t.has("test.plugin::lang.labels.pluginName"))
|
|
}
|
|
|
|
test("has returns false for missing key") {
|
|
val t = makeTranslator()
|
|
assert(!t.has("test.plugin::lang.nonexistent"))
|
|
}
|
|
|
|
// --- Namespace alias ---
|
|
|
|
test("namespace alias resolves correctly") {
|
|
val t = makeTranslator()
|
|
t.addAlias("blog", "test.plugin")
|
|
assertEquals(
|
|
t.get("blog::lang.labels.pluginName"),
|
|
"Blog",
|
|
)
|
|
}
|
|
|
|
// --- Runtime set ---
|
|
|
|
test("set overrides a translation at runtime") {
|
|
val t = makeTranslator()
|
|
t.set("test.plugin::lang.labels.pluginName", "My Blog")
|
|
assertEquals(
|
|
t.get("test.plugin::lang.labels.pluginName"),
|
|
"My Blog",
|
|
)
|
|
}
|
|
|
|
test("set override is locale-specific") {
|
|
val t = makeTranslator()
|
|
t.set("test.plugin::lang.labels.pluginName", "My Blog", Some(LocaleTag("en")))
|
|
// Polish should still return original
|
|
assertEquals(
|
|
t.get("test.plugin::lang.labels.pluginName", locale = Some(LocaleTag("pl"))),
|
|
"Blog",
|
|
)
|
|
}
|
|
|
|
// --- clearCache ---
|
|
|
|
test("clearCache resets loaded translations") {
|
|
val t = makeTranslator()
|
|
// Load something
|
|
t.get("test.plugin::lang.labels.pluginName")
|
|
// Clear
|
|
t.clearCache()
|
|
// Should still work (reloads)
|
|
assertEquals(
|
|
t.get("test.plugin::lang.labels.pluginName"),
|
|
"Blog",
|
|
)
|
|
}
|
|
|
|
// --- setLocale validation ---
|
|
|
|
test("setLocale rejects path traversal") {
|
|
val t = makeTranslator()
|
|
intercept[IllegalArgumentException] {
|
|
t.setLocale(LocaleTag("../etc/passwd"))
|
|
}
|
|
}
|
|
|
|
// --- getLocale / getFallback ---
|
|
|
|
test("getLocale returns current locale") {
|
|
val t = makeTranslator()
|
|
assertEquals(t.getLocale.value, "en")
|
|
t.setLocale(LocaleTag("pl"))
|
|
assertEquals(t.getLocale.value, "pl")
|
|
}
|
|
|
|
test("getFallback returns configured fallback") {
|
|
val t = makeTranslator()
|
|
assertEquals(t.getFallback.map(_.value), Some("en"))
|
|
}
|
|
|
|
test("setFallback changes fallback locale") {
|
|
val t = makeTranslator()
|
|
t.setFallback(LocaleTag("de"))
|
|
assertEquals(t.getFallback.map(_.value), Some("de"))
|
|
}
|
|
|
|
// --- Unknown namespace ---
|
|
|
|
test("unknown namespace returns raw key") {
|
|
val t = makeTranslator()
|
|
assertEquals(
|
|
t.get("unknown.ns::lang.key"),
|
|
"unknown.ns::lang.key",
|
|
)
|
|
}
|