Files
summer-phrasebook/src/test/scala/summer/phrasebook/PhrasebookTranslatorSpec.scala
Jakub Zych 8684d2fb7b Implement Phase 1 core translation engine (95 tests passing)
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.
2026-02-22 23:30:16 +01:00

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",
)
}