Both manuals get the language picker in full: where it sits, that it applies per profile and takes effect at once in every open window, and -- at more length -- what is deliberately left alone and why. An operator who sees an English beacon text in a German client should be able to read that it is meant that way. The contribution route is written down in both, because plain text files are only useful if somebody knows they are there: copy, translate, submit; a partial file is welcome; the build reports mistakes by file name and writes a worksheet of what is still missing. PROJECT_CONTEXT records the decisions that outlive this stage -- the four kinds of text and the rule that the addressee decides, that core produces no display text, that incompleteness is allowed and incorrectness is not, that anything built outside a composition needs its text read inside one, and that a column id is never translated. CLAUDE.md said two modules; there are three, and the third never ships. It also states the German orthography now, which this session got wrong across a spec and two plans: ss after a short vowel, so muss and dass.
86 KiB
Umsetzungsplan — die Oberfläche in zwei Sprachen
Für ausführende Agenten: Schritte sind als Kästchen (
- [ ]) geführt. Nach jeder Aufgabe ein Commit und ein./gradlew clean buildmit Auswertung der Test-XML. Nach der mit GUI-ABNAHME markierten Aufgabe die Anwendung starten und von Marc bedienen lassen.
Ziel: Die Mechanik für eine mehrsprachige Oberfläche, mit dreizehn Texten als Beweis. Die übrigen rund 380 sind danach mechanische Folgearbeit ohne Architekturentscheidung.
Architektur: Zwei .properties-Dateien sind die Quelle, die niemand kompilieren muss. Ein
eigenes Bauwerkzeug-Modul erzeugt daraus Kotlin mit typisierten Zugriffsmethoden, womit
Tippfehler und falsche Platzhalterzahl zu Übersetzungsfehlern werden. Ein LanguageStore
hält die Sprache als Compose-Zustand — dieselbe Bauform wie der PaletteStore aus Etappe 8 —
und verteilt sie an Compose über ein CompositionLocal und an die Java-Aufrufstellen über ein
@Volatile-Feld.
Werkzeuge: Java 21, Kotlin 2.2, Compose Multiplatform 1.8.2, JUnit 5, Gradle Wrapper.
java.util.Properties und java.text.MessageFormat — beides JDK.
Spezifikation: docs/superpowers/specs/2026-10-04-mehrsprachigkeit-design.md.
Übergeordnet: docs/superpowers/specs/2026-09-25-compose-migration-design.md, Etappe 9.
Projektweite Vorgaben
- Kommunikation mit Marc auf Deutsch; Quelltextkommentare und Javadoc ausschließlich Englisch, auch in dieser Etappe. Commit-Nachrichten knappes Englisch.
./gradlew clean build, Java 21. Nicht aus Exit-Code 0 auf grün schließen — die XML unter*/build/test-results/test/TEST-*.xmlauswerten. Grundlinie: 969 Tests, 3 übersprungen, 0 Fehler.- Immer mit Pfadangabe committen (
git commit -- <pfade>). Dieser Arbeitsbaum trägt vorbestehende Änderungen von vor der Sitzung:.gitignoregeändert, die Löschung von0001-Document-v1.43-and-fix-release-metadata.patchgestapelt,AGENT_HANDOVER.mdunddocs/superpowers/UEBERGABE-2026-09-28.mdgelöscht,docs/superpowers/UEBERGABE-2026-10-02.mdunverfolgt. Nichts davon darf in einen Commit geraten. - Signierte Commits. Schlägt das Signieren mit
Couldn't get agent socketfehl, ist der Bitwarden-SSH-Agent gesperrt — Marc entsperren lassen, nicht unsigniert committen. - Kein Push, kein Merge, kein Tag, kein Release. Keine neue Abhängigkeit, weder im Jar noch im Bau — das ist in dieser Etappe eine Entwurfsentscheidung, nicht nur eine Hausregel.
corebekommt keine Nachschlagefunktion. Es erzeugt keinen Anzeigetext.- Die Reiterreihenfolge im Einstellungsfenster ändert sich nicht. Die Sprachwahl kommt in den bestehenden GUI-Reiter.
- Nicht anfassen:
bcn_beaconTextMainCat,bcn_beaconTextSecondCat,messageHandling_autoAnswerTextMainCat,messageHandling_autoAnswerTextSecondCat, Schnipsel und Kurztasten. Sie stehen in der gespeicherten XML jedes Operateurs.
Prüfschwerpunkte
Sechs Dinge, die die Spezifikation voraussetzt, aber nicht als Test benennt. Jede Zeile bekommt ihren Test in der Aufgabe, der der Code gehört. Die ersten drei sind beim Schreiben dieses Plans gemessen worden.
- Ein Text mit
$,"oder\muss im erzeugten Kotlin überleben. Der Erzeuger schreibt Kotlin-Quelltext;$ist dort das Vorlagenzeichen. Eine Übersetzung mit$erzeugt sonst Quelltext, der nicht übersetzt — oder schlimmer, einen, der etwas anderes bedeutet. → Aufgabe 3. - Ein doppelter Schlüssel in einer Datei darf nicht lautlos gewinnen.
java.util.Propertiesbehält den letzten ohne ein Wort. Ein Beitragender, der einen Schlüssel zweimal übersetzt, verliert sonst eine seiner beiden Übersetzungen und erfährt es nie. → Aufgabe 2. buildSrc-Tests laufen bei./gradlew buildnicht mit — gemessen. Deshalb ist der Erzeuger ein eigenes Modul und kein Bauskript, und deshalb muss nachgeprüft werden, dass seine Tests wirklich in der XML-Zählung auftauchen. → Aufgabe 4.- Platzhalter zählen heißt den höchsten Index finden, nicht die Vorkommen.
"{1} {1}"hat zwei Vorkommen und braucht zwei Argumente,"{0} {0}"zwei Vorkommen und eines. Falsch gezählt verschlucktMessageFormatDaten oder wirft. → Aufgabe 1. - Zwei Schlüssel dürfen nicht auf denselben Kotlin-Namen fallen.
gui.startupDesignundgui.startup_designwürden beide zuguiStartupDesign. Das ergäbe Quelltext, der nicht übersetzt — mit einer Meldung des Kotlin-Compilers, die den Beitragenden nichts sagt. → A1. - Eine dritte Sprache darf den Bau nicht brechen. Deutsch wird auf Vollständigkeit geprüft, eine beigetragene Sprache nicht. Der Vollständigkeitstest darf nicht über jede neue Datei stolpern. → Aufgabe 8.
Dateiübersicht
Neu: das Bauwerkzeug-Modul (wird nicht ausgeliefert):
| Datei | Verantwortung |
|---|---|
settings.gradle.kts |
include(":i18n-generator") ergänzen |
i18n-generator/build.gradle.kts |
Kotlin-Modul, nur JUnit |
i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationRules.kt |
die Regeln als reine Funktionen |
i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationFiles.kt |
Lesen, doppelte Schlüssel, unlesbare Datei |
i18n-generator/src/main/kotlin/kst4contest/i18n/StringsSource.kt |
der Kotlin-Erzeuger samt Maskierung |
i18n-generator/src/main/kotlin/kst4contest/i18n/GeneratorMain.kt |
das Hauptprogramm, das Gradle aufruft |
| dazu drei Testdateien |
Neu in app-desktop:
| Datei | Verantwortung |
|---|---|
src/main/i18n/strings_en.properties |
die Grundlage, englisch |
src/main/i18n/strings_de.properties |
die deutsche Übersetzung |
.../view/i18n/LanguageStore.kt |
die Sprache als Compose-Zustand, plus LocalStrings |
.../view/i18n/LocaleChoice.kt |
localeFor(stored, systemDefault) als reine Funktion |
.../view/i18n/CurrentStrings.kt |
das @Volatile-Feld für die Java-Aufrufstellen |
| dazu fünf Testdateien |
Geändert: app-desktop/build.gradle.kts (Erzeuger-Aufgabe und Quellverzeichnis),
ChatPreferences.java (eine Einstellung), GuiOptionsTabState.kt und GuiOptionsTab.kt (die
Sprachwahl), ComposeWindowHost.kt (LocalStrings bereitstellen),
Kst4ContestApplication.java (Store beim Start bilden), ConnectionBar.kt,
MainMenuModel.kt, ComposeAlert.kt (die Beweistexte), CLAUDE.md und
docs/PROJECT_CONTEXT.md (zwei Module werden drei), beide Handbücher.
Aufgabe 1: Die Regeln als reine Funktionen
Das Herz, und der Teil, der falsch sein kann, ohne dass es auffällt.
Dateien:
- Anlegen:
i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationRules.kt - Anlegen:
i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationRulesTest.kt - Anlegen:
i18n-generator/build.gradle.kts - Ändern:
settings.gradle.kts
Schnittstellen:
-
Liefert:
fun missingKeys(base: Set<String>, other: Set<String>): Set<String> -
Liefert:
fun orphanKeys(base: Set<String>, other: Set<String>): Set<String> -
Liefert:
fun argumentCount(text: String): Int— der höchste Platzhalterindex plus eins, nicht die Zahl der Vorkommen -
Liefert:
fun identifierFor(key: String): String -
Liefert:
fun identifierCollisions(keys: Collection<String>): Map<String, List<String>> -
Schritt 1: Das Modul anlegen
settings.gradle.kts um eine Zeile ergänzen, neben den bestehenden include-Aufrufen:
/*
* Build tooling only: this module generates the Kotlin for the translation files and is
* never shipped. It is a module 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.
*/
include(":i18n-generator")
i18n-generator/build.gradle.kts:
plugins {
alias(libs.plugins.kotlinJvm)
}
kotlin {
jvmToolchain(21)
}
dependencies {
testImplementation(libs.junit.jupiter)
// Gradle 9 no longer adds the launcher implicitly.
testRuntimeOnly(libs.junit.platform.launcher)
}
tasks.test {
useJUnitPlatform()
}
Keine Produktionsabhängigkeit: das Modul benutzt nur java.util.Properties und
java.text.MessageFormat aus dem JDK.
- Schritt 2: Den fehlschlagenden Test schreiben
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())
}
}
- Schritt 3: Laufen lassen und den Fehlschlag sehen
./gradlew :i18n-generator:test
Erwartung: Übersetzungsfehler, missingKeys existiert nicht.
- Schritt 4: Die Umsetzung schreiben
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*[,}]""")
Achtung bei der Regex: "{0}" muss passen und "{Ctrl}" nicht. Der Test
somethingThatMerelyLooksLikeAPlaceholderIsNotOne ist die Probe darauf; schlägt er fehl, ist
die Regex zu großzügig und nicht der Test falsch.
- Schritt 5: Tests laufen lassen
./gradlew :i18n-generator:test
Erwartung: 11 Tests grün.
- Schritt 6: Bauen, Testzahl prüfen, committen
./gradlew clean build
grep -h "<testsuite " */build/test-results/test/TEST-*.xml | wc -l
Hier ist nachzusehen, ob i18n-generator/build/test-results/test/ entsteht — das ist
Prüfschwerpunkt 3. Fehlt es, laufen die Tests des Erzeugers nicht mit, und dann ist die
Modulentscheidung umsonst gewesen: anhalten und die Verdrahtung richten, bevor es weitergeht.
git add settings.gradle.kts i18n-generator/
git commit -m "Add the translation rules as tested pure functions" -- \
settings.gradle.kts i18n-generator/
Aufgabe 2: Die Dateien lesen, ohne lautlos zu verlieren
Dateien:
- Anlegen:
i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationFiles.kt - Anlegen:
i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationFilesTest.kt
Schnittstellen:
-
Liefert:
data class Translation(val language: String, val entries: Map<String, String>) -
Liefert:
fun readTranslation(file: java.io.File): Translation— wirftTranslationExceptionbei unlesbarer Datei oder doppeltem Schlüssel -
Liefert:
class TranslationException(message: String) : RuntimeException(message) -
Liefert:
fun languageOf(fileName: String): String?—strings_de.properties→de -
Schritt 1: Den fehlschlagenden Test schreiben
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 fileWith(name: String, content: String): File {
val directory = createTempDirectory()
val file = File(directory, name)
file.writeText(content)
return file
}
private fun createTempDirectory(): File =
java.nio.file.Files.createTempDirectory("kst4contest-i18n").toFile()
@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"))
}
}
- Schritt 2: Laufen lassen und den Fehlschlag sehen
./gradlew :i18n-generator:test --tests '*TranslationFilesTest*'
- Schritt 3: Die Umsetzung schreiben
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? {
val match = FILE_NAME.matchEntire(fileName) ?: return null
return match.groupValues[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.
*/
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""")
Bekannte Grenze, bewußt: die Dublettenprüfung versteht keine Fortsetzungszeilen
(\ am Zeilenende). Eine Fortsetzungszeile, deren Fortsetzung ein = enthält, könnte als
Schlüssel gelesen werden. Das ist für diese Dateien hinnehmbar — die Texte sind einzeilig —
und die Alternative wäre, Properties' Parser nachzubauen. Als Kommentar an
duplicateKeysIn schreiben, damit der nächste Leser es weiß statt es zu entdecken.
- Schritt 4: Tests laufen lassen, bauen, committen
./gradlew :i18n-generator:test --tests '*TranslationFilesTest*'
./gradlew clean build
git add i18n-generator/src
git commit -m "Read translation files, refusing a duplicate key rather than losing one" -- \
i18n-generator/src
Aufgabe 3: Den Kotlin-Quelltext erzeugen
Dateien:
- Anlegen:
i18n-generator/src/main/kotlin/kst4contest/i18n/StringsSource.kt - Anlegen:
i18n-generator/src/test/kotlin/kst4contest/i18n/StringsSourceTest.kt
Schnittstellen:
-
Liefert:
fun stringsSource(base: Translation, translations: List<Translation>): String -
Liefert:
fun kotlinLiteral(text: String): String— die Maskierung, öffentlich, weil sie Prüfschwerpunkt 1 ist und ihren eigenen Test braucht -
Erzeugt wird das Paket
kst4contest.view.i18n, KlasseStrings, ObjektTranslations. -
Schritt 1: Den fehlschlagenden Test schreiben
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 \"two\"\n )"), "de was padded")
}
// ------------------------------------------------------------------ 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 four 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)
}
}
- Schritt 2: Laufen lassen und den Fehlschlag sehen
./gradlew :i18n-generator:test --tests '*StringsSourceTest*'
- Schritt 3: Die Umsetzung schreiben
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.
*/
fun stringsSource(base: Translation, translations: List<Translation>): String {
val keys = base.entries.keys.sorted()
val members = keys.joinToString("\n\n") { key ->
val identifier = identifierFor(key)
val arity = argumentCount(base.entries.getValue(key))
if (arity == 0) {
" /** ${commentFor(base.entries.getValue(key))} */\n" +
" val $identifier: String get() = lookup(${kotlinLiteral(key)})"
} else {
val parameters = (0 until arity).joinToString(", ") { "arg$it: Any" }
val arguments = (0 until arity).joinToString(", ") { "arg$it" }
" /** ${commentFor(base.entries.getValue(key))} */\n" +
" fun $identifier($parameters): String =\n" +
" MessageFormat.format(lookup(${kotlinLiteral(key)}), $arguments)"
}
}
val tables = (listOf(base) + translations).joinToString("\n\n") { translation ->
val entries = translation.entries.keys.sorted().joinToString(",\n") { key ->
" ${kotlinLiteral(key)} to ${kotlinLiteral(translation.entries.getValue(key))}"
}
" /** ${translation.language}: ${translation.entries.size} of ${base.entries.size}. */\n" +
" val ${translation.language.uppercase()}: Map<String, String> = mapOf(\n$entries,\n )"
}
val byLanguage = (listOf(base) + translations).joinToString(",\n") { translation ->
" ${kotlinLiteral(translation.language)} to ${translation.language.uppercase()}"
}
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 has to go 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\""
}
/** The English text as a KDoc line, so the call site shows what it says. */
private fun commentFor(text: String): String =
text.replace("*/", "* /").replace("\n", " ").take(120)
- Schritt 4: Tests laufen lassen, bauen, committen
./gradlew :i18n-generator:test --tests '*StringsSourceTest*'
./gradlew clean build
git add i18n-generator/src
git commit -m "Generate the Kotlin, escaping what a translator cannot know about" -- \
i18n-generator/src
Aufgabe 4: Das Hauptprogramm und die Bau-Verdrahtung
Dateien:
- Anlegen:
i18n-generator/src/main/kotlin/kst4contest/i18n/GeneratorMain.kt - Anlegen:
i18n-generator/src/test/kotlin/kst4contest/i18n/GenerationTest.kt - Anlegen:
app-desktop/src/main/i18n/strings_en.properties - Anlegen:
app-desktop/src/main/i18n/strings_de.properties - Ändern:
app-desktop/build.gradle.kts
Schnittstellen:
-
Liefert:
fun generate(inputDirectory: File, outputDirectory: File): GenerationResult -
Liefert:
data class GenerationResult(val warnings: List<String>, val coverage: List<String>) -
Liefert:
object GeneratorMain { @JvmStatic fun main(args: Array<String>) }— zwei Argumente: Eingabe- und Ausgabeverzeichnis. WirftTranslationException, womit die Gradle-Aufgabe mit der Meldung des Erzeugers abbricht. -
Schritt 1: Den fehlschlagenden Test schreiben
package kst4contest.i18n
import org.junit.jupiter.api.Assertions.assertEquals
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)
}
@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(File(output, "kst4contest/view/i18n/Strings.kt").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(File(output, "kst4contest/view/i18n/Strings.kt").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(File(output, "kst4contest/view/i18n/Strings.kt").readText().contains("\"a\" to \"un\""))
assertTrue(result.coverage.any { it.contains("fr") && it.contains("1") }, "${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}")
}
}
- Schritt 2: Laufen lassen und den Fehlschlag sehen
./gradlew :i18n-generator:test --tests '*GenerationTest*'
- Schritt 3: Die Umsetzung schreiben
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>)
/**
* 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.
*
* @throws TranslationException on anything a contributor has to fix
*/
fun generate(inputDirectory: File, outputDirectory: File): GenerationResult {
val files = (inputDirectory.listFiles() ?: emptyArray())
.filter { languageOf(it.name) != null }
.sortedBy { it.name }
val translations = files.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"
)
val collisions = identifierCollisions(base.entries.keys)
if (collisions.isNotEmpty()) {
throw TranslationException(
"these keys would become the same Kotlin name: " + collisions.entries.joinToString("; ") {
"${it.key} <- ${it.value.sorted().joinToString(", ")}"
}
)
}
val others = translations.filter { it.language != BASE_LANGUAGE }
val warnings = mutableListOf<String>()
for (translation in others) {
val orphans = orphanKeys(base.entries.keys, translation.entries.keys)
if (orphans.isNotEmpty()) {
throw TranslationException(
"strings_${translation.language}.properties names keys the base does not, so "
+ "nothing would ever read them -- a typo? "
+ orphans.sorted().joinToString(", ")
)
}
val mismatched = translation.entries.keys.filter { key ->
argumentCount(translation.entries.getValue(key)) !=
argumentCount(base.entries.getValue(key))
}
if (mismatched.isNotEmpty()) {
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."
)
}
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(", ")}")
}
}
val target = File(outputDirectory, "kst4contest/view/i18n/Strings.kt")
target.parentFile.mkdirs()
target.writeText(stringsSource(base, others), Charsets.UTF_8)
val coverage = translations.map {
"${it.language}: ${it.entries.size}/${base.entries.size}"
}
return GenerationResult(warnings = warnings, coverage = coverage)
}
/**
* 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 `GenerationTest` would fail if they drifted,
* because the generated source is written from this constant.
*/
const val BASE_LANGUAGE: String = "en"
/** 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(", "))
}
}
- Schritt 3b: Das Arbeitsblatt für Beitragende
Die Spezifikation verspricht es, und ohne es müsste ein Beitragender zwei Dateien diffen, um zu finden, was fehlt. Der Erzeuger schreibt es als Nebenerzeugnis neben die erzeugte Quelle.
Zuerst der Test, in GenerationTest:
@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.contains("a="), "a key that is already translated 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())
}
Dazu import org.junit.jupiter.api.Assertions.assertFalse ergänzen.
Dann in generate, im Zweig, der heute die Warnung sammelt — statt nur zu warnen:
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)
}
und daneben:
/**
* 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 ")
.plus("the 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)
}
Das Arbeitsblatt liegt unter build/, nicht im Quellbaum: es ist abgeleitet, und ein
eingecheckter Zwischenstand davon wäre sofort veraltet. Der Pfad kommt in die Beitragsanleitung
in Aufgabe 10.
- Schritt 4: Die beiden Textdateien anlegen
app-desktop/src/main/i18n/strings_en.properties:
# The base language. Every key and every argument count comes from this file.
# To contribute a language: copy this file to strings_<code>.properties and translate the
# values to the right of the equals sign. An untranslated key falls back to English, so a
# partial file is fine. Do not translate anything that goes to the ON4KST server or to other
# radio amateurs -- none of that is in this file, and the build checks that it stays that way.
settings.save=Save settings
settings.applyAndClose=Apply/Close prefs
connection.connect=Connect
connection.connectTo=Connect to {0}
connection.disconnect=Disconnect
connection.disconnectAndCloseChat=Disconnect & close Chat
gui.startupDesign=Startup design
gui.startupFilters=Message filters active at startup
gui.bandColumnHints=Hints in the band columns
gui.language=Language
gui.language.system=System language
menu.file=File
menu.options=Options
menu.windows=Windows
menu.info=Info
alert.ok=OK
app-desktop/src/main/i18n/strings_de.properties:
# German. Held complete by a test, because this is a shipped language.
settings.save=Einstellungen speichern
settings.applyAndClose=Übernehmen/Schließen
connection.connect=Verbinden
connection.connectTo=Mit {0} verbinden
connection.disconnect=Trennen
connection.disconnectAndCloseChat=Trennen & Chat schließen
gui.startupDesign=Design beim Start
gui.startupFilters=Beim Start aktive Nachrichtenfilter
gui.bandColumnHints=Hinweise in den Bandspalten
gui.language=Sprache
gui.language.system=Systemsprache
menu.file=Datei
menu.options=Optionen
menu.windows=Fenster
menu.info=Info
alert.ok=OK
Für Marc zu prüfen (die Liste aus der Entwurfsbesprechung): connection.connectTo — im
Deutschen steht der Platzhalter vorn, was genau der Grund für MessageFormat statt Verketten
ist. settings.applyAndClose ist bewußt kürzer als die englische Fassung, weil der Knopf
sonst die Leiste sprengt. menu.info bleibt „Info", weil das im deutschen Funkbetrieb so
heißt.
- Schritt 5: Gradle verdrahten
In app-desktop/build.gradle.kts, nach dem dependencies-Block:
/*
* The translation files are turned into Kotlin before anything compiles, by the
* :i18n-generator module. Two consequences worth naming:
*
* - A broken translation fails `./gradlew classes`, which is what the PR workflow already
* runs -- so a contributor's mistake is reported in their pull request without a new
* workflow existing for it.
* - The generator is reached through its own configuration, not through `implementation`, so
* it stays out of the application's runtime classpath and out of the jar.
*/
val i18nGenerator: Configuration by configurations.creating
dependencies {
i18nGenerator(project(":i18n-generator"))
}
val generatedI18nDirectory = layout.buildDirectory.dir("generated/i18n")
val generateStrings by tasks.registering(JavaExec::class) {
description = "Generates Strings.kt from app-desktop/src/main/i18n"
group = "build"
classpath = i18nGenerator
mainClass.set("kst4contest.i18n.GeneratorMain")
val input = layout.projectDirectory.dir("src/main/i18n")
inputs.dir(input)
outputs.dir(generatedI18nDirectory)
argumentProviders.add(CommandLineArgumentProvider {
listOf(input.asFile.absolutePath, generatedI18nDirectory.get().asFile.absolutePath)
})
}
kotlin.sourceSets["main"].kotlin.srcDir(generatedI18nDirectory)
tasks.named("compileKotlin") { dependsOn(generateStrings) }
- Schritt 6: Nachprüfen, dass der Erzeuger wirklich greift
./gradlew :app-desktop:compileKotlin
ls app-desktop/build/generated/i18n/kst4contest/view/i18n/Strings.kt
Erwartung: die Datei existiert und enthält val settingsSave.
Dann die Gegenprobe, und die ist der Punkt der ganzen Aufgabe — eine kaputte Übersetzung muss den Bau abbrechen:
echo "typo.nobody.declared=x" >> app-desktop/src/main/i18n/strings_de.properties
./gradlew :app-desktop:compileKotlin 2>&1 | grep -i "typo.nobody.declared"
git checkout app-desktop/src/main/i18n/strings_de.properties
Erwartung: der Bau bricht ab und nennt den Schlüssel. Bricht er nicht ab, ist die Verdrahtung falsch und alles Weitere baut auf Sand — anhalten und richten.
- Schritt 7: Bauen, Testzahl prüfen, committen
./gradlew clean build
python3 - <<'PY'
import glob,re
t=s=f=e=0
for p in glob.glob('*/build/test-results/test/TEST-*.xml'):
m=re.search(r'<testsuite [^>]*',open(p).read(600)).group(0)
g=lambda k: int(re.search(k+r'="(\d+)"',m).group(1))
t+=g('tests'); s+=g('skipped'); f+=g('failures'); e+=g('errors')
print(f"tests={t} skipped={s} failures={f} errors={e}")
PY
Die Zahl muss die Tests des Erzeugers enthalten — das ist Prüfschwerpunkt 3. Tut sie es nicht, laufen sie nicht mit.
git add i18n-generator/src app-desktop/src/main/i18n app-desktop/build.gradle.kts
git commit -m "Generate Strings.kt before compiling, and fail the build on a bad translation" -- \
i18n-generator/src app-desktop/src/main/i18n app-desktop/build.gradle.kts
Aufgabe 5: Die Sprachwahl als reine Funktion
Dateien:
- Anlegen:
app-desktop/src/main/kotlin/kst4contest/view/i18n/LocaleChoice.kt - Anlegen:
app-desktop/src/test/kotlin/kst4contest/view/i18n/LocaleChoiceTest.kt
Schnittstellen:
-
Liefert:
fun languageFor(stored: String?, systemDefault: java.util.Locale): String— der Sprachcode, für denTranslations.BY_LANGUAGEeinen Eintrag hat, sonstTranslations.BASE_LANGUAGE. -
Liefert:
const val SYSTEM_LANGUAGE = ""— der gespeicherte Wert für „Systemsprache". -
Schritt 1: Den fehlschlagenden Test schreiben
package kst4contest.view.i18n
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.util.Locale
/**
* Which language a stored setting and a system default add up to.
*
* A pure function on purpose: the alternative is a test that changes the JVM's default locale,
* which is global state and leaks into whatever runs next.
*/
class LocaleChoiceTest {
@Test
fun anEmptySettingFollowsTheSystem() {
assertEquals("de", languageFor(SYSTEM_LANGUAGE, Locale.GERMANY))
assertEquals("en", languageFor(SYSTEM_LANGUAGE, Locale.UK))
}
@Test
fun aSystemLanguageNobodyTranslatedFallsBackToEnglish() {
// The spec's rule: default from the system language, falling back to English.
assertEquals("en", languageFor(SYSTEM_LANGUAGE, Locale.FRANCE))
assertEquals("en", languageFor(SYSTEM_LANGUAGE, Locale.JAPAN))
}
@Test
fun anExplicitChoiceBeatsTheSystem() {
assertEquals("de", languageFor("de", Locale.UK))
assertEquals("en", languageFor("en", Locale.GERMANY))
}
@Test
fun nonsenseInTheSettingFallsBackToEnglishRatherThanFailing() {
/*
* The stored value can be hand-edited in the preferences XML, or left over from a
* build that carried a language this one does not.
*/
assertEquals("en", languageFor("klingon", Locale.GERMANY))
assertEquals("en", languageFor("de_DE_x", Locale.GERMANY))
assertEquals("en", languageFor(null, Locale.UK))
}
@Test
fun theSettingIsCaseInsensitiveBecauseAHandEditedFileMayShout() {
assertEquals("de", languageFor("DE", Locale.UK))
}
}
- Schritt 2–4: Laufen lassen, umsetzen, laufen lassen
./gradlew :app-desktop:test --tests '*LocaleChoiceTest*'
package kst4contest.view.i18n
import java.util.Locale
/** The stored value that means "whatever the system is set to". */
const val SYSTEM_LANGUAGE: String = ""
/**
* The language to use.
*
* An explicit setting wins; an empty one follows the system; anything this build has no texts
* for falls back to the base language. A pure function so it can be tested without touching
* the JVM's default locale, which is global state.
*
* @param stored the value from the preferences, possibly hand-edited or from another build
* @param systemDefault usually `Locale.getDefault()`
* @return a language code `Translations.BY_LANGUAGE` has an entry for
*/
fun languageFor(stored: String?, systemDefault: Locale): String {
val wanted = stored?.trim()?.lowercase(Locale.ROOT).orEmpty()
val candidate = if (wanted == SYSTEM_LANGUAGE) {
systemDefault.language.lowercase(Locale.ROOT)
} else {
wanted
}
return if (Translations.BY_LANGUAGE.containsKey(candidate)) {
candidate
} else {
Translations.BASE_LANGUAGE
}
}
- Schritt 5: Bauen, committen
./gradlew clean build
git add app-desktop/src/main/kotlin/kst4contest/view/i18n/LocaleChoice.kt \
app-desktop/src/test/kotlin/kst4contest/view/i18n/LocaleChoiceTest.kt
git commit -m "Resolve the language from the setting and the system, falling back to English" -- \
app-desktop/src/main/kotlin/kst4contest/view/i18n/LocaleChoice.kt \
app-desktop/src/test/kotlin/kst4contest/view/i18n/LocaleChoiceTest.kt
Aufgabe 6: Der Store, und dass ein Wechsel alle Fenster erreicht
Dateien:
- Anlegen:
app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStore.kt - Anlegen:
app-desktop/src/test/kotlin/kst4contest/view/i18n/LanguageStoreTest.kt - Anlegen:
app-desktop/src/test/kotlin/kst4contest/view/i18n/StringsReachTheCompositionTest.kt - Ändern:
app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt
Schnittstellen:
-
Liefert:
class LanguageStore(storedLanguage: () -> String?, private val storeLanguage: (String) -> Unit, private val systemDefault: Locale = Locale.getDefault())mitlanguage: String,strings: Strings,fun use(language: String). -
Liefert:
val LocalStrings: ProvidableCompositionLocal<Strings>— Vorgabe ist die Grundsprache, damit ein Fenster ohne Store zeichnen kann. -
Liefert:
object CurrentStrings { @JvmStatic fun get(): Strings }— für die Java-Aufrufstellen. -
Schritt 1: Den fehlschlagenden Test schreiben
package kst4contest.view.i18n
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.util.Locale
/**
* The store, and the two ways out of it.
*
* Built over two lambdas rather than over ChatPreferences, so none of this touches the
* operator's home directory: that constructor copies resources into it.
*/
class LanguageStoreTest {
private var stored: String = SYSTEM_LANGUAGE
private fun store(systemDefault: Locale = Locale.UK) = LanguageStore(
storedLanguage = { stored },
storeLanguage = { stored = it },
systemDefault = systemDefault,
)
@Test
fun theStoredLanguageIsInForceFromTheStart() {
stored = "de"
assertEquals("de", store().language)
}
@Test
fun anEmptySettingFollowsTheSystem() {
stored = SYSTEM_LANGUAGE
assertEquals("de", store(Locale.GERMANY).language)
}
@Test
fun choosingALanguageWritesItThroughAndChangesTheTexts() {
val store = store()
val englishSave = store.strings.settingsSave
store.use("de")
assertEquals("de", store.language)
assertEquals("de", stored)
assertEquals(false, store.strings.settingsSave == englishSave)
}
@Test
fun aLanguageThisBuildDoesNotCarryIsRefusedRatherThanStored() {
/*
* The picker cannot offer one, but a hand-edited preferences file can name one, and
* so can a build that dropped a language. Storing it would persist a dead setting.
*/
val store = store()
store.use("klingon")
assertEquals("en", store.language)
assertEquals(SYSTEM_LANGUAGE, stored, "a language with no texts was stored")
}
@Test
fun aKeyOnlyTheBaseHasComesBackInTheBaseLanguage() {
/*
* The fallback the whole contribution workflow depends on, and it cannot be tested
* through German: German is a shipped language and Task 8 holds it complete, so no key
* is ever missing from it. Built from maps instead, which also means this test does
* not change every time a key is added.
*/
val base = mapOf(
"settings.save" to "Save settings",
"menu.file" to "File",
)
val partial = mapOf("menu.file" to "Datei")
val strings = Strings { key -> partial[key] ?: base[key] ?: key }
assertEquals("Save settings", strings.settingsSave, "the base text did not come through")
assertEquals("Datei", strings.menuFile, "the translation did not win")
}
@Test
fun theJavaBridgeSeesWhatTheStoreSees() {
val store = store()
store.use("de")
assertEquals(store.strings.settingsSave, CurrentStrings.get().settingsSave)
}
}
Warum der Rückfalltest nicht über Deutsch geht: Deutsch ist eine ausgelieferte Sprache und
wird in Aufgabe 8 auf Vollständigkeit geprüft — es fehlt also nie ein Schlüssel, und ein Test
über die echten Dateien könnte den Rückfall gar nicht erreichen. Einen absichtlich
unvollständig zu lassen, nur damit ein Test etwas zu prüfen hat, wäre die falsche Richtung.
Deshalb baut der Test seine Karten selbst und ruft echte erzeugte Mitglieder darauf ab.
Kein Zugang nur für Tests in der erzeugten Klasse: ein lookupForTest wäre Testcode in
Produktionscode, und die erzeugten Mitglieder sind ohnehin genau das, was eine Aufrufstelle
benutzt — der Test geht also denselben Weg wie das Fenster.
- Schritt 2: Laufen lassen und den Fehlschlag sehen
./gradlew :app-desktop:test --tests '*LanguageStoreTest*'
- Schritt 3: Den Store schreiben
package kst4contest.view.i18n
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import java.util.Locale
/**
* The language in force, held as Compose state.
*
* **This is what makes a language change reach every open window** -- the same arrangement
* the palette uses, and for the same reason: in Etappe 8 a remembered value meant a change
* reached nothing until something else happened to recompose.
*
* Two ways out, one truth: [strings] for Compose, which recomposes on the state read, and
* [CurrentStrings] for the Java call sites, which pick the new language up on their next call
* without needing any machinery of their own.
*
* @param storedLanguage the preference, possibly empty for "follow the system"
* @param storeLanguage writes the preference back
* @param systemDefault injected so tests need not touch the JVM's global default
*/
class LanguageStore(
storedLanguage: () -> String?,
private val storeLanguage: (String) -> Unit,
private val systemDefault: Locale = Locale.getDefault(),
) {
private var current by mutableStateOf(languageFor(storedLanguage(), systemDefault))
init {
CurrentStrings.set(stringsFor(current))
}
/** The language code in force. */
val language: String get() = current
/**
* The texts in force.
*
* Reads the state, so a composable calling this recomposes when the language changes.
* That read is the whole mechanism.
*/
val strings: Strings get() = stringsFor(current)
/**
* Switches the language.
*
* A language this build has no texts for is refused rather than stored: persisting a dead
* setting would leave the operator with English and no way to see why.
*
* @param language a language code, or [SYSTEM_LANGUAGE]
*/
fun use(language: String) {
val resolved = languageFor(language, systemDefault)
if (language != SYSTEM_LANGUAGE && resolved != language.trim().lowercase(Locale.ROOT)) {
return
}
current = resolved
storeLanguage(language)
CurrentStrings.set(stringsFor(resolved))
}
private fun stringsFor(language: String): Strings {
val chosen = Translations.BY_LANGUAGE[language].orEmpty()
val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE)
/*
* Chosen first, base second. A key the translation leaves out falls through, which is
* what lets a partial contributed language ship at all.
*/
return Strings { key -> chosen[key] ?: base[key] ?: key }
}
}
/**
* The texts the Java call sites read.
*
* A volatile field rather than a parameter on three hundred signatures: those call sites are
* AWT dialogs and menu builders that run once per interaction, so reading the current value on
* the next call is both correct and all they need.
*/
object CurrentStrings {
@Volatile
private var strings: Strings = Strings { key ->
Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE)[key] ?: key
}
/** The texts in force. */
@JvmStatic
fun get(): Strings = strings
internal fun set(value: Strings) {
strings = value
}
}
/**
* The texts for the windows below.
*
* The default is the base language rather than null: a window composed before a profile
* exists -- the operator profile picker -- still has to be able to draw.
*/
val LocalStrings = staticCompositionLocalOf {
Strings { key -> Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE)[key] ?: key }
}
- Schritt 4: Den Test schreiben, der die Komposition prüft
Dieselbe Bauform wie ThemeReadsPaletteStoreTest, das in Etappe 8 genau diesen Fehler gefunden
hat:
package kst4contest.view.i18n
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.util.Locale
/**
* A language change reaches a composed window without anything else happening.
*
* The one mechanism the acceptance criterion hangs on, and the one a state-level test cannot
* see: the store is always right, the question is whether the composition asks it again.
* Etappe 8 shipped exactly this fault for the palette.
*
* Headless: no window is opened, only a composition.
*/
class StringsReachTheCompositionTest {
@OptIn(ExperimentalTestApi::class)
@Test
fun aLanguageChosenAfterTheFirstDrawReachesTheComposition() = runComposeUiTest {
var stored = SYSTEM_LANGUAGE
val store = LanguageStore(
storedLanguage = { stored },
storeLanguage = { stored = it },
systemDefault = Locale.UK,
)
val seen = mutableListOf<String>()
setContent {
CompositionLocalProvider(LocalStrings provides store.strings) {
seen += LocalStrings.current.settingsSave
}
}
waitForIdle()
val firstDraw = seen.last()
store.use("de")
waitForIdle()
assertEquals("Save settings", firstDraw)
assertEquals(
"Einstellungen speichern",
seen.last(),
"the composition still holds the texts from before the change",
)
}
}
Erwartung beim ersten Lauf: dieser Test schlägt fehl, weil CompositionLocalProvider mit
store.strings nur bei einer Neuzeichnung der umgebenden Funktion neu liest. Das ist der
Befund, nicht ein Testfehler: die Bereitstellung muss innerhalb einer Komposition liegen, die
den Zustand liest. Die Lösung ist, store.strings im setContent-Block zu lesen — so wie
Kst4ContestTheme es für die Palette tut. Wenn der Test nach dieser Änderung grün ist, steht
die Mechanik; bleibt er rot, ist der Zustandszugriff noch außerhalb der Komposition.
- Schritt 5:
ComposeWindowHostden Store durchreichen
show(...) bekommt einen Parameter languageStore: LanguageStore? = null hinter
paletteStore, und die Bereitstellung kommt neben die der Palette:
CompositionLocalProvider(
LocalPaletteStore provides paletteStore,
LocalStrings provides
(languageStore?.strings ?: LocalStrings.current),
) {
Die Java-Aufrufstellen müssen den Store ausdrücklich übergeben — Kotlins Vorgabewerte sind
für Java unsichtbar, das hat Etappe 8 gemessen. Dieselben sechs Fenster-Wirte und dieselben
Aufrufstellen in Kst4ContestApplication wie dort, jeweils hinter paletteStore.
- Schritt 6: Bauen, Testzahl prüfen, committen
Aufgabe 7: Die Einstellung und die Sprachwahl im GUI-Reiter
Dateien:
- Ändern:
core/src/main/java/kst4contest/model/ChatPreferences.java - Anlegen:
core/src/test/java/kst4contest/model/ChatPreferencesLanguageTest.java - Ändern:
app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt - Ändern:
app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt - Ändern:
app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java
Schnittstellen:
-
Liefert:
ChatPreferences.getGuiOptions_language()/setGuiOptions_language(String), Vorgabe"",nullwird"". -
Schritt 1: Die Einstellung, genau nach dem Muster der Palettenfelder
Dieselben vier Stellen wie in Etappe 8, und diese Datei rückt mit Tabs ein:
- Feld neben
guiOptions_paletteOverridesEvening:
/**
* The operator's interface language: empty for the system language, otherwise a language
* code such as "de". Per profile, like every other GUI preference -- at a multi operator
* station two operators may want two languages.
*/
private String guiOptions_language = "";
- Getter und Setter nach dem Muster der Nachbarn,
nullwird"". - Ein
doc.createElement("guiOptions_language")-Block im Schreibpfad nebenguiOptions_paletteOverridesDay. - Eine
getText(...)-Lesestelle daneben, mit""als Rückfall.
CONFIG_VERSION bleibt bei 7. Die Begründung steht seit Etappe 8 im Javadoc der
Konstante: nichts in der Anwendung verzweigt über die Zahl.
- Schritt 2: Der Test dazu
package kst4contest.model;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** The interface language is stored per profile, like every other GUI preference. */
class ChatPreferencesLanguageTest {
@Test
void theDefaultIsTheSystemLanguage() {
// Empty means "follow the system", which is what the spec asks for on a first start.
assertEquals("", new ChatPreferences().getGuiOptions_language());
}
@Test
void aChosenLanguageIsKept() {
ChatPreferences prefs = new ChatPreferences();
prefs.setGuiOptions_language("de");
assertEquals("de", prefs.getGuiOptions_language());
}
@Test
void aNullSetterValueBecomesEmpty() {
ChatPreferences prefs = new ChatPreferences();
prefs.setGuiOptions_language(null);
assertEquals("", prefs.getGuiOptions_language());
}
}
- Schritt 3: Die Sprachwahl im Reiter
GuiOptionsTabState bekommt eine Eigenschaft nach dem Muster der Nachbarn, aber über den
Store statt direkt über die Einstellung — der Store ist die Stelle, die auch die Fenster
umschaltet:
class GuiOptionsTabState(
private val prefs: ChatPreferences,
private val languageStore: LanguageStore,
) {
...
/** The interface language, as the picker offers it. */
var language: String
get() = prefs.guiOptions_language
set(value) { languageStore.use(value) }
}
Und in GuiOptionsTab, als letzter Abschnitt, damit keine gewohnte Position wandert:
Form.section(strings.guiLanguage) {
Form.choice(
label = strings.guiLanguage,
items = listOf(SYSTEM_LANGUAGE) + Translations.BY_LANGUAGE.keys.sorted(),
selected = state.language,
describe = { code ->
if (code == SYSTEM_LANGUAGE) strings.guiLanguageSystem else code.uppercase()
},
onSelect = { state.language = it },
)
}
describe zeigt den Sprachcode in Großbuchstaben und nicht den Namen der Sprache: ein
Anzeigename pro Sprache wäre ein weiterer Schlüssel, den jeder Beitragende pflegen müsste, und
DE/EN ist für zwei Sprachen eindeutig. Wird die Liste länger, ist ein Name je Sprache die
naheliegende Erweiterung — jetzt nicht, YAGNI.
- Schritt 4: Den Store beim Start bilden
In Kst4ContestApplication.startRuntime(), neben paletteStore:
languageStore = new kst4contest.view.i18n.LanguageStore(
() -> chatcontroller.getChatPreferences().getGuiOptions_language(),
language -> chatcontroller.getChatPreferences().setGuiOptions_language(language),
java.util.Locale.getDefault());
Der Konstruktor hat einen Vorgabewert für systemDefault, den Java nicht sieht — deshalb wird
er hier ausdrücklich übergeben.
- Schritt 5: Bauen, Testzahl prüfen, committen
Aufgabe 8: Die drei Abgrenzungstests
Der Teil, auf den es fachlich ankommt. Keine neue Mechanik, nur Beweise.
Dateien:
-
Anlegen:
core/src/test/java/kst4contest/controller/On4KstProtocolLocaleTest.java -
Anlegen:
core/src/test/java/kst4contest/model/ChatPreferencesLocaleTest.java -
Anlegen:
app-desktop/src/test/kotlin/kst4contest/view/i18n/TranslationBoundaryTest.kt -
Schritt 1: Die Protokollrahmen sind sprachunabhängig
On4KstProtocol ist paketprivat, der Test gehört also in dasselbe Paket.
package kst4contest.controller;
import org.junit.jupiter.api.Test;
import java.util.Locale;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* The frames on the wire do not change with the interface language.
*
* A translated frame breaks the connection, and nothing about the user interface would show
* it. This checks the output rather than the translation files, because the output is what
* the server reads.
*/
class On4KstProtocolLocaleTest {
private <T> T underLocale(Locale locale, java.util.function.Supplier<T> body) {
Locale previous = Locale.getDefault();
try {
Locale.setDefault(locale);
return body.get();
} finally {
Locale.setDefault(previous);
}
}
@Test
void everyFrameIsIdenticalUnderGermanAndEnglish() {
for (Locale locale : new Locale[] { Locale.GERMANY, Locale.UK, Locale.forLanguageTag("tr") }) {
assertEquals(
underLocale(Locale.UK, () -> On4KstProtocol.settingsDone(1)),
underLocale(locale, () -> On4KstProtocol.settingsDone(1)),
"settingsDone under " + locale);
assertEquals(
underLocale(Locale.UK, () -> On4KstProtocol.addChat(1, 0L)),
underLocale(locale, () -> On4KstProtocol.addChat(1, 0L)),
"addChat under " + locale);
assertEquals(
underLocale(Locale.UK, On4KstProtocol::clientLivenessProbe),
underLocale(locale, On4KstProtocol::clientLivenessProbe),
"clientLivenessProbe under " + locale);
assertEquals(
underLocale(Locale.UK, On4KstProtocol::serverLivenessProbeResponse),
underLocale(locale, On4KstProtocol::serverLivenessProbeResponse),
"serverLivenessProbeResponse under " + locale);
}
}
}
Turkish ist absichtlich dabei. Es ist der Locale, unter dem "I".toLowerCase() nicht "i"
ergibt — die klassische Falle für jeden Protokollcode, der ohne Locale.ROOT umschaltet. Vor
dem Schreiben die echte Signatur von login(...) nachsehen und sie ebenfalls aufnehmen; sie
nimmt ein Passwort, das die Klasse laut eigenem Javadoc nie protokolliert.
- Schritt 2: Die Vorgabewerte ändern sich mit keiner Sprache, über Reflexion
package kst4contest.model;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* No default that goes to another radio amateur changes with the interface language.
*
* Over every String field by reflection rather than over a maintained list: the beacon texts
* and the auto answers are the ones anybody would think of, and they are also in every
* operator's stored XML, so translating one would alter existing profiles. The fields nobody
* thinks of are the reason this is reflective -- including the ones added after this test.
*/
class ChatPreferencesLocaleTest {
private Map<String, String> stringFieldsUnder(Locale locale) throws Exception {
Locale previous = Locale.getDefault();
try {
Locale.setDefault(locale);
ChatPreferences prefs = new ChatPreferences();
Map<String, String> values = new LinkedHashMap<>();
for (Field field : ChatPreferences.class.getDeclaredFields()) {
if (field.getType() != String.class || java.lang.reflect.Modifier.isStatic(field.getModifiers())) {
continue;
}
field.setAccessible(true);
values.put(field.getName(), (String) field.get(prefs));
}
return values;
} finally {
Locale.setDefault(previous);
}
}
@Test
void everyStringDefaultIsTheSameUnderGermanAndEnglish() throws Exception {
assertEquals(stringFieldsUnder(Locale.UK), stringFieldsUnder(Locale.GERMANY));
}
@Test
void theFourTextsThatGoToOtherRadioAmateursAreStillEnglish() throws Exception {
/*
* Named as well as covered reflectively: these four are in the stored XML of every
* operator, so a change to them is a change to existing profiles. If a later stage
* moves them, this test is the place that says it was deliberate.
*/
Map<String, String> values = stringFieldsUnder(Locale.GERMANY);
assertEquals("Hi, pse call us", values.get("bcn_beaconTextMainCat"));
assertEquals("Hi, pse call us", values.get("bcn_beaconTextSecondCat"));
org.junit.jupiter.api.Assertions.assertTrue(
values.get("messageHandling_autoAnswerTextMainCat").startsWith("Hi, sry I am not qrv"),
values.get("messageHandling_autoAnswerTextMainCat"));
}
}
Erwartung: dieser Test ist von Anfang an grün — er beweist einen Zustand, der heute gilt, und wacht darüber. Das ist Absicht und keine vergeudete Arbeit: er wird rot, wenn jemand in der Folgearbeit einen Vorgabewert ins Bündel zieht.
- Schritt 3: Kein Bündelwert ist Protokoll- oder Funkertext
package kst4contest.view.i18n
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.lang.reflect.Modifier
/**
* Nothing that goes to the server or to another radio amateur has got into the texts.
*
* The third of the three boundary checks, and the one that catches the follow-up work: the
* remaining ~380 strings get pulled into these files over time, and a beacon template or a
* `/CQ` sitting among them would be translated by the next contributor in good faith.
*/
class TranslationBoundaryTest {
private val everyText: List<String> =
Translations.BY_LANGUAGE.values.flatMap { it.values }
@Test
fun noTextIsAProtocolFrameOrAChatCommand() {
val forbidden = listOf("LOGINC", "SDONE", "ACHAT", "DXQ", "SETNAME", "MYQRG")
for (text in everyText) {
assertTrue(
forbidden.none { text.contains(it) },
"'$text' contains a protocol token; that text goes to the ON4KST server and "
+ "must not be in a translation file",
)
assertTrue(
!text.trimStart().startsWith("/"),
"'$text' looks like a chat command; those go to the server as a message",
)
}
}
@Test
fun noTextIsOneOfThePreferenceDefaultsThatGoToOtherRadioAmateurs() {
val prefs = ChatPreferences()
val defaults = ChatPreferences::class.java.declaredFields
.filter { it.type == String::class.java && !Modifier.isStatic(it.modifiers) }
.mapNotNull { field ->
field.isAccessible = true
(field.get(prefs) as String?)?.takeIf { it.isNotBlank() }
}
.toSet()
for (text in everyText) {
assertTrue(
text !in defaults,
"'$text' is a ChatPreferences default; it goes to other radio amateurs and is "
+ "in every operator's stored XML",
)
}
}
@Test
fun germanIsCompleteBecauseItIsAShippedLanguage() {
// Prüfschwerpunkt 6: only German. A contributed language may be partial by design.
val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE).keys
val german = Translations.BY_LANGUAGE.getValue("de").keys
assertEquals(
emptySet<String>(),
base - german,
"German is shipped, so a gap in it is a defect and not a contribution in progress",
)
}
@Test
fun aThirdLanguageIsNotHeldToCompleteness() {
/*
* Nothing here asserts over every language, and that is the point: this test exists so
* that adding strings_fr.properties with ten keys does not turn the build red.
*/
val held = setOf(Translations.BASE_LANGUAGE, "de")
assertTrue(
Translations.BY_LANGUAGE.keys.all { it in held || true },
"placeholder-free restatement: no language outside $held is checked for coverage",
)
}
}
Der letzte Test ist als Zusicherung schwach — || true ist immer wahr. Er steht als
Erinnerung im Quelltext und nicht als Prüfung; beim Umsetzen durch etwas Echtes ersetzen
oder ganz streichen und die Begründung stattdessen in das Javadoc von
germanIsCompleteBecauseItIsAShippedLanguage schreiben. Eine Zusicherung, die nicht fehlschlagen
kann, ist schlimmer als keine: sie sieht nach Abdeckung aus.
- Schritt 4: Bauen, Testzahl prüfen, committen
Aufgabe 9: Die dreizehn Texte einsetzen — GUI-ABNAHME
Dateien:
-
Ändern:
app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt:45,47,91,92,93,94 -
Ändern:
app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuModel.kt:48,61,86,107 -
Ändern:
app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt:22,28,43 -
Ändern:
app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt:171,188 -
Schritt 1: Die Aufrufstellen umstellen
Jede Compose-Aufrufstelle liest LocalStrings.current:
val strings = LocalStrings.current
...
Form.button(strings.settingsSave, onClick = onSave)
Form.button(strings.settingsApplyAndClose, onClick = onApplyAndClose)
ConnectionBar.kt:47 ist der Platzhalterfall und der Grund, warum MessageFormat da ist:
var label = strings.connectionConnectTo(main.getChatCategoryName(main.categoryNumber))
Vorher stand dort "Connect to " + main.getChatCategoryName(...). Im Deutschen steht der
Platzhalter vorn (Mit {0} verbinden), was durch Verketten nicht zu erreichen ist.
MainMenuModel.kt ist keine Composable-Funktion; mainMenuModel(...) bekommt die Texte
als Parameter:
fun mainMenuModel(
state: MainMenuState,
actions: MainMenuActions,
settingsWindowOpen: Boolean,
monitorWindowOpen: Boolean,
strings: Strings,
): List<MenuSpec>
Die beiden Aufrufer — Kst4ContestMenuBar und Kst4ContestMenuRow — sind Composables und
geben LocalStrings.current weiter. Das hält das Modell frei von Compose, wie es heute ist.
- Schritt 2: Bauen, Testzahl prüfen
./gradlew clean build
Erwartung: grün. DrawingLayerRegressionTest und die beiden Menü-Tests prüfen Texte — schlagen
sie fehl, erwarten sie englische Literale und sind auf strings umzustellen, nicht die
Texte zurückzuändern.
- Schritt 3: GUI-ABNAHME
./gradlew :app-desktop:run --args="--profile default"
Zu prüfen:
- GUI-Reiter → Sprache auf Deutsch → die drei Abschnittstitel und die Knöpfe der Verbindungsleiste wechseln sofort.
- Das Hauptfenster dahinter wechselt mit — die vier Menütitel werden Datei, Optionen, Fenster, Info. Das ist das Abnahmekriterium; wechselt nur das Einstellungsfenster, liest es den Zustand nicht.
- Verbindungsknopf:
Mit 144 MHz verbinden, nichtVerbinden mit 144 MHz— der Platzhalter steht vorn. - Einstellungen speichern, beenden, neu starten → noch deutsch.
- Zweites Profil → eigene Sprache, das erste bleibt unberührt.
- Auf Systemsprache stellen → bei deutschem System deutsch, sonst englisch.
- Eine Verbindung zu ON4KST aufbauen und einen
/CQsenden — das ist die Abnahme, die die Abgrenzungstests nicht leisten können: nichts am Protokoll oder am Funkertext darf sich geändert haben.
- Schritt 4: Committen
Aufgabe 10: Dokumentation
- Schritt 1: Beide Handbücher
Ein Abschnitt zur Sprachwahl in github_docs/de-Funktionen.md und
github_docs/en-Features.md: wo sie sitzt, dass sie sofort wirkt und pro Profil gilt, dass
Systemsprache die Vorgabe ist und Englisch der Rückfall, und dass ein unübersetzter Text
englisch erscheint statt zu fehlen.
- Schritt 2: Wie man eine Sprache beiträgt
Das ist der Zweck der Auslagerung und gehört dorthin, wo ein Beitragender sucht. In
github_docs/en-Features.md (englisch, weil Beitragende von außen kommen) und als Hinweis in
README.md: Datei kopieren, Werte übersetzen, ./gradlew :app-desktop:classes baut und meldet
Fehler, eine unvollständige Datei ist willkommen. Dazu das Arbeitsblatt
strings_<lang>.todo.properties erwähnen.
- Schritt 3:
CLAUDE.mdunddocs/PROJECT_CONTEXT.md
CLAUDE.md sagt heute „Das Projekt besteht aus zwei Modulen" — es sind drei, und das dritte
wird nicht ausgeliefert. In PROJECT_CONTEXT.md die dauerhaften Entscheidungen: die vier
Textsorten und die Regel des Adressaten, dass core keinen Anzeigetext erzeugt,
„Unvollständigkeit ist erlaubt, Unrichtigkeit nicht", und dass die Stelligkeit aus der
englischen Grundlage kommt.
- Schritt 4: Veraltete Aussagen suchen
grep -rniE "englisch|english|sprache|language" github_docs/*.md docs/PROJECT_CONTEXT.md CLAUDE.md \
| grep -viE "changelog" | head -20
Alles, was sagt, die Oberfläche sei englisch, ist jetzt unvollständig.
- Schritt 5: Committen
Was unverändert bleibt
- Die vier
ChatPreferences-Vorgabewerte und alles in der gespeicherten XML. corebekommt keine Nachschlagefunktion und erzeugt keinen Anzeigetext. Die beiden bekannten Verstöße (PathPropagationAssessment,PriorityCalculator) bleiben englisch.- Die Reiterreihenfolge im Einstellungsfenster; die Sprachwahl kommt als letzter Abschnitt in den bestehenden GUI-Reiter.
CONFIG_VERSIONbleibt 7.- Keine neue Abhängigkeit, weder im Jar noch im Bau.
- Quelltextkommentare und Javadoc bleiben ausschließlich Englisch.
- Die übrigen ~380 Texte bleiben englisch, mit der Regel aus den Prüfschwerpunkten für die Folgearbeit.
Bekannte Fallstricke
buildSrcist keine Option — gemessen, seine Tests laufen bei./gradlew buildnicht mit. Deshalb das eigene Modul.- Kotlins Vorgabewerte sind für Java unsichtbar. Jede Java-Aufrufstelle übergibt den Store ausdrücklich; Etappe 8 hat das auf die harte Art gelernt.
ComposeStationMapDrawingTestist flaky unter paralleler Last. Bei Rot zuerst isoliert nachprüfen.- PMD/SpotBugs melden rund 4448 vorbestehende Befunde und brechen den Build nicht. Das
erzeugte
Strings.ktliegt unterbuild/und wird davon nicht erfaßt. - Eine Zahl aus diesem Plan ist keine Messung. Die Testzahlen stehen als Erwartung da; abgelesen wird aus der XML.