Speak the operator's language

The interface is German or English, chosen per operator profile on the GUI tab, in force at
once in every open window, and remembered across restarts. 376 texts in both languages.

The texts are ordinary .properties files that nobody has to compile: a contributor copies the
English one, translates the values, and opens a pull request. A third module -- build tooling
that never ships -- turns them into Kotlin before anything compiles, which is what makes a
mistyped key and a wrong argument count compile errors rather than things an operator
discovers. It lives in the main build rather than buildSrc because buildSrc's tests do not run
on `./gradlew build`, measured, and a generator whose tests never run is the exact failure it
exists to prevent.

Incompleteness is allowed and incorrectness is not. A missing key warns and falls back to
English, so a partial contributed language can be submitted at all; an orphan key, a
placeholder the translation drops, a duplicate key or an unreadable file fail the build with
the generator's own message naming the file. The argument count always comes from the English
base. German is held complete by a test because it ships; a contributed language is not.

What is deliberately not translated is the harder half. Protocol frames and chat commands
would break the connection or go unread; the beacon texts, auto answers, snippets and
shortcuts go to other radio amateurs and sit in every operator's stored profile; the 273
println calls in core are diagnostics, and a German stack trace is worse for whoever reads the
bug report; and QRA, QRG, QTF, LOS, Fresnel and wkd read the same in either language. Three
tests guard the boundary by measuring behaviour rather than text: the protocol frames built
under three locales, every preference default compared by reflection, and no translation value
being a protocol command. The reflective one is the only one that covers what nobody thought
of.

One insight turned up three times. Anything built outside a composition freezes the language it
was built in: the window title, the settings tab strip and every column header are all
constructed once at startup -- the columns from Java -- so each now holds a function read when
it is drawn. That also fixed something older and unrelated to language: the main window's title
was a fixed "KST4Contest (Compose)" for the whole session, where the JavaFX window had carried
the live connection line. It carries it again.

A column's id is never translated. It is what the remembered widths and order are keyed on.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:36:01 +02:00
1 parent da8a28d250
commit e44436750b
85 files changed
+3552 -508

No files matched your search

@@ -0,0 +1,168 @@
package kst4contest.i18n
import java.io.File
/**
* What generation reported.
*
* @param warnings incomplete translations -- allowed, and the reason a partial contribution
* can be submitted at all
* @param coverage one line per language, so a pull request can be judged at a glance
*/
data class GenerationResult(val warnings: List<String>, val coverage: List<String>)
/**
* English, because the source text lives in the code and AGENTS.md requires it there.
*
* Deliberately a second declaration: the generated `Translations.BASE_LANGUAGE` says the same
* thing on the application's side. They cannot be shared -- this module is build tooling and
* is not on the application's classpath -- and the generated source is written from this
* constant, so a drift would show up in `GenerationTest`.
*/
const val BASE_LANGUAGE: String = "en"
/**
* Reads every translation file in a directory and writes the generated Kotlin.
*
* Incompleteness is allowed, incorrectness is not. A missing key warns and falls back to the
* base at runtime; an orphan key, a placeholder mismatch, a duplicate key, an unreadable file
* or two keys collapsing to one identifier all throw, which fails the build with this
* message.
*
* Anything in the directory that is not a translation file -- a README for contributors, say
* -- is ignored rather than refused: documentation must not break the build.
*
* @param inputDirectory holds `strings_<code>.properties`
* @param outputDirectory receives the generated source and any worksheets
* @return the warnings and the coverage lines
* @throws TranslationException on anything a contributor has to fix
*/
fun generate(inputDirectory: File, outputDirectory: File): GenerationResult {
val translations = (inputDirectory.listFiles() ?: emptyArray())
.filter { languageOf(it.name) != null }
.sortedBy { it.name }
.map(::readTranslation)
val base = translations.firstOrNull { it.language == BASE_LANGUAGE }
?: throw TranslationException(
"strings_$BASE_LANGUAGE.properties is missing from ${inputDirectory.path}; the base "
+ "language is where every key and every argument count comes from"
)
refuseIdentifierCollisions(base)
val others = translations.filter { it.language != BASE_LANGUAGE }
val warnings = mutableListOf<String>()
for (translation in others) {
refuseOrphans(base, translation)
refusePlaceholderMismatches(base, translation)
val missing = missingKeys(base.entries.keys, translation.entries.keys)
if (missing.isNotEmpty()) {
warnings += "strings_${translation.language}.properties is missing "
.plus("${missing.size} of ${base.entries.size} keys; they fall back to ")
.plus("$BASE_LANGUAGE: ${missing.sorted().take(10).joinToString(", ")}")
writeWorksheet(outputDirectory, translation.language, base, missing)
}
}
val target = File(outputDirectory, "kst4contest/view/i18n/Strings.kt")
target.parentFile.mkdirs()
target.writeText(stringsSource(base, others), Charsets.UTF_8)
return GenerationResult(
warnings = warnings,
coverage = translations.map { "${it.language}: ${it.entries.size}/${base.entries.size}" },
)
}
private fun refuseIdentifierCollisions(base: Translation) {
val collisions = identifierCollisions(base.entries.keys)
if (collisions.isEmpty()) {
return
}
throw TranslationException(
"these keys would become the same Kotlin name: " + collisions.entries.joinToString("; ") {
"${it.key} <- ${it.value.sorted().joinToString(", ")}"
}
)
}
private fun refuseOrphans(base: Translation, translation: Translation) {
val orphans = orphanKeys(base.entries.keys, translation.entries.keys)
if (orphans.isEmpty()) {
return
}
throw TranslationException(
"strings_${translation.language}.properties names keys the base does not, so nothing "
+ "would ever read them -- a typo? " + orphans.sorted().joinToString(", ")
)
}
private fun refusePlaceholderMismatches(base: Translation, translation: Translation) {
val mismatched = translation.entries.keys.filter { key ->
argumentCount(translation.entries.getValue(key)) != argumentCount(base.entries.getValue(key))
}
if (mismatched.isEmpty()) {
return
}
throw TranslationException(
"strings_${translation.language}.properties uses different placeholders than the base "
+ "for: " + mismatched.sorted().joinToString(", ")
+ ". A placeholder the translation leaves out drops its data silently."
)
}
/**
* Writes the keys a translation still lacks, with the base text as the value.
*
* A worksheet, not a build output: a contributor copies its lines into their own file and
* translates the right-hand side. Without it they would have to diff two files of several
* hundred lines to find out what is left.
*
* Only written when something is missing -- an empty worksheet is a file somebody has to open
* to learn there is nothing in it.
*/
private fun writeWorksheet(
outputDirectory: File,
language: String,
base: Translation,
missing: Set<String>,
) {
val file = File(outputDirectory, "todo/strings_$language.todo.properties")
file.parentFile.mkdirs()
val header = "# ${missing.size} of ${base.entries.size} keys are not yet translated into "
.plus("$language. Copy these lines into strings_$language.properties and translate the ")
.plus("values. Partial is fine: anything left out falls back to $BASE_LANGUAGE.")
val lines = missing.sorted().joinToString("\n") { key ->
"$key=${base.entries.getValue(key)}"
}
file.writeText("$header\n\n$lines\n", Charsets.UTF_8)
}
/** Called by Gradle: input directory, output directory. */
object GeneratorMain {
@JvmStatic
fun main(args: Array<String>) {
require(args.size == 2) { "usage: GeneratorMain <input dir> <output dir>" }
val result = generate(File(args[0]), File(args[1]))
result.warnings.forEach { println("[i18n, warning] $it") }
println("[i18n] coverage -- " + result.coverage.joinToString(", "))
}
}
@@ -0,0 +1,121 @@
package kst4contest.i18n
/**
* Turns translations into the Kotlin the application compiles against.
*
* Two things come out: a `Strings` class whose members are one per key -- a property for a
* plain text, a function for a parameterised one -- and a `Translations` object holding the
* texts. The class is what makes a typo and a wrong argument count compile errors rather than
* things an operator discovers.
*
* @param base the source language; every key and every argument count comes from here
* @param translations the other languages, each carrying only the keys it actually has
* @return the complete Kotlin source file
*/
fun stringsSource(base: Translation, translations: List<Translation>): String {
val members = base.entries.keys.sorted().joinToString("\n\n") { key ->
memberFor(key, base.entries.getValue(key))
}
val tables = (listOf(base) + translations).joinToString("\n\n") { translation ->
tableFor(translation, base.entries.size)
}
val byLanguage = (listOf(base) + translations).joinToString(",\n") { translation ->
" ${kotlinLiteral(translation.language)} to ${tableName(translation.language)}"
}
return """
|// Generated from app-desktop/src/main/i18n by the :i18n-generator module.
|// Do not edit: edit the .properties files and rebuild.
|package kst4contest.view.i18n
|
|import java.text.MessageFormat
|
|/**
| * The interface texts of one language.
| *
| * One member per key, so a mistyped key and a wrong argument count are both compile
| * errors. Generated -- see the module named above.
| */
|class Strings(private val lookup: (String) -> String) {
|
|$members
|}
|
|/** The texts themselves, and which language has which. */
|object Translations {
|
|$tables
|
| /** Every language this build carries, by its code. */
| val BY_LANGUAGE: Map<String, Map<String, String>> = mapOf(
|$byLanguage,
| )
|
| /** The language every lookup falls back to. */
| const val BASE_LANGUAGE: String = ${kotlinLiteral(base.language)}
|}
|
""".trimMargin()
}
/**
* A text as a Kotlin string literal.
*
* The `$` is the one that matters and the one a translator cannot be expected to know about:
* in Kotlin it opens a template, so an unescaped one produces source that either does not
* compile or means something other than the text. The backslash goes first, or it would
* escape the escapes added after it.
*/
fun kotlinLiteral(text: String): String {
val escaped = text
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("$", "\\$")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
return "\"$escaped\""
}
/** A property for a plain text, a function for one with placeholders. */
private fun memberFor(key: String, text: String): String {
val identifier = identifierFor(key)
val arity = argumentCount(text)
val documentation = " /** ${commentFor(text)} */"
if (arity == 0) {
return "$documentation\n val $identifier: String get() = lookup(${kotlinLiteral(key)})"
}
val parameters = (0 until arity).joinToString(", ") { "arg$it: Any" }
val arguments = (0 until arity).joinToString(", ") { "arg$it" }
return documentation +
"\n fun $identifier($parameters): String =" +
"\n MessageFormat.format(lookup(${kotlinLiteral(key)}), $arguments)"
}
private fun tableFor(translation: Translation, baseSize: Int): String {
val entries = translation.entries.keys.sorted().joinToString(",\n") { key ->
" ${kotlinLiteral(key)} to ${kotlinLiteral(translation.entries.getValue(key))}"
}
return " /** ${translation.language}: ${translation.entries.size} of $baseSize. */\n" +
" val ${tableName(translation.language)}: Map<String, String> = mapOf(\n$entries,\n )"
}
/** `pt_BR` becomes `PT_BR`; the code is already identifier-safe. */
private fun tableName(language: String): String = language.uppercase()
/**
* The source text as a KDoc line, so a call site shows what it says.
*
* The comment terminator has to go: a text containing one would end the KDoc early and leave
* the rest of the text as stray source.
*/
private fun commentFor(text: String): String =
text.replace("*/", "* /").replace("\n", " ").take(120)
@@ -0,0 +1,95 @@
package kst4contest.i18n
import java.io.File
import java.util.Properties
/** One language's texts. */
data class Translation(val language: String, val entries: Map<String, String>)
/** What the generator refuses. The message is read by a contributor, so it names the file. */
class TranslationException(message: String) : RuntimeException(message)
/**
* Reads one translation file.
*
* `Properties` does the parsing -- separators, comments, escapes, continuation lines -- and is
* battle-tested at it. What it does *not* do is complain about a key that appears twice: it
* keeps the last one silently. In a file of several hundred lines that is an easy mistake and
* an invisible loss, so the keys are counted separately before the values are taken.
*
* @throws TranslationException when the file cannot be read or names a key twice
*/
fun readTranslation(file: File): Translation {
if (!file.isFile) {
throw TranslationException("${file.name}: no such translation file (${file.path})")
}
val duplicates = duplicateKeysIn(file)
if (duplicates.isNotEmpty()) {
throw TranslationException(
"${file.name}: these keys appear more than once, so one translation would be "
+ "lost without a word: ${duplicates.sorted().joinToString(", ")}"
)
}
val properties = Properties()
runCatching { file.reader(Charsets.UTF_8).use(properties::load) }
.onFailure { throw TranslationException("${file.name}: cannot be read -- ${it.message}") }
val language = languageOf(file.name)
?: throw TranslationException("${file.name}: the file name does not name a language")
return Translation(
language = language,
entries = properties.stringPropertyNames().associateWith { properties.getProperty(it) },
)
}
/**
* The language a translation file name names, or null when it names none.
*
* `strings_de.properties` is German, `strings_pt_BR.properties` is Brazilian Portuguese, and
* `strings.properties` is not a translation file at all.
*/
fun languageOf(fileName: String): String? =
FILE_NAME.matchEntire(fileName)?.groupValues?.get(1)
/**
* Keys that appear more than once, found by reading the lines rather than the parsed map --
* which is the only place the information still exists.
*
* Deliberately does not understand continuation lines (a trailing backslash): a continuation
* whose text contains a separator would be counted as a key. That is acceptable here because
* these texts are one line each, and the alternative is reimplementing `Properties`' parser
* to find a mistake `Properties` itself does not report.
*/
private fun duplicateKeysIn(file: File): Set<String> {
val seen = mutableSetOf<String>()
val twice = mutableSetOf<String>()
file.forEachLine(Charsets.UTF_8) { raw ->
val line = raw.trim()
if (line.isEmpty() || line.startsWith("#") || line.startsWith("!")) {
return@forEachLine
}
val separator = line.indexOfFirst { it == '=' || it == ':' }
if (separator <= 0) {
return@forEachLine
}
val key = line.take(separator).trim()
if (!seen.add(key)) {
twice += key
}
}
return twice
}
private val FILE_NAME = Regex("""strings_([A-Za-z]{2}(?:_[A-Za-z]{2})?)\.properties""")
@@ -0,0 +1,63 @@
package kst4contest.i18n
/**
* The rules that decide whether a set of translations is usable.
*
* Pure functions over key sets and texts, with no file and no Gradle in sight, so the part
* that can be quietly wrong is the part that is tested.
*/
/** Keys the base has and a translation does not. Allowed: they fall back to the base. */
fun missingKeys(base: Set<String>, other: Set<String>): Set<String> = base - other
/** Keys a translation has and the base does not. Not allowed: a contributor's typo. */
fun orphanKeys(base: Set<String>, other: Set<String>): Set<String> = other - base
/**
* How many arguments a text needs.
*
* The highest placeholder index plus one, and deliberately not the number of occurrences:
* `MessageFormat` addresses arguments by index, so `{1} {1}` needs two and `{0} {0}` needs
* one. Counting occurrences makes the first throw and the second swallow an argument.
*
* A brace followed by anything but a digit is not a placeholder -- braces occur in real
* interface text.
*/
fun argumentCount(text: String): Int {
val indices = PLACEHOLDER.findAll(text).map { it.groupValues[1].toInt() }
return (indices.maxOrNull() ?: -1) + 1
}
/**
* The Kotlin identifier for a key.
*
* `settings.save` becomes `settingsSave`. Dots, underscores and dashes all separate words,
* because a contributor will use whichever they are used to and the generated name must not
* depend on which.
*/
fun identifierFor(key: String): String {
val words = key.split('.', '_', '-').filter { it.isNotEmpty() }
val joined = words.mapIndexed { index, word ->
if (index == 0) {
word.replaceFirstChar { it.lowercaseChar() }
} else {
word.replaceFirstChar { it.uppercaseChar() }
}
}.joinToString("")
/* A key may start with a digit; a Kotlin identifier may not. */
return if (joined.firstOrNull()?.isDigit() == true) "key$joined" else joined
}
/**
* Keys that would produce the same identifier, grouped by that identifier.
*
* Generating both would emit Kotlin that does not compile, and the compiler's complaint names
* the generated file -- which tells a contributor nothing about the line they wrote.
*/
fun identifierCollisions(keys: Collection<String>): Map<String, List<String>> =
keys.groupBy { identifierFor(it) }.filterValues { it.size > 1 }
/** `{0}`, `{12}`, `{0,number}` -- a brace, digits, then either a brace or a comma. */
private val PLACEHOLDER = Regex("""\{(\d+)\s*[,}]""")
@@ -0,0 +1,171 @@
package kst4contest.i18n
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
import java.nio.file.Files
/**
* Generation end to end: what breaks the build and what only warns.
*
* The distinction is the whole arrangement. A translation that is 80 per cent finished has to
* be submittable, or nobody outside this project can contribute one; a translation that is
* wrong must not ship.
*/
class GenerationTest {
private fun directories(): Pair<File, File> {
val input = Files.createTempDirectory("kst4contest-i18n-in").toFile()
val output = Files.createTempDirectory("kst4contest-i18n-out").toFile()
return input to output
}
private fun write(directory: File, name: String, content: String) {
File(directory, name).writeText(content, Charsets.UTF_8)
}
private fun generated(output: File): File =
File(output, "kst4contest/view/i18n/Strings.kt")
@Test
fun aCompleteSetGeneratesAndWarnsAboutNothing() {
val (input, output) = directories()
write(input, "strings_en.properties", "a=one\n")
write(input, "strings_de.properties", "a=eins\n")
val result = generate(input, output)
assertTrue(result.warnings.isEmpty(), "${result.warnings}")
assertTrue(generated(output).isFile, "no source written")
}
@Test
fun aMissingKeyOnlyWarnsAndStillGenerates() {
val (input, output) = directories()
write(input, "strings_en.properties", "a=one\nb=two\n")
write(input, "strings_de.properties", "a=eins\n")
val result = generate(input, output)
assertEquals(1, result.warnings.size, "${result.warnings}")
assertTrue(result.warnings.single().contains("b"), result.warnings.single())
assertTrue(generated(output).isFile)
}
@Test
fun anOrphanKeyBreaksTheBuildAndNamesItself() {
val (input, output) = directories()
write(input, "strings_en.properties", "a=one\n")
write(input, "strings_de.properties", "a=eins\ntypo=zwei\n")
val failure = assertThrows(TranslationException::class.java) { generate(input, output) }
assertTrue(failure.message!!.contains("typo"), failure.message)
assertTrue(failure.message!!.contains("strings_de"), failure.message)
}
@Test
fun aPlaceholderMismatchBreaksTheBuild() {
/*
* The one that would otherwise swallow data in silence: the base passes two
* arguments, the translation prints one, and MessageFormat says nothing.
*/
val (input, output) = directories()
write(input, "strings_en.properties", "a={0} of {1}\n")
write(input, "strings_de.properties", "a={0}\n")
val failure = assertThrows(TranslationException::class.java) { generate(input, output) }
assertTrue(failure.message!!.contains("a"), failure.message)
}
@Test
fun anIdentifierCollisionBreaksTheBuild() {
val (input, output) = directories()
write(input, "strings_en.properties", "gui.startupDesign=x\ngui.startup_design=y\n")
val failure = assertThrows(TranslationException::class.java) { generate(input, output) }
assertTrue(failure.message!!.contains("guiStartupDesign"), failure.message)
}
@Test
fun aMissingBaseFileBreaksTheBuild() {
val (input, output) = directories()
write(input, "strings_de.properties", "a=eins\n")
assertThrows(TranslationException::class.java) { generate(input, output) }
}
@Test
fun aThirdLanguageIsGeneratedToo() {
// Prüfschwerpunkt 6: a contributed language must not need any wiring.
val (input, output) = directories()
write(input, "strings_en.properties", "a=one\nb=two\n")
write(input, "strings_de.properties", "a=eins\nb=zwei\n")
write(input, "strings_fr.properties", "a=un\n")
val result = generate(input, output)
assertTrue(generated(output).readText().contains("\"a\" to \"un\""))
assertTrue(result.coverage.any { it.startsWith("fr:") && it.contains("1/2") }, "${result.coverage}")
}
@Test
fun theCoverageLineNamesEveryLanguage() {
val (input, output) = directories()
write(input, "strings_en.properties", "a=one\nb=two\n")
write(input, "strings_de.properties", "a=eins\nb=zwei\n")
val result = generate(input, output)
assertTrue(result.coverage.any { it.startsWith("de:") && it.contains("2/2") }, "${result.coverage}")
}
@Test
fun anIncompleteTranslationGetsAWorksheetWithTheEnglishTextToTranslate() {
val (input, output) = directories()
write(input, "strings_en.properties", "a=one\nb=two\n")
write(input, "strings_de.properties", "a=eins\n")
generate(input, output)
val worksheet = File(output, "todo/strings_de.todo.properties")
assertTrue(worksheet.isFile, "no worksheet was written")
val content = worksheet.readText()
assertTrue(content.contains("b=two"), content)
assertFalse(content.lines().any { it.startsWith("a=") }, "an already translated key was listed")
}
@Test
fun aCompleteTranslationGetsNoWorksheet() {
// An empty worksheet is a file somebody has to open to learn there is nothing to do.
val (input, output) = directories()
write(input, "strings_en.properties", "a=one\n")
write(input, "strings_de.properties", "a=eins\n")
generate(input, output)
assertFalse(File(output, "todo/strings_de.todo.properties").exists())
}
@Test
fun aFileThatIsNotATranslationIsIgnoredRatherThanRefused() {
/*
* The input directory may hold a README telling a contributor what to do. Refusing it
* would make the documentation break the build.
*/
val (input, output) = directories()
write(input, "strings_en.properties", "a=one\n")
write(input, "README.md", "# how to contribute a language\n")
val result = generate(input, output)
assertTrue(result.warnings.isEmpty(), "${result.warnings}")
assertTrue(generated(output).isFile)
}
}
@@ -0,0 +1,126 @@
package kst4contest.i18n
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* Turning translations into Kotlin.
*
* The dangerous part is the escaping. This writes Kotlin source, and in Kotlin `$` opens a
* template: a translation containing one produces either source that does not compile or --
* worse -- source that means something else. A translator has no reason to know that.
*/
class StringsSourceTest {
private fun base(vararg pairs: Pair<String, String>) = Translation("en", mapOf(*pairs))
@Test
fun aPlainKeyBecomesAProperty() {
val source = stringsSource(base("settings.save" to "Save settings"), emptyList())
assertTrue(
source.contains("val settingsSave: String get() = lookup(\"settings.save\")"),
source,
)
}
@Test
fun aKeyWithAPlaceholderBecomesAFunctionWithThatManyArguments() {
val source = stringsSource(base("c.to" to "Connect to {0}"), emptyList())
assertTrue(source.contains("fun cTo(arg0: Any)"), source)
assertFalse(source.contains("val cTo"), "a parameterised key became a property")
}
@Test
fun theArityFollowsTheHighestIndexAndNotTheOccurrences() {
val source = stringsSource(base("x" to "{1} {1}"), emptyList())
assertTrue(source.contains("fun x(arg0: Any, arg1: Any)"), source)
}
@Test
fun theBaseTextsAreEmittedAsAMap() {
val source = stringsSource(base("a" to "one"), emptyList())
assertTrue(source.contains("\"a\" to \"one\""), source)
}
@Test
fun aTranslationIsEmittedWithOnlyTheKeysItHas() {
/*
* Incompleteness is allowed, so a translation carries only its own keys and the
* lookup falls through to the base. Padding it with English would make a missing
* translation indistinguishable from a deliberate English term.
*/
val source = stringsSource(
base("a" to "one", "b" to "two"),
listOf(Translation("de", mapOf("a" to "eins"))),
)
assertTrue(source.contains("\"a\" to \"eins\""), source)
assertFalse(source.contains("\"b\" to \"zwei\""), "de gained a key it does not have")
// b belongs to the base table and to nothing else.
assertEquals(1, source.split("\"b\" to ").size - 1, "b appears more than once")
}
// ------------------------------------------------------------------ escaping
@Test
fun aDollarSignIsEscapedBecauseKotlinWouldReadItAsATemplate() {
assertEquals("\"5 \\\$ only\"", kotlinLiteral("5 \$ only"))
}
@Test
fun quotesAndBackslashesAreEscaped() {
assertEquals("\"say \\\"hi\\\"\"", kotlinLiteral("say \"hi\""))
assertEquals("\"a\\\\b\"", kotlinLiteral("a\\b"))
}
@Test
fun newlinesAndTabsBecomeEscapesRatherThanBreakingTheLiteral() {
assertEquals("\"a\\nb\"", kotlinLiteral("a\nb"))
assertEquals("\"a\\tb\"", kotlinLiteral("a\tb"))
}
@Test
fun anUmlautStaysItselfBecauseTheGeneratedFileIsUtf8() {
assertEquals("\"Grüße\"", kotlinLiteral("Grüße"))
}
@Test
fun aTranslationFullOfHazardsSurvivesIntoTheSource() {
// The end-to-end version of the escaping tests above.
val source = stringsSource(base("x" to "a \$b \"c\" d\\e"), emptyList())
assertTrue(source.contains("\\\$b"), source)
assertTrue(source.contains("\\\"c\\\""), source)
assertTrue(source.contains("d\\\\e"), source)
}
@Test
fun aTextContainingACommentEndDoesNotBreakTheKdoc() {
/*
* The texts are echoed into a KDoc line so the call site shows what they say. A text
* containing the comment terminator would end the comment early and leave the rest as
* stray source.
*/
val source = stringsSource(base("x" to "ends a comment */ here"), emptyList())
/*
* The KDoc line only. Inside the string literal in the table below, a comment
* terminator is ordinary text and perfectly valid Kotlin -- asserting over the whole
* file would be asserting that a harmless thing does not happen.
*
* Writing this comment is itself the demonstration: the first version quoted the
* terminator and ended this comment three lines early.
*/
val kdoc = source.lines().single { it.trimStart().startsWith("/** ends a comment") }
assertTrue(kdoc.trimEnd().endsWith("*/"), kdoc)
assertEquals(1, kdoc.split("*/").size - 1, "the KDoc line terminates twice: $kdoc")
}
}
@@ -0,0 +1,105 @@
package kst4contest.i18n
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
/**
* Reading a translation file.
*
* The one rule with teeth: `java.util.Properties` keeps the last of two identical keys
* without a word. A contributor who translates a key twice -- easy in a file of several
* hundred lines -- would otherwise lose one of their translations and never find out.
*/
class TranslationFilesTest {
private fun createTempDirectory(): File =
java.nio.file.Files.createTempDirectory("kst4contest-i18n").toFile()
private fun fileWith(name: String, content: String): File {
val file = File(createTempDirectory(), name)
file.writeText(content, Charsets.UTF_8)
return file
}
@Test
fun aPlainFileIsReadKeyByKey() {
val file = fileWith("strings_en.properties", "settings.save=Save settings\nmenu.file=File\n")
val translation = readTranslation(file)
assertEquals("en", translation.language)
assertEquals("Save settings", translation.entries["settings.save"])
assertEquals("File", translation.entries["menu.file"])
}
@Test
fun umlautsSurviveBecauseTheFileIsUtf8() {
// Java 9 and later read .properties as UTF-8, which is why no escapes are needed.
val file = fileWith("strings_de.properties", "settings.save=Einstellungen speichern\nx=Grüße\n")
assertEquals("Grüße", readTranslation(file).entries["x"])
}
@Test
fun aDuplicateKeyIsRefusedRatherThanSilentlyWinning() {
/*
* Properties would keep the second and say nothing. For a contributor that means one
* of their two translations vanishes with a green build.
*/
val file = fileWith("strings_de.properties", "a=erste\nb=zwei\na=zweite\n")
val failure = assertThrows(TranslationException::class.java) { readTranslation(file) }
assertTrue(failure.message!!.contains("a"), failure.message)
assertTrue(failure.message!!.contains("strings_de.properties"), failure.message)
}
@Test
fun aDuplicateKeyIsStillFoundWhenTheLinesAreNotAdjacentOrAreSpaced() {
val file = fileWith("strings_de.properties", "a = eins\nb=zwei\n a=drei\n")
assertThrows(TranslationException::class.java) { readTranslation(file) }
}
@Test
fun commentsAndBlankLinesAreNotKeys() {
val file = fileWith("strings_en.properties", "# a comment\n! another\n\na=one\n")
assertEquals(mapOf("a" to "one"), readTranslation(file).entries)
}
@Test
fun aValueMayContainAnEqualsSign() {
// Only the first separator splits; the rest is text. Operators do write "a=b".
val file = fileWith("strings_en.properties", "formula=a=b\n")
assertEquals("a=b", readTranslation(file).entries["formula"])
}
@Test
fun aMissingFileIsRefusedWithItsName() {
val absent = File("/nonexistent/kst4contest/strings_de.properties")
val failure = assertThrows(TranslationException::class.java) { readTranslation(absent) }
assertTrue(failure.message!!.contains("strings_de.properties"), failure.message)
}
@Test
fun theLanguageComesFromTheFileName() {
assertEquals("en", languageOf("strings_en.properties"))
assertEquals("de", languageOf("strings_de.properties"))
assertEquals("pt_BR", languageOf("strings_pt_BR.properties"))
}
@Test
fun aFileNameThatIsNotATranslationNamesNoLanguage() {
assertNull(languageOf("strings.properties"))
assertNull(languageOf("gradle.properties"))
assertNull(languageOf("strings_de.txt"))
}
}
@@ -0,0 +1,106 @@
package kst4contest.i18n
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The rules that decide whether a set of translations is usable.
*
* Pure functions, and therefore the part that can be wrong without anyone noticing: a build
* break that never breaks looks exactly like a clean project. Every rule here has a failure
* that reaches an operator -- a lost translation, a swallowed argument, or Kotlin that does
* not compile with a message a contributor cannot read.
*/
class TranslationRulesTest {
@Test
fun whatTheBaseHasAndATranslationLacksIsMissing() {
assertEquals(
setOf("b"),
missingKeys(base = setOf("a", "b"), other = setOf("a")),
)
}
@Test
fun whatATranslationHasAndTheBaseLacksIsAnOrphan() {
// A contributor's typo. Silently keeping it would mean a translation nothing uses.
assertEquals(
setOf("typo"),
orphanKeys(base = setOf("a"), other = setOf("a", "typo")),
)
}
@Test
fun aTextWithoutPlaceholdersNeedsNoArguments() {
assertEquals(0, argumentCount("Save settings"))
assertEquals(0, argumentCount(""))
}
@Test
fun theArgumentCountIsTheHighestIndexPlusOneAndNotTheNumberOfOccurrences() {
/*
* The trap. "{1} {1}" occurs twice and needs two arguments, because MessageFormat
* addresses them by index; "{0} {0}" occurs twice and needs one. Counting occurrences
* makes the first case throw and the second swallow an argument.
*/
assertEquals(1, argumentCount("Connect to {0}"))
assertEquals(1, argumentCount("{0} and {0} again"))
assertEquals(2, argumentCount("{1} {1}"))
assertEquals(3, argumentCount("{2} {0}"))
}
@Test
fun aFormattedPlaceholderStillCounts() {
// MessageFormat allows {0,number} and {1,date,short}; both are arguments.
assertEquals(1, argumentCount("Seen {0,number} times"))
assertEquals(2, argumentCount("{0} at {1,date,short}"))
}
@Test
fun somethingThatMerelyLooksLikeAPlaceholderIsNotOne() {
// Braces appear in real text. Only a leading digit makes it an argument.
assertEquals(0, argumentCount("Press {Ctrl} to stop"))
assertEquals(0, argumentCount("100 {}"))
}
@Test
fun aDottedKeyBecomesALowerCamelCaseIdentifier() {
assertEquals("settingsSave", identifierFor("settings.save"))
assertEquals("menuFile", identifierFor("menu.file"))
assertEquals("guiBandColumnHints", identifierFor("gui.bandColumnHints"))
}
@Test
fun aKeyWithUnderscoresOrDashesBecomesOneIdentifierToo() {
assertEquals("guiStartupDesign", identifierFor("gui.startup_design"))
assertEquals("guiStartupDesign", identifierFor("gui.startup-design"))
}
@Test
fun anIdentifierNeverStartsWithADigit() {
// A key may legitimately start with a number; a Kotlin identifier may not.
assertTrue(identifierFor("2.metres").first().isLetter())
}
@Test
fun twoKeysCollapsingToOneIdentifierAreReported() {
/*
* gui.startupDesign and gui.startup_design both become guiStartupDesign. Generating
* both would produce Kotlin that does not compile, and the compiler's message names
* the generated file -- which tells a contributor nothing about their properties file.
*/
val collisions = identifierCollisions(listOf("gui.startupDesign", "gui.startup_design"))
assertEquals(1, collisions.size)
assertEquals(
listOf("gui.startupDesign", "gui.startup_design"),
collisions.getValue("guiStartupDesign").sorted(),
)
}
@Test
fun distinctKeysReportNoCollision() {
assertTrue(identifierCollisions(listOf("a.b", "c.d")).isEmpty())
}
}