Files
kst4contest/docs/superpowers/plans/2026-10-04-mehrsprachigkeit.md
T
Rsclub2_2 06446cf1d8 Document the two languages, and how to contribute a third
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.
2026-10-05 22:36:01 +02:00

86 KiB
Raw Blame History

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 build mit 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-*.xml auswerten. Grundlinie: 969 Tests, 3 übersprungen, 0 Fehler.
  • Immer mit Pfadangabe committen (git commit -- <pfade>). Dieser Arbeitsbaum trägt vorbestehende Änderungen von vor der Sitzung: .gitignore geändert, die Löschung von 0001-Document-v1.43-and-fix-release-metadata.patch gestapelt, AGENT_HANDOVER.md und docs/superpowers/UEBERGABE-2026-09-28.md gelöscht, docs/superpowers/UEBERGABE-2026-10-02.md unverfolgt. Nichts davon darf in einen Commit geraten.
  • Signierte Commits. Schlägt das Signieren mit Couldn't get agent socket fehl, 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.
  • core bekommt 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.

  1. 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.
  2. Ein doppelter Schlüssel in einer Datei darf nicht lautlos gewinnen. java.util.Properties behä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.
  3. buildSrc-Tests laufen bei ./gradlew build nicht 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.
  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 verschluckt MessageFormat Daten oder wirft. → Aufgabe 1.
  5. Zwei Schlüssel dürfen nicht auf denselben Kotlin-Namen fallen. gui.startupDesign und gui.startup_design würden beide zu guiStartupDesign. Das ergäbe Quelltext, der nicht übersetzt — mit einer Meldung des Kotlin-Compilers, die den Beitragenden nichts sagt. → A1.
  6. 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 — wirft TranslationException bei 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, Klasse Strings, Objekt Translations.

  • 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. Wirft TranslationException, 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 den Translations.BY_LANGUAGE einen Eintrag hat, sonst Translations.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()) mit language: 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: ComposeWindowHost den 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 "", null wird "".

  • 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:

  1. 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 = "";
  1. Getter und Setter nach dem Muster der Nachbarn, null wird "".
  2. Ein doc.createElement("guiOptions_language")-Block im Schreibpfad neben guiOptions_paletteOverridesDay.
  3. 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:

  1. GUI-Reiter → Sprache auf Deutsch → die drei Abschnittstitel und die Knöpfe der Verbindungsleiste wechseln sofort.
  2. 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.
  3. Verbindungsknopf: Mit 144 MHz verbinden, nicht Verbinden mit 144 MHz — der Platzhalter steht vorn.
  4. Einstellungen speichern, beenden, neu starten → noch deutsch.
  5. Zweites Profil → eigene Sprache, das erste bleibt unberührt.
  6. Auf Systemsprache stellen → bei deutschem System deutsch, sonst englisch.
  7. Eine Verbindung zu ON4KST aufbauen und einen /CQ senden — 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.md und docs/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.
  • core bekommt 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_VERSION bleibt 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

  • buildSrc ist keine Option — gemessen, seine Tests laufen bei ./gradlew build nicht 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.
  • ComposeStationMapDrawingTest ist 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.kt liegt unter build/ 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.