diff --git a/CLAUDE.md b/CLAUDE.md index 33711843..bea000b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ erzeugt und **nicht ausgeliefert** wird). Paketnamen sind über `core` und ## Sprache -Kommunikation mit Marc auf Deutsch, in **neuer Rechtschreibung** — `muss` und `dass`, +Kommunikation auf Deutsch, in **neuer Rechtschreibung** — `muss` und `dass`, nicht `muß` und `daß`. Quelltextkommentare und Javadoc bleiben ausschließlich Englisch. Oberflächentexte stehen seit Etappe 9 in `app-desktop/src/main/i18n/strings_.properties` @@ -38,8 +38,11 @@ Regel, was übersetzt wird, steht in `docs/PROJECT_CONTEXT.md` unter „Importan ## Laufende Arbeit -Spezifikationen liegen unter `docs/superpowers/specs/`, Umsetzungspläne unter -`docs/superpowers/plans/`. Die Ablösung von JavaFX durch Compose Multiplatform ist +Spezifikationen liegen unter `docs/superpowers/specs/` und bleiben: sie sagen, **warum** die +Architektur so ist, und das steht nirgends sonst. Umsetzungspläne werden nach der Ausführung +entfernt — sie sind Arbeitsanweisungen für erledigte Arbeit, und ein liegengebliebener Plan +liest sich für den Nächsten wie eine offene Absicht. Was aus ihnen zu behalten war, steht in +den Commit-Nachrichten und in `docs/PROJECT_CONTEXT.md`. Die Ablösung von JavaFX durch Compose Multiplatform ist **abgeschlossen**: unter `src/main` steht kein `javafx.` mehr, und `org.openjfx` ist aus `app-desktop/build.gradle.kts` und `gradle/libs.versions.toml` entfernt. Die Begründungen und die harten Lehren stehen in diff --git a/docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md b/docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md index 9273f1f6..3d663e40 100644 --- a/docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +++ b/docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md @@ -43,7 +43,8 @@ Erhoben am 1. Oktober 2026 auf Linux, Branch `nextMajorRelease/version1_50`. > und darum nicht testbar war). Erstmals mit Tests: vier, darunter „ein Stoß von 20 > Ereignissen wird zu einem Schreibvorgang". **Offen (Etappe 6):** die 11 Restdateien, die 19 > Steuerelemente, `org.openjfx` aus Build und Versionskatalog. -> Plan: `docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md`. +> Behoben in Etappe 4/5; der Plan ist nach seiner Ausführung entfernt worden, die +> Begründung steht in der Commit-Nachricht und hier. > **Etappe 6 abgeschlossen am 2026-10-04 — JavaFX ist raus.** Nachgewiesen, nicht > geschlussfolgert: `grep -rl "javafx\." core/src/main app-desktop/src/main` ist leer, es @@ -153,7 +154,8 @@ Erhoben am 1. Oktober 2026 auf Linux, Branch `nextMajorRelease/version1_50`. > `OperatorProfilePickerWindow` (eigener Faden plus `CountDownLatch`) würde dort verklemmen, > weil es genau den Faden blockiert, auf dem das neue Fenster zeichnen will. Der > Einzelkonstruktor `ComposeDialog(Dialog.ModalityType)` existiert und wird benutzt. -> Plan: `docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md`. +> Behoben in Etappe 4/5; der Plan ist nach seiner Ausführung entfernt worden, die +> Begründung steht in der Commit-Nachricht und hier. > **Teil 3 abgeschlossen am 2026-10-02.** Der gesamte durchsetzte JavaFX-Aufbau in > `Kst4ContestApplication` ist gelöscht; die Klasse ist von 11.179 auf ~5.100 Zeilen diff --git a/docs/superpowers/plans/2026-09-25-etappe1-gradle-build.md b/docs/superpowers/plans/2026-09-25-etappe1-gradle-build.md deleted file mode 100644 index 96083dd5..00000000 --- a/docs/superpowers/plans/2026-09-25-etappe1-gradle-build.md +++ /dev/null @@ -1,991 +0,0 @@ -# Etappe 1 — Gradle-Build, zwei Module, JPMS-Ausbau: Umsetzungsplan - -> **Für agentische Bearbeiter:** ERFORDERLICHE UNTER-SKILL: superpowers:subagent-driven-development (empfohlen) oder superpowers:executing-plans, um diesen Plan Aufgabe für Aufgabe umzusetzen. Schritte nutzen Checkbox-Syntax (`- [ ]`). - -**Ziel:** Die unveränderte JavaFX-Anwendung baut, testet und paketiert unter Gradle in zwei Modulen, ohne JPMS. - -**Architektur:** Ein Gradle-Multiprojekt mit `core` (Fachlichkeit) und `app-desktop` (Oberfläche). Paketnamen und Importe ändern sich **nicht** — Gradle-Module erzwingen keine Paketumbenennung, die Aufteilung ist ein reines Verschieben von Verzeichnissen. `module-info.java` entfällt; die Anwendung läuft künftig vom Klassenpfad. Das Packaging ruft `jpackage` weiterhin direkt auf, nur aus einer Gradle-Aufgabe statt aus Maven. - -**Tech-Stack:** Gradle 8.10, Java 21, JavaFX 21.0.5 (unverändert), `org.openjfx.javafxplugin` 0.1.0, SpotBugs- und PMD-Gradle-Plugins, `jpackage` aus dem JDK. - -**Spec:** `docs/superpowers/specs/2026-09-25-compose-migration-design.md` - -## Zwei Präzisierungen gegenüber dem Spec - -Beide sind Verkleinerungen des Risikos, keine Zieländerungen. Sie sind hier festgehalten, weil das Spec sie noch nicht kennt. - -1. **`core` darf in Etappe 1 noch von JavaFX abhängen.** Das Spec trennt Modulaufteilung (Etappe 1) und JavaFX-Ausbau (Etappe 2). Da `ChatController`, `ChatMember`, `ChatPreferences` und `ScoreService` heute JavaFX benutzen, deklariert `core` die JavaFX-Abhängigkeit in Etappe 1 weiterhin. Etappe 2 entfernt sie. Damit bleibt Etappe 1 eine reine Build-Umstellung ohne fachliche Änderung. - -2. **`compose.desktop.nativeDistributions` kommt erst in Etappe 3.** Das Spec nennt es für Etappe 1. Das Compose-Gradle-Plugin bündelt seine Laufzeit über `jlink`, und JavaFX ohne `module-info.java` vom Klassenpfad zu jlinken ist ein ungelöster Nebenschauplatz. Etappe 1 behält deshalb den heutigen, erprobten Weg: direkter `jpackage`-Aufruf auf ein Verzeichnis mit JAR und Abhängigkeiten. Auf `nativeDistributions` wird umgestellt, wenn mit Etappe 3 ohnehin Kotlin und Compose einziehen und JavaFX aus dem Packaging verschwindet. - -## Globale Randbedingungen - -- Java 21. `sourceCompatibility` und `targetCompatibility` bleiben 21. -- JavaFX bleibt auf 21.0.5. Kein Versionssprung in dieser Etappe. -- Projektversion bleibt `1.50.0-nightly`, identisch zum heutigen `pom.xml:9`. -- Hauptklasse bleibt `kst4contest.view.Kst4ContestApplication` (`pom.xml:26`). -- Anwendungsname bleibt `praktiKST` (`pom.xml:8`), da Launcher-, Paket- und Verzeichnisnamen daran hängen. -- **Keine Quelldatei unter `src/main/java/kst4contest/` wird inhaltlich geändert**, mit einer Ausnahme: `module-info.java` wird gelöscht. Dateien werden verschoben, nicht bearbeitet. -- Paketnamen und `import`-Anweisungen bleiben unverändert. -- Kommentare und Javadoc auf Englisch; Commit-Nachrichten knapp auf Englisch. -- Die Versionen der Gradle-Plugins werden zu Beginn von Aufgabe 1 gegen die aktuellen Veröffentlichungen geprüft; die hier genannten sind der Stand vom 2026-09-25. - -## Prüfschwerpunkte - -Build-Umstellungen brechen typischerweise nicht beim Übersetzen, sondern zur Laufzeit. Diese fünf Klassen von Fehlern deckt kein Kompilierlauf ab; jede bekommt ihren Test in der zuständigen Aufgabe. - -1. **Ressourcen fehlen im JAR.** `praktiKST.db`, `praktiKSTpreferences.xml`, CSS und die Karten-HTML werden über `getResourceAsStream` geladen. Ein falsch zugeordnetes `resources`-Verzeichnis fällt erst beim Start auf. → Aufgabe 4. -2. **JavaFX-Natives fehlen.** Ohne `module-info.java` lädt JavaFX vom Klassenpfad und braucht die plattformspezifischen Klassifikator-Artefakte. Fehlen sie, startet die Anwendung nicht. → Aufgabe 3. -3. **`jdk.jsobject` fehlt zur Laufzeit.** Die Karten-Brücke `StationMapBridge` benutzt `netscape.javascript.JSObject`. Das Modul stand bisher in `module-info.java` und in der jpackage-`addmodules`-Liste. Ohne JPMS muss es über `--add-modules` beim jpackage-Lauf erhalten bleiben. → Aufgabe 8. -4. **SQLite-Treiber wird nicht gefunden.** `sqlite-jdbc` lädt über den `ServiceLoader`; ein verlorener `META-INF/services`-Eintrag beim Zusammenstellen des Verzeichnisses zeigt sich erst beim ersten Datenbankzugriff. → Aufgabe 4. -5. **Die Versionsauflösung bricht.** Zehn Stellen in fünf Workflows und zwei PKGBUILDs lesen die Projektversion mit `grep -m1 '' pom.xml`. Wird `pom.xml` gelöscht, liefern sie eine leere Version und die Artefaktnamen werden falsch — ohne dass der Build scheitert. → Aufgaben 1, 9, 10 und 12. -6. **Das Paket startet, die Anwendung nicht.** Ein `jpackage`-Image kann sich erzeugen lassen und beim Start scheitern. Jede Packaging-Aufgabe endet mit einem echten Start des erzeugten Artefakts. → Aufgaben 8 und 9. - ---- - -## Aufgabe 1: Gradle-Gerüst neben Maven - -Maven bleibt in dieser Aufgabe vollständig funktionsfähig. Erst Aufgabe 12 entfernt es. - -**Dateien:** -- Anlegen: `settings.gradle.kts` -- Anlegen: `build.gradle.kts` -- Anlegen: `gradle/libs.versions.toml` -- Anlegen: `gradlew`, `gradlew.bat`, `gradle/wrapper/gradle-wrapper.jar`, `gradle/wrapper/gradle-wrapper.properties` (durch `gradle wrapper` erzeugt) -- Ändern: `.gitignore` - -**Schnittstellen:** -- Erzeugt: die Projekte `:core` und `:app-desktop`, auf die alle folgenden Aufgaben verweisen. - -- [ ] **Schritt 1: Aktuelle Plugin-Versionen prüfen** - -Vor dem Schreiben der Dateien die aktuellen Veröffentlichungen nachsehen: -- Gradle (hier angenommen: 8.10) -- `org.openjfx.javafxplugin` (hier angenommen: 0.1.0) -- `com.github.spotbugs` Gradle-Plugin (hier angenommen: 6.0.26) - -Weichen sie ab, die neueren Versionen verwenden und die Abweichung im Ledger vermerken. - -- [ ] **Schritt 2: Wrapper erzeugen** - -Ausführen: `gradle wrapper --gradle-version 8.10` -Erwartet: `gradlew`, `gradlew.bat` und `gradle/wrapper/` entstehen. - -Ist kein Gradle installiert, den Wrapper aus einer temporären Gradle-Distribution erzeugen. - -- [ ] **Schritt 3: `settings.gradle.kts` schreiben** - -```kotlin -rootProject.name = "kst4contest" - -include(":core") -include(":app-desktop") -``` - -- [ ] **Schritt 3b: `gradle.properties` schreiben** - -```properties -version=1.50.0-nightly -group=de.praktimarc -org.gradle.parallel=true -org.gradle.caching=true -``` - -Diese Datei ist ab Aufgabe 9 die **einzige** Quelle der Projektversion für CI und Paketbau. Sie ist bewusst so einfach gehalten, dass ein `grep` sie lesen kann, ohne Gradle zu starten. - -`version` und `group` müssen den Werten aus `pom.xml:9` und dem dortigen `groupId` entsprechen. - -- [ ] **Schritt 4: `gradle/libs.versions.toml` schreiben** - -```toml -[versions] -java = "21" -javafx = "21.0.5" -javafxPlugin = "0.1.0" -sqliteJdbc = "3.46.1.3" -jlayer = "1.0.1" -junit = "5.10.2" -mockito = "5.12.0" -spotbugsPlugin = "6.0.26" - -[libraries] -sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqliteJdbc" } -jlayer = { module = "javazoom:jlayer", version.ref = "jlayer" } -junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } -mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } -mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" } - -[plugins] -javafx = { id = "org.openjfx.javafxplugin", version.ref = "javafxPlugin" } -spotbugs = { id = "com.github.spotbugs", version.ref = "spotbugsPlugin" } -``` - -Die Bibliotheksversionen müssen den Werten in `pom.xml` entsprechen. Vor dem Schreiben mit `grep -n "sqlite-jdbc" -A2 pom.xml` und den entsprechenden Blöcken abgleichen und bei Abweichung die Werte aus `pom.xml` übernehmen. - -- [ ] **Schritt 5: `build.gradle.kts` im Wurzelverzeichnis schreiben** - -```kotlin -plugins { - java -} - -allprojects { - group = providers.gradleProperty("group").get() - version = providers.gradleProperty("version").get() - - repositories { - mavenCentral() - } -} - -subprojects { - apply(plugin = "java") - - extensions.configure { - toolchain { - languageVersion.set(JavaLanguageVersion.of(21)) - } - } - - tasks.withType().configureEach { - useJUnitPlatform() - } -} -``` - -`group` und `version` kommen aus `gradle.properties`. Prüfen: `./gradlew -q properties | grep -E "^(group|version):"` — erwartet `group: de.praktimarc` und `version: 1.50.0-nightly`, übereinstimmend mit `pom.xml:9`. - -- [ ] **Schritt 6: Modulverzeichnisse und Platzhalter-Builds anlegen** - -```bash -mkdir -p core/src/main/java app-desktop/src/main/java -printf 'plugins {\n java\n}\n' > core/build.gradle.kts -printf 'plugins {\n java\n}\n' > app-desktop/build.gradle.kts -``` - -- [ ] **Schritt 7: `.gitignore` ergänzen** - -Folgende Zeilen anfügen: - -``` -.gradle/ -build/ -core/build/ -app-desktop/build/ -``` - -- [ ] **Schritt 8: Gerüst prüfen** - -Ausführen: `./gradlew projects` -Erwartet: Ausgabe listet `Root project 'kst4contest'` mit den Unterprojekten `core` und `app-desktop`. - -- [ ] **Schritt 9: Maven ist unberührt** - -Ausführen: `./mvnw -q -DskipTests compile` -Erwartet: Erfolg. Die Gradle-Dateien dürfen den Maven-Build nicht stören. - -- [ ] **Schritt 10: Commit** - -```bash -git add settings.gradle.kts build.gradle.kts gradle.properties gradle core/build.gradle.kts app-desktop/build.gradle.kts gradlew gradlew.bat .gitignore -git commit -m "Add the Gradle multi-project skeleton alongside Maven" -``` - ---- - -## Aufgabe 2: Quellen auf die beiden Module verteilen - -**Dateien:** -- Verschieben: `src/main/java/kst4contest/{model,controller,logic,utils,locatorUtils,service}` → `core/src/main/java/kst4contest/` -- Verschieben: `src/main/java/kst4contest/{view,test}` → `app-desktop/src/main/java/kst4contest/` -- Löschen: `src/main/java/module-info.java` -- Ändern: `core/build.gradle.kts`, `app-desktop/build.gradle.kts` - -**Schnittstellen:** -- Konsumiert: die Projekte `:core` und `:app-desktop` aus Aufgabe 1. -- Erzeugt: `:app-desktop` hängt über `implementation(project(":core"))` an `:core`. - -- [ ] **Schritt 1: Aufteilung festlegen und prüfen** - -Ausführen: `find src/main/java/kst4contest -maxdepth 1 -mindepth 1 -type d | sort` -Erwartet: `controller`, `locatorUtils`, `logic`, `model`, `service`, `test`, `utils`, `view`. - -`view` (samt `view/map`) geht nach `app-desktop`, alles andere nach `core`. `kst4contest.test` enthält Hilfsklassen mit JavaFX-Bezug und geht ebenfalls nach `app-desktop`. - -- [ ] **Schritt 2: Verschieben** - -```bash -mkdir -p core/src/main/java/kst4contest app-desktop/src/main/java/kst4contest -git mv src/main/java/kst4contest/model core/src/main/java/kst4contest/ -git mv src/main/java/kst4contest/controller core/src/main/java/kst4contest/ -git mv src/main/java/kst4contest/logic core/src/main/java/kst4contest/ -git mv src/main/java/kst4contest/utils core/src/main/java/kst4contest/ -git mv src/main/java/kst4contest/locatorUtils core/src/main/java/kst4contest/ -git mv src/main/java/kst4contest/service core/src/main/java/kst4contest/ -git mv src/main/java/kst4contest/view app-desktop/src/main/java/kst4contest/ -git mv src/main/java/kst4contest/test app-desktop/src/main/java/kst4contest/ -git rm src/main/java/module-info.java -``` - -- [ ] **Schritt 3: `core/build.gradle.kts` schreiben** - -```kotlin -plugins { - java - alias(libs.plugins.javafx) -} - -javafx { - version = libs.versions.javafx.get() - modules = listOf("javafx.controls", "javafx.fxml", "javafx.web", "javafx.media") -} - -dependencies { - implementation(libs.sqlite.jdbc) - implementation(libs.jlayer) -} -``` - -> JavaFX steht hier bewusst noch drin. `ChatController`, `ChatMember`, `ChatPreferences` und `ScoreService` benutzen es. Etappe 2 entfernt diesen Block. - -- [ ] **Schritt 4: `app-desktop/build.gradle.kts` schreiben** - -```kotlin -plugins { - java - application - alias(libs.plugins.javafx) -} - -javafx { - version = libs.versions.javafx.get() - modules = listOf("javafx.controls", "javafx.fxml", "javafx.web", "javafx.media") -} - -dependencies { - implementation(project(":core")) - implementation(libs.sqlite.jdbc) - implementation(libs.jlayer) -} - -application { - mainClass.set("kst4contest.view.Kst4ContestApplication") -} -``` - -- [ ] **Schritt 5: Übersetzen** - -Ausführen: `./gradlew :core:compileJava` -Erwartet: BUILD SUCCESSFUL. - -Scheitert es mit fehlenden Typen aus `kst4contest.view`, ist eine Klasse falsch einsortiert. Die betroffene Klasse ermitteln, prüfen ob sie Fachlichkeit oder Oberfläche ist, und entsprechend verschieben. Jede solche Entscheidung als `Ruling:` ins Ledger. - -- [ ] **Schritt 6: `app-desktop` übersetzen** - -Ausführen: `./gradlew :app-desktop:compileJava` -Erwartet: BUILD SUCCESSFUL. - -- [ ] **Schritt 7: Commit** - -```bash -git add -A -git commit -m "Split the sources into the core and app-desktop modules" -``` - ---- - -## Aufgabe 3: JavaFX-Natives und Start aus Gradle - -Deckt Prüfschwerpunkt 2 ab. - -**Dateien:** -- Ändern: `app-desktop/build.gradle.kts` - -- [ ] **Schritt 1: Start versuchen** - -Ausführen: `./gradlew :app-desktop:run` -Erwartet: Das Hauptfenster erscheint. - -Erscheint stattdessen eine Meldung wie `Graphics Device initialization failed` oder `no javafx.graphics in java.library.path`, fehlen die plattformspezifischen JavaFX-Artefakte. Weiter mit Schritt 2. Startet die Anwendung, Schritt 2 überspringen und im Ledger vermerken. - -- [ ] **Schritt 2: Klassifikator-Abhängigkeiten ergänzen** - -In `app-desktop/build.gradle.kts` vor dem `dependencies`-Block einfügen: - -```kotlin -val javafxPlatform = when { - org.gradle.internal.os.OperatingSystem.current().isWindows -> "win" - org.gradle.internal.os.OperatingSystem.current().isMacOsX -> - if (System.getProperty("os.arch") == "aarch64") "mac-aarch64" else "mac" - else -> "linux" -} -``` - -und im `dependencies`-Block: - -```kotlin -listOf("javafx-base", "javafx-graphics", "javafx-controls", "javafx-fxml", "javafx-web", "javafx-media") - .forEach { module -> - runtimeOnly("org.openjfx:$module:${libs.versions.javafx.get()}:$javafxPlatform") - } -``` - -- [ ] **Schritt 3: Start erneut versuchen** - -Ausführen: `./gradlew :app-desktop:run` -Erwartet: Das Hauptfenster erscheint. - -- [ ] **Schritt 4: Commit** - -```bash -git add app-desktop/build.gradle.kts -git commit -m "Resolve the JavaFX native artifacts for the Gradle run task" -``` - ---- - -## Aufgabe 4: Ressourcen und Tests zuordnen - -Deckt Prüfschwerpunkte 1 und 4 ab. - -**Dateien:** -- Verschieben: `src/main/resources/*` → `core/src/main/resources/` bzw. `app-desktop/src/main/resources/` -- Verschieben: `src/test/java/kst4contest/*` → `core/src/test/java/kst4contest/` bzw. `app-desktop/src/test/java/kst4contest/` -- Ändern: `core/build.gradle.kts`, `app-desktop/build.gradle.kts` - -**Schnittstellen:** -- Konsumiert: die Modulaufteilung aus Aufgabe 2. - -**Zuordnungsregel:** `app-desktop` sieht die Ressourcen von `core` zur Laufzeit, umgekehrt nicht. **Im Zweifel gehört eine Ressource nach `core`.** Eine fälschlich nach `core` gelegte Ressource ist folgenlos; eine fälschlich nach `app-desktop` gelegte bricht `core` zur Laufzeit. - -- [ ] **Schritt 1: Ressourcen verschieben** - -```bash -mkdir -p core/src/main/resources app-desktop/src/main/resources -git mv src/main/resources/praktiKST.db core/src/main/resources/ -git mv src/main/resources/praktiKSTpreferences.xml core/src/main/resources/ -git mv src/main/resources/praktiKSTpreferences.old core/src/main/resources/ -git mv src/main/resources/icons core/src/main/resources/ -for f in src/main/resources/*.mp3; do git mv "$f" core/src/main/resources/; done -git mv src/main/resources/leaflet-1.9.4.css app-desktop/src/main/resources/ -git mv src/main/resources/leaflet-1.9.4.js app-desktop/src/main/resources/ -git mv src/main/resources/KST4ContestDefaultDay.css app-desktop/src/main/resources/ -git mv src/main/resources/KST4ContestDefaultEvening.css app-desktop/src/main/resources/ -git mv src/main/resources/web app-desktop/src/main/resources/ -``` - -- [ ] **Schritt 2: Tests verschieben** - -```bash -mkdir -p core/src/test/java/kst4contest app-desktop/src/test/java/kst4contest -git mv src/test/java/kst4contest/controller core/src/test/java/kst4contest/ -git mv src/test/java/kst4contest/test core/src/test/java/kst4contest/ -git mv src/test/java/kst4contest/view app-desktop/src/test/java/kst4contest/ -``` - -- [ ] **Schritt 3: Testabhängigkeiten ergänzen** - -In `core/build.gradle.kts` und `app-desktop/build.gradle.kts` jeweils im `dependencies`-Block anfügen: - -```kotlin - testImplementation(libs.junit.jupiter) - testImplementation(libs.mockito.core) - testImplementation(libs.mockito.junit.jupiter) -``` - -- [ ] **Schritt 4: Tests übersetzen** - -Ausführen: `./gradlew :core:compileTestJava :app-desktop:compileTestJava` -Erwartet: BUILD SUCCESSFUL. - -Scheitert eine Testklasse in `core` an einem Typ aus `kst4contest.view`, gehört sie nach `app-desktop`. Verschieben und als `Ruling:` ins Ledger. - -- [ ] **Schritt 5: Tests ausführen** - -Ausführen: `./gradlew test` -Erwartet: BUILD SUCCESSFUL. Die Summe der **ausgeführten Testfälle** beider Module beträgt **208**. - -Die Zahl prüfen: -`find core/build/test-results app-desktop/build/test-results -name "TEST-*.xml" -exec grep -ho 'tests="[0-9]*"' {} + | grep -o '[0-9]*' | paste -sd+ | bc` -Erwartet: `208`. - -Die Zahl ist nicht die der `@Test`-Annotationen (148): sechs `@ParameterizedTest`-Methoden expandieren zu 60 Fällen. Die belastbare Invariante ist die Dateizahl: - -`find core/src/test app-desktop/src/test -name "*.java" | wc -l` -Erwartet: `43` — dieselbe Zahl wie vorher unter `src/test/java`. - -Weicht eine der Zahlen ab, fehlt eine Testklasse oder wurde eine doppelt eingebunden. Ursache suchen, nicht die Erwartung anpassen. - -- [ ] **Schritt 6: Anwendung starten und Datenbank prüfen** - -Ausführen: `./gradlew :app-desktop:run` -Erwartet: Das Hauptfenster erscheint, ohne dass auf der Konsole `SQLException`, `ClassNotFoundException: org.sqlite.JDBC` oder eine Meldung über eine fehlende Ressource erscheint. - -Im laufenden Programm das Einstellungsfenster öffnen und den Reiter **Workedstn database** aufrufen. -Erwartet: Der Reiter zeigt Zahlen aus der Datenbank, keine Fehlermeldung. Das beweist, dass `praktiKST.db` gefunden und der SQLite-Treiber über den `ServiceLoader` geladen wurde. - -- [ ] **Schritt 7: Commit** - -```bash -git add -A -git commit -m "Assign resources and tests to the core and app-desktop modules" -``` - ---- - -## Aufgabe 5: Lombok entfernen - -**Dateien:** -- Ändern: `pom.xml` - -- [ ] **Schritt 1: Nachweisen, dass Lombok ungenutzt ist** - -Ausführen: `grep -rl "lombok" core/src app-desktop/src | wc -l` -Erwartet: `0`. - -Ist die Zahl größer als 0, wird Lombok doch benutzt. Dann diese Aufgabe überspringen, im Ledger als `Ruling:` festhalten und mit Aufgabe 6 weitermachen. - -- [ ] **Schritt 2: Aus `pom.xml` entfernen** - -Den ``-Block mit `lombok` (ab `pom.xml:114`) und den ``-Eintrag für Lombok im `maven-compiler-plugin` (ab `pom.xml:251`) löschen. - -In den Gradle-Dateien taucht Lombok nicht auf, dort ist nichts zu tun. - -- [ ] **Schritt 3: Beide Builds prüfen** - -Ausführen: `./gradlew build -x test` -Erwartet: BUILD SUCCESSFUL. - -Für den Maven-Build gilt ab hier: die Enforcer-Ausführung `verify-packaging-module-list` wurde bereits in dieser Aufgabe entfernt, weil das Löschen von `module-info.java` in Aufgabe 2 sie sonst dauerhaft brechen würde. Ohne sie übersetzt Maven weiter: - -Ausführen: `./mvnw -q -DskipTests compile` -Erwartet: Erfolg. - -- [ ] **Schritt 4: Commit** - -```bash -git add pom.xml -git commit -m "Drop the unused Lombok dependency" -``` - ---- - -## Aufgabe 6: SpotBugs und PMD unter Gradle - -**Dateien:** -- Ändern: `build.gradle.kts` -- Unverändert: `pmd-ruleset.xml` - -- [ ] **Schritt 1: Heutige Konfiguration ablesen** - -Ausführen: `grep -n -A12 "spotbugs-maven-plugin" pom.xml && grep -n -A12 "maven-pmd-plugin" pom.xml` - -Aus der Ausgabe `effort`, `threshold`, `failOnViolation` und den Verweis auf `pmd-ruleset.xml` notieren. Diese Werte werden im nächsten Schritt übernommen, nicht neu erfunden. - -- [ ] **Schritt 2: Plugins im Wurzel-Build ergänzen** - -In `build.gradle.kts` den `plugins`-Block erweitern: - -```kotlin -plugins { - java - alias(libs.plugins.spotbugs) apply false - pmd apply false -} -``` - -und im `subprojects`-Block anfügen: - -```kotlin - apply(plugin = "com.github.spotbugs") - apply(plugin = "pmd") - - extensions.configure { - toolVersion = "7.7.0" - ruleSetFiles = files(rootProject.file("pmd-ruleset.xml")) - ruleSets = emptyList() - isIgnoreFailures = true - } -``` - -`isIgnoreFailures` entspricht dem heutigen `failOnViolation`. Steht dort `true`, muss hier `false` stehen. - -- [ ] **Schritt 3: Analysen ausführen** - -Ausführen: `./gradlew pmdMain spotbugsMain` -Erwartet: BUILD SUCCESSFUL. Berichte liegen unter `core/build/reports/` und `app-desktop/build/reports/`. - -- [ ] **Schritt 4: Commit** - -```bash -git add build.gradle.kts -git commit -m "Run SpotBugs and PMD from the Gradle build" -``` - ---- - -## Aufgabe 7: jpackage-Image aus Gradle - -**Dateien:** -- Ändern: `app-desktop/build.gradle.kts` - -**Schnittstellen:** -- Erzeugt: die Gradle-Aufgabe `:app-desktop:packageImage`, die aus `app-desktop/build/dist-libs/` (JAR als `app.jar`) ein `jpackage`-Image unter `app-desktop/build/jpackage/` erzeugt. Aufgaben 9, 10 und 11 rufen sie auf. - -Die heutige Maven-Strecke sammelt JAR und Abhängigkeiten in `target/modules` und ruft darauf `jpackage --type IMAGE`. Gradle bekommt dieselbe Strecke. - -- [ ] **Schritt 1: Sammel- und Packaging-Aufgabe ergänzen** - -An `app-desktop/build.gradle.kts` anfügen: - -```kotlin -val collectRuntime by tasks.registering(Sync::class) { - dependsOn(tasks.jar) - from(tasks.jar) { rename { "app.jar" } } - from(configurations.runtimeClasspath) - into(layout.buildDirectory.dir("dist-libs")) -} - -val packageImage by tasks.registering(Exec::class) { - dependsOn(collectRuntime) - val input = layout.buildDirectory.dir("dist-libs").get().asFile - val output = layout.buildDirectory.dir("jpackage").get().asFile - val os = org.gradle.internal.os.OperatingSystem.current() - val icon = when { - os.isWindows -> "packaging/icons/kst4contest.ico" - os.isMacOsX -> "packaging/icons/kst4contest.icns" - else -> "packaging/icons/kst4contest.png" - } - doFirst { output.deleteRecursively() } - commandLine( - "jpackage", - "--icon", rootProject.file(icon).absolutePath, - "--type", "app-image", - "--name", "praktiKST", - "--input", input.absolutePath, - "--dest", output.absolutePath, - "--main-jar", "app.jar", - "--main-class", "kst4contest.view.Kst4ContestApplication", - "--java-options", "-Dfile.encoding=UTF-8" - ) -} -``` - -> `--icon` ist nicht optional: ohne dieses Argument liefert `jpackage` stillschweigend sein Standardsymbol aus. Alle heutigen CI-Aufrufe setzen es (`nightly-artifacts.yml:72`, `:130` und weitere). - -- [ ] **Schritt 2: Image bauen** - -Ausführen: `./gradlew :app-desktop:packageImage` -Erwartet: BUILD SUCCESSFUL, und `app-desktop/build/jpackage/praktiKST/` existiert. - -Prüfen: `ls app-desktop/build/jpackage/praktiKST/bin/` -Erwartet: eine ausführbare Datei `praktiKST`. - -- [ ] **Schritt 3: Erzeugtes Image starten** - -Ausführen: `./app-desktop/build/jpackage/praktiKST/bin/praktiKST` -Erwartet: Das Hauptfenster erscheint. - -Scheitert der Start an fehlenden JavaFX-Klassen, enthält `runtime-input` die Klassifikator-Artefakte nicht. Dann in Schritt 1 `configurations.runtimeClasspath` gegen eine Konfiguration prüfen, die die `runtimeOnly`-Einträge aus Aufgabe 3 enthält. - -> Das Verzeichnis heißt bewusst `dist-libs` und das Haupt-JAR bewusst `app.jar`: die CI-Strecken in `.github/workflows/` rufen `jpackage` heute auf genau diese Form auf. Damit ändert Aufgabe 9 nur die Build-Befehle, nicht die `jpackage`-Aufrufe. - -- [ ] **Schritt 4: Commit** - -```bash -git add app-desktop/build.gradle.kts -git commit -m "Build the jpackage app-image from Gradle" -``` - ---- - -## Aufgabe 8: JPMS-Reste entfernen und `--add-modules` sichern - -Deckt Prüfschwerpunkte 3 und 5 ab. - -**Dateien:** -- Ändern: `app-desktop/build.gradle.kts` -- Löschen: `packaging/AddModules.java` -- Ändern: `pom.xml` (Enforcer-Ausführung `verify-packaging-module-list`, ab `pom.xml:225`) - -- [ ] **Schritt 1: Die Karten-Brücke ohne `--add-modules` scheitern sehen** - -Das Image aus Aufgabe 7 starten und das Kartenfenster öffnen. -Erwartet: Entweder erscheint die Karte nicht, oder auf der Konsole steht ein Fehler zu `netscape.javascript.JSObject` bzw. `jdk.jsobject`. - -Erscheint die Karte fehlerfrei, ist `jdk.jsobject` in der Standard-Laufzeit enthalten. Das im Ledger festhalten und Schritt 2 trotzdem ausführen — die Liste sichert auch die übrigen Module ab. - -- [ ] **Schritt 2: `--add-modules` in die Gradle-Aufgabe übernehmen** - -Die heutige Liste steht in `pom.xml` ab `:478`. Ohne `javafx.*` (JavaFX kommt vom Klassenpfad) bleibt: - -In `app-desktop/build.gradle.kts` die `commandLine`-Liste in `packageImage` um folgende Einträge **vor** `--java-options` erweitern: - -```kotlin - "--add-modules", - "java.desktop,java.net.http,java.sql,jdk.crypto.ec,jdk.jsobject,jdk.net,jdk.xml.dom", -``` - -- [ ] **Schritt 3: Neu bauen und Karte prüfen** - -Ausführen: `./gradlew :app-desktop:packageImage && ./app-desktop/build/jpackage/praktiKST/bin/praktiKST` -Erwartet: Das Hauptfenster erscheint, das Kartenfenster zeigt Kacheln und Stationsmarker, und auf der Konsole steht kein Fehler zu `JSObject`. - -- [ ] **Schritt 4: Helfer und Enforcer-Regel entfernen** - -```bash -git rm packaging/AddModules.java -``` - -Die Enforcer-Ausführung `verify-packaging-module-list` samt `exec-maven-plugin`-Aufruf und erklärendem Kommentar wurde bereits in Aufgabe 5 aus `pom.xml` entfernt — sie brach ab Aufgabe 2, weil `module-info.java` dort entfiel. Hier ist nur noch zu prüfen: - -Ausführen: `grep -c "verify-packaging-module-list" pom.xml` -Erwartet: `0`. - -- [ ] **Schritt 5: Prüfen, dass nichts mehr auf den Helfer verweist** - -Ausführen: `grep -rln "AddModules" .github/ packaging/ pom.xml` -Erwartet: `.github/workflows/pr-compile-check.yml`, `.github/workflows/nightly-artifacts.yml`, `.github/workflows/tagged-release.yml`, `packaging/macos/build-signed-dmg.sh`, `packaging/aur/kst4contest/PKGBUILD`, `packaging/aur/kst4contest-git/PKGBUILD` und ein erklärender Kommentar in `pom.xml`. Zusammen 18 Aufrufstellen — sie werden in den Aufgaben 9, 10 und 11 auf `gradle.properties` umgestellt. - -Die Modulliste ist ohne `module-info.java` eine Konstante geworden. Damit sie nicht über 18 Stellen driftet — genau das verhinderte bisher `AddModules.java` —, steht sie als `jpackageAddModules` in `gradle.properties`, neben der Version. CI und PKGBUILDs lesen sie mit demselben `grep` wie die Version: - -```bash -ADD_MODULES=$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2) -``` - -- [ ] **Schritt 6: Commit** - -```bash -git add -A -git commit -m "Drop the JPMS module list helper and pin the runtime modules in Gradle" -``` - ---- - -## Aufgabe 9: PR-Prüfung und Nightly-Strecke auf Gradle - -Deckt Prüfschwerpunkt 5 auf Windows und Linux ab — dort ist die CI die einzige Prüfmöglichkeit. - -**Dateien:** -- Ändern: `.github/workflows/pr-compile-check.yml` -- Ändern: `.github/workflows/nightly-artifacts.yml` - -**Schnittstellen:** -- Konsumiert: `:app-desktop:collectRuntime` aus Aufgabe 7, das `app-desktop/build/dist-libs/` mit `app.jar` füllt. - -Das Muster ist in allen Strecken gleich. Ersetzt wird jeweils nur der Build-Schritt; die `jpackage`-Aufrufe bleiben, nur ihr `--input`-Pfad wechselt von `target/dist-libs` auf `app-desktop/build/dist-libs`. - -- [ ] **Schritt 1: `pr-compile-check.yml` anpassen** - -Ersetzen: - -```yaml - - name: Ensure mvnw is executable - run: chmod +x mvnw - - name: Verify packaging module list - run: java packaging/AddModules.java --verify-pom - - name: Compile - run: ./mvnw -B -DskipTests compile -``` - -durch: - -```yaml - - name: Ensure gradlew is executable - run: chmod +x gradlew - - name: Compile - run: ./gradlew -S compileJava compileTestJava -``` - -Der Schritt „Verify packaging module list" entfällt ersatzlos — `packaging/AddModules.java` wurde in Aufgabe 8 gelöscht. - -- [ ] **Schritt 2: Prüfen, dass kein Verweis auf den Helfer bleibt** - -Ausführen: `grep -rn "AddModules" .github/ packaging/ pom.xml` -Erwartet: keine Ausgabe. - -- [ ] **Schritt 3: `nightly-artifacts.yml` — Build-Schritte ersetzen** - -`nightly-artifacts.yml` hat sieben bauende Jobs: `build-windows-zip` (`:25`), `build-linux-appimage` (`:95`), `build-linux-deb` (`:177`), `build-linux-rpm` (`:238`), `build-linux-arch` (`:299`), `build-flatpak` (`:420`) und `build-macos-dmg` (`:611`). `publish-flatpak-repo` (`:550`) baut nicht und bleibt unverändert. In **jedem** der sieben Jobs die Zeilenpaare - -```yaml - - name: Ensure mvnw is executable - run: chmod +x mvnw -``` - -ersetzen durch - -```yaml - - name: Ensure gradlew is executable - run: chmod +x gradlew -``` - -und jede Zeile der Form - -``` -./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs -cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar -``` - -ersetzen durch die eine Zeile - -``` -./gradlew -S :app-desktop:collectRuntime -``` - -Auf Windows lautet die Entsprechung `.\gradlew.bat -S :app-desktop:collectRuntime` anstelle von `.\mvnw.cmd ...`. - -- [ ] **Schritt 4: `--input`-Pfade umstellen** - -In jedem `jpackage`-Aufruf in `nightly-artifacts.yml` das Argument `--input target/dist-libs` durch `--input app-desktop/build/dist-libs` ersetzen. - -Ausführen: `grep -n "target/dist-libs" .github/workflows/nightly-artifacts.yml` -Erwartet: keine Ausgabe. - -- [ ] **Schritt 5: `--add-modules` in der CI nachziehen** - -Jeder `jpackage`-Aufruf, der bisher auf die JPMS-Modulliste gebaut hat, braucht dieselbe Liste wie Aufgabe 8: - -``` ---add-modules java.desktop,java.net.http,java.sql,jdk.crypto.ec,jdk.jsobject,jdk.net,jdk.xml.dom -``` - -Enthält ein Aufruf bereits `--add-modules` mit `javafx.*`-Einträgen, werden die `javafx.*`-Einträge gestrichen und die übrigen auf obige Liste gebracht. - -- [ ] **Schritt 5b: Versionsauflösung umstellen** - -Jeder bauende Job beginnt mit einem Schritt „Resolve nightly version info", der so aussieht: - -```yaml - VERSION=$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/') -``` - -Diese Zeile in **jedem** Job ersetzen durch: - -```yaml - VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2) -``` - -Ausführen: `grep -c "pom.xml" .github/workflows/nightly-artifacts.yml` -Erwartet: `0`. - -- [ ] **Schritt 6: Lokale Trockenprüfung der YAML-Dateien** - -Ausführen: `python3 -c "import yaml,sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]; print('YAML ok')" .github/workflows/pr-compile-check.yml .github/workflows/nightly-artifacts.yml` -Erwartet: `YAML ok`. - -- [ ] **Schritt 6b: Linux-Jobs lokal mit `act` prüfen** - -`act` (0.2.89) und Docker sind vorhanden. Fünf der sieben bauenden Jobs laufen auf `ubuntu-latest` und damit lokal: - -```bash -act -j build-linux-appimage -W .github/workflows/nightly-artifacts.yml -act -j build-linux-deb -W .github/workflows/nightly-artifacts.yml -act -j build-linux-rpm -W .github/workflows/nightly-artifacts.yml -act -j build-linux-arch -W .github/workflows/nightly-artifacts.yml -act --privileged -j build-flatpak -W .github/workflows/nightly-artifacts.yml -``` - -Erwartet: Jeder Lauf endet mit `Job succeeded`. - -`build-flatpak` braucht `--privileged`, weil `flatpak-builder` Namensräume anlegt. Scheitert es trotzdem an fehlenden Rechten im Container, ist das ein Umgebungsproblem von `act` und kein Fehler der Umstellung: im Ledger als `Ruling:` festhalten und diesen einen Job über die CI prüfen. - -`build-windows-zip` (`runs-on: windows-latest`) und `build-macos-dmg` (`runs-on: macos-latest`, `macos-15-intel`) kann `act` **nicht** ausführen — es startet ausschließlich Linux-Container. Beide bleiben Schritt 7. - -> **Ohne Push verifiziert am 2026-09-25.** Linux über `act` (Schritt 6b), macOS auf einem Gerät mit macOS 27.0 / arm64, Windows 11 Pro / AMD64 in WinBoat. Auf allen drei Plattformen: Gradle-Build grün, `jpackage` erzeugt ein startfähiges Artefakt mit allen acht Laufzeitmodulen, und die Anwendung initialisiert JavaFX (RSS 182 MB Linux, 183 MB macOS, 193 MB Windows). -> -> **Was ein echter CI-Lauf trotzdem noch zeigen muss:** die Artefakt-Uploads, die MSI-Strecke mit WiX, die Flatpak-Signierung und das signierte DMG. Alle vier scheitern lokal an fehlenden Secrets oder Diensten, nicht am Build. - -- [ ] **Schritt 7: Commit und CI-Lauf abwarten** - -```bash -git add .github/workflows/pr-compile-check.yml .github/workflows/nightly-artifacts.yml -git commit -m "Build the PR check and nightly artifacts with Gradle" -git push -``` - -Danach den Nightly-Workflow in GitHub Actions auslösen und **das Ergebnis abwarten**. -Erwartet: Windows-ZIP, Linux-AppImage und Debian-Paket entstehen. - -Windows- und macOS-Artefakte lassen sich lokal nicht prüfen. Scheitert ein Job, ist die CI-Ausgabe die einzige Fehlerquelle; nicht raten, sondern das Protokoll lesen. - -- [ ] **Schritt 8: Erzeugtes Linux-Artefakt starten** - -Das AppImage aus dem Workflow-Lauf herunterladen und ausführen. -Erwartet: Das Hauptfenster erscheint, das Kartenfenster zeigt Kacheln. - ---- - -## Aufgabe 10: Release-Strecke und AUR - -**Dateien:** -- Ändern: `.github/workflows/tagged-release.yml` -- Ändern: `packaging/aur/kst4contest/PKGBUILD` -- Prüfen: `.github/workflows/aur-publish.yml`, `.github/workflows/aur-nightly-git.yml`, `.github/workflows/docs-pdf.yml`, `.github/workflows/github-wiki.yml`, `.github/workflows/test-publish-version-xml.yml` - -- [ ] **Schritt 1: `tagged-release.yml` umstellen** - -Dieselben Ersetzungen wie in Aufgabe 9, Schritte 3 bis 5, an allen betroffenen Stellen. Die Zeilenanker zum Zeitpunkt der Planerstellung: `:40`, `:92-98`, `:172-178`, `:226-232`, `:280-286`, `:387-400`, `:523-524`. Vor dem Bearbeiten mit `grep -n "mvnw\|target/dist-libs" .github/workflows/tagged-release.yml` neu ermitteln, da sich die Zeilen durch die vorherigen Aufgaben verschoben haben können. - -- [ ] **Schritt 2: Prüfen** - -Ausführen: `grep -n "mvnw\|target/dist-libs" .github/workflows/tagged-release.yml` -Erwartet: keine Ausgabe. - -- [ ] **Schritt 3: `PKGBUILD` umstellen** - -In `packaging/aur/kst4contest/PKGBUILD` die `build()`-Funktion anpassen: der Maven-Aufruf und das `cp` auf `target/dist-libs/app.jar` (`:26`) werden durch `./gradlew --offline -S :app-desktop:collectRuntime` ersetzt, und der `jpackage`-Aufruf (`:44`) bekommt `--input app-desktop/build/dist-libs`. - -Die `depends`- und `makedepends`-Einträge müssen `gradle` statt `maven` nennen, falls dort `maven` steht. - -Ausführen: `grep -n "maven\|mvn\|target/" packaging/aur/kst4contest/PKGBUILD` -Erwartet: keine Ausgabe. - -- [ ] **Schritt 4: Flatpak-Job prüfen** - -Der Job `build-flatpak` in `nightly-artifacts.yml` (`:420`) und sein Gegenstück in `tagged-release.yml` bauen inline, es gibt kein `packaging/flatpak`-Verzeichnis. Dort gelten dieselben Ersetzungen wie in Aufgabe 9, Schritte 3 bis 5. - -Ausführen: `grep -n "mvnw\|target/dist-libs" .github/workflows/nightly-artifacts.yml` -Erwartet: keine Ausgabe. - -- [ ] **Schritt 5: Die übrigen Workflows prüfen** - -Ausführen: `grep -ln "mvnw\|mvn " .github/workflows/*.yml` -Erwartet: keine Ausgabe. - -Treffer werden nach demselben Muster umgestellt. `docs-pdf.yml` und `github-wiki.yml` bauen vermutlich kein Java und bleiben dann unverändert. - -- [ ] **Schritt 5b: Versionsauflösung in den übrigen Dateien umstellen** - -Dieselbe Ersetzung wie Aufgabe 9, Schritt 5b, in `tagged-release.yml`, `aur-publish.yml`, `aur-nightly-git.yml` und `docs-pdf.yml`. - -In `packaging/aur/kst4contest/PKGBUILD` und `packaging/aur/kst4contest-git/PKGBUILD` jede Stelle, die `pom.xml` nach der Version durchsucht, auf `gradle.properties` umstellen. - -Ausführen: `grep -rn "pom.xml" .github/ packaging/` -Erwartet: keine Ausgabe. - -- [ ] **Schritt 6: YAML prüfen** - -Ausführen: `python3 -c "import yaml,glob; [yaml.safe_load(open(f)) for f in glob.glob('.github/workflows/*.yml')]; print('YAML ok')"` -Erwartet: `YAML ok`. - -- [ ] **Schritt 7: Commit** - -```bash -git add .github/workflows packaging/aur -git commit -m "Build the release and AUR packages with Gradle" -``` - ---- - -## Aufgabe 11: macOS-DMG - -**Dateien:** -- Ändern: `packaging/macos/build-signed-dmg.sh` - -Nur auf macOS oder über die CI prüfbar. - -- [ ] **Schritt 1: Eingabepfade im Skript ermitteln** - -Ausführen: `grep -n "dist-libs\|target/\|jpackage\|--input" packaging/macos/build-signed-dmg.sh` - -- [ ] **Schritt 2: Umstellen** - -Jeden Verweis auf `target/dist-libs` auf `app-desktop/build/dist-libs` ändern und den Maven-Aufruf, falls vorhanden, durch `./gradlew -S :app-desktop:collectRuntime` ersetzen. Die `--add-modules`-Liste wie in Aufgabe 8, Schritt 2. - -Signierung, Entitlements (`packaging/macos/kst4contest.entitlements`), das Drag-Layout und `packaging/macos/dmg/render-background.swift` bleiben **unverändert**. Diese Aufgabe ändert ausschließlich, woher die Eingabedateien kommen. - -- [ ] **Schritt 3: Prüfen** - -Ausführen: `grep -n "target/" packaging/macos/build-signed-dmg.sh` -Erwartet: keine Ausgabe. - -> **Teilweise verifiziert am 2026-09-25** auf einem Mac mit macOS 27.0 / arm64 (Temurin 21.0.12.1), ohne Push: `./gradlew :app-desktop:collectRuntime` und `:app-desktop:packageImage` laufen durch. Es entsteht `praktiKST.app` mit allen acht Laufzeitmodulen, eingebettetem `praktiKST.icns`, `app.mainclass=kst4contest.view.Main` und den sechs `mac-aarch64`-JavaFX-JARs. Die App startet, läuft stabil (182 MB RSS) und initialisiert den JavaFX-Toolkit — `sample` zeigt JavaFX Application Thread, QuantumRenderer und Prism. -> -> **Offen bleibt die Signierung und das DMG selbst.** `build-signed-dmg.sh` bricht ohne `SIGNING_IDENTITY` ab; ein Zertifikat ist auf dem Rechner vorhanden ("Developer ID Application: Philipp Wagner (RNGLLTC995)"), wurde aber bewusst nicht benutzt. Die von dieser Etappe geänderten Zeilen des Skripts sind dieselben, die `packageImage` oben verifiziert hat — Signierung, Entitlements und Drag-Layout sind unverändert. - -- [ ] **Schritt 4: Commit und CI-Lauf** - -```bash -git add packaging/macos/build-signed-dmg.sh -git commit -m "Point the macOS DMG build at the Gradle output" -git push -``` - -Den macOS-Job in GitHub Actions auslösen und abwarten. -Erwartet: Ein DMG entsteht, zeigt das Drag-to-Install-Layout und die darin enthaltene Anwendung startet. - ---- - -## Aufgabe 12: Maven entfernen - -Erst ausführen, wenn Aufgabe 9, 10 und 11 auf allen drei Plattformen grün sind. - -**Dateien:** -- Löschen: `pom.xml`, `mvnw`, `mvnw.cmd`, `.mvn/` -- Ändern: `README.md`, `AGENTS.md`, `docs/PROJECT_CONTEXT.md` - -- [ ] **Schritt 1: Grüne CI bestätigen** - -Ausführen: `gh run list --limit 5` -Erwartet: Die letzten Läufe von `nightly-artifacts` und `tagged-release` sind erfolgreich. - -Ist ein Lauf rot, diese Aufgabe **nicht** beginnen. Erst die Ursache beheben. - -- [ ] **Schritt 2: Entfernen** - -```bash -git rm pom.xml mvnw mvnw.cmd -git rm -r .mvn -``` - -- [ ] **Schritt 3: Dokumentation nachziehen** - -In `README.md` den Abschnitt „Building from source" anpassen: statt des Maven-Wrappers wird `./gradlew build` genannt. JDK 21 bleibt die Voraussetzung. - -In `AGENTS.md` den Eintrag `pom.xml` in der Liste der Repository-Bereiche durch `build.gradle.kts`, `settings.gradle.kts` und `gradle/` ersetzen. - -In `docs/PROJECT_CONTEXT.md` unter „Current Architecture" den Satz „Java 21 / JavaFX desktop application built with Maven." ersetzen durch „Java 21 / JavaFX desktop application built with Gradle in the modules `core` and `app-desktop`." - -- [ ] **Schritt 4: Prüfen, dass kein Verweis bleibt** - -Ausführen: `grep -rn "mvnw\|pom\.xml\|Maven" README.md AGENTS.md docs/PROJECT_CONTEXT.md .github/ packaging/` -Erwartet: keine Ausgabe. Treffer in historischen Abschnitten von `docs/` dürfen stehen bleiben, wenn sie die Vergangenheit beschreiben; das im Ledger als `Ruling:` festhalten. - -- [ ] **Schritt 4b: Versionsauflösung ist von `pom.xml` gelöst** - -Ausführen: `grep -rn "pom.xml" .github/ packaging/ README.md AGENTS.md` -Erwartet: keine Ausgabe. - -Bleibt hier ein Treffer, liefert die betroffene Strecke nach dem Löschen von `pom.xml` eine **leere** Version und benennt ihre Artefakte falsch, ohne zu scheitern. Diese Prüfung ist deshalb nicht optional. - -- [ ] **Schritt 5: Vollständiger Bau- und Testlauf** - -Ausführen: `./gradlew clean build` -Erwartet: BUILD SUCCESSFUL, 208 Testfälle grün, 0 Fehler. - -- [ ] **Schritt 6: Image bauen und starten** - -Ausführen: `./gradlew :app-desktop:packageImage && ./app-desktop/build/jpackage/praktiKST/bin/praktiKST` -Erwartet: Das Hauptfenster erscheint. Stationsliste, Chat-Reiter, Einstellungsfenster und Kartenfenster funktionieren. - -- [ ] **Schritt 7: Commit** - -```bash -git add -A -git commit -m "Remove the Maven build in favour of Gradle" -``` - ---- - -## Abnahme der Etappe - -Etappe 1 ist abgeschlossen, wenn alles davon zutrifft: - -- [ ] `./gradlew clean build` läuft durch, 208 Testfälle aus 43 Testdateien grün. -- [ ] `./gradlew :app-desktop:packageImage` erzeugt ein Image, das startet. -- [ ] Im gestarteten Image funktionieren Stationsliste, die drei Chat-Reiter, das Einstellungsfenster mit allen zwölf Reitern, das Kartenfenster und der Profilwechsel. -- [ ] Windows-ZIP, Linux-AppImage, Debian-Paket, RPM, Arch-Paket, Flatpak und macOS-DMG entstehen in der CI und starten. -- [ ] `pom.xml`, `mvnw`, `mvnw.cmd`, `.mvn/`, `module-info.java` und `packaging/AddModules.java` existieren nicht mehr. -- [ ] `grep -rn "pom.xml" .github/ packaging/` liefert keine Ausgabe; die Version kommt überall aus `gradle.properties`. -- [ ] Keine Quelldatei unter `kst4contest/` wurde inhaltlich geändert — nur verschoben. Prüfbar mit `git log --stat` über die Commits dieser Etappe. diff --git a/docs/superpowers/plans/2026-09-25-etappe2-core-ohne-javafx.md b/docs/superpowers/plans/2026-09-25-etappe2-core-ohne-javafx.md deleted file mode 100644 index 4a85e49f..00000000 --- a/docs/superpowers/plans/2026-09-25-etappe2-core-ohne-javafx.md +++ /dev/null @@ -1,2028 +0,0 @@ -# Etappe 2 — `core` von JavaFX befreien: Umsetzungsplan - -> **Für agentische Bearbeiter:** ERFORDERLICHE UNTER-SKILL: superpowers:subagent-driven-development (empfohlen) oder superpowers:executing-plans, um diesen Plan Aufgabe für Aufgabe umzusetzen. Schritte nutzen Checkbox-Syntax (`- [ ]`). - -**Ziel:** `core` compiliert und testet ohne JavaFX auf dem Klassenpfad. Die JavaFX-Oberfläche läuft unverändert weiter und beweist, dass die neue Schnittstelle trägt. - -**Architektur:** Ein neues Paket `kst4contest.observe` in `core` ersetzt die JavaFX-Beobachtungstypen durch drei bewusst minimale Abstraktionen: `ObservableValue`/`MutableValue` für Einzelwerte, `ObservableRoster` für Sammlungen (snapshotbasiert, ohne inkrementelles Änderungsprotokoll) und `UiDispatcher` als Ersatz für `Platform.runLater`. `app-desktop` stellt die JavaFX-Implementierung von `UiDispatcher` bereit und spiegelt jedes `ObservableRoster` über eine einzige Adapterklasse in eine `ObservableList`, an der die `TableView` hängen bleiben. - -**Tech-Stack:** Java 21, Gradle 9.7.1, JUnit 5, Mockito. Keine neuen Abhängigkeiten. - -**Spec:** `docs/superpowers/specs/2026-09-25-compose-migration-design.md` - -## Drei Präzisierungen gegenüber dem Spec - -1. **Audio gehört nicht mehr hierher.** Das Spec nennt für Etappe 2 die Verlagerung von `javafx.scene.media` nach `core/audio`. Nach der Modulaufteilung aus Etappe 1 liegt `javafx.scene.media` ausschließlich in `app-desktop/…/Kst4ContestApplication.java` (`:33`, `:34`, `:6599`, `:6611`); `core/…/utils/PlayAudioUtils.java` benutzt nur `jlayer`. Für das Ziel „`core` ohne JavaFX" ist hier nichts zu tun. Der Ersatz des `MediaPlayer` wandert zu Etappe 6, die JavaFX ganz entfernt. - -2. **Zwei JavaFX-Klassen in `core` sind toter Code.** `utils/BoundedDequeObservableList.java` (168 Zeilen, `extends ObservableListBase`) und `utils/TestAudioPlayerUtils.java` (19 Zeilen, `extends Application`) werden von keiner anderen Datei referenziert. Sie werden gelöscht, nicht portiert. Die JavaFX-Importe in `controller/StartChat.java` (`:6`, `:7`) hängen nur an einer auskommentierten Zeile (`:26`). - -3. **Die Kopplung ist kleiner als die Importliste.** 15 Dateien in `core` importieren JavaFX, aber elf davon nur für einen einzelnen `Platform.runLater`, eine `SimpleStringProperty` als Wertbehälter oder eine `ObservableList`, die wie eine gewöhnliche Liste benutzt wird. Der eigentliche Aufwand steckt in `ChatController`, `ScoreService` und `ChatPreferences`. - -## Globale Randbedingungen - -- Java 21, Gradle 9.7.1. Keine neuen Abhängigkeiten in `core`. -- **Kein Verhalten ändert sich.** Diese Etappe ist eine Umstellung von Typen, keine fachliche Änderung. -- Die Invarianten aus dem Spec-Abschnitt „Invarianten" gelten unverändert: Chat-Identität samt Kategorie, `NOT-QRV`-Vorrang, `null` bedeutet unbekannt, Protokoll-Framing, `preferences.xml` Version 7, Operator-Profile, SQLite-Schema. -- **Die Bündelung bleibt.** `flushPendingChatMessagesToUi`, das Coalescing der Mitgliederliste und jede vorhandene Entprellung werden übernommen, nicht vereinfacht. Sie sind Leistungsschutz bei mehreren tausend ON4KST-Nutzern. -- Die Regel aus `AGENTS.md` gilt wortgleich weiter, nur heißt die Grenze jetzt `UiDispatcher`: Worker-Threads mutieren keine UI-gebundenen Sammlungen; UI-sichtbare Änderungen laufen über den Dispatcher. -- Kommentare und Javadoc auf Englisch; Commit-Nachrichten knapp auf Englisch. -- `core/build.gradle.kts` verliert seinen `javafx`-Block erst in der letzten Aufgabe. Bis dahin compiliert `core` weiter mit JavaFX und die Umstellung bleibt schrittweise prüfbar. - -## Prüfschwerpunkte - -Fünf Fehlerklassen, die kein Übersetzungslauf zeigt. Jede bekommt ihren Test in der zuständigen Aufgabe. - -1. **Die Stationsliste steht anders da als vorher.** `SortedList` fügte einzelne Elemente per Binärsuche ein und landete bei Gleichstand an undefinierter Position, ohne bereits sichtbare Zeilen zu bewegen; über viele Zu- und Abgänge driftete der Gleichstandsblock so von der Ankunftsreihenfolge weg. Eine Sortierung, die den Block bei jeder Änderung vollständig neu ableitet, liefert dort zwangsläufig eine andere — nämlich deterministische — Reihenfolge. Zu prüfen ist deshalb nicht Gleichheit mit vorher, sondern dass die neue Reihenfolge bei Gleichstand der Ankunftsreihenfolge folgt und bei einem Spaltenwechsel nicht die vorherige Spalte nachwirkt. → Aufgabe 10. -2. **Listener feuern auf dem falschen Thread.** Ein `ObservableRoster`, das seine Listener synchron im mutierenden Thread aufruft, bringt Worker-Threads direkt an die UI — genau das, was `AGENTS.md` verbietet. → Aufgaben 3 und 4. -3. **Die Bündelung geht verloren.** Wird pro Änderung ein Snapshot erzeugt und ein Listener gerufen, entsteht bei mehreren tausend Nutzern eine Flut. Das fällt erst unter Last auf, nicht im Test. → Aufgabe 11. -4. **`frequency` wird zu `""` statt `null`.** `StringProperty` liefert ohne gesetzten Wert `null` über `get()`. Ein `MutableValue` mit Vorgabewert `""` würde aus „unbekannte Frequenz" eine leere, aber vorhandene machen — und verstößt gegen „`null` bedeutet nicht verfügbar". → Aufgabe 8. -5. **Listener überleben den Profilwechsel.** Der Laufzeit-Neuaufbau baut eine neue `Kst4ContestApplication`; Adapter, die sich nicht abmelden, halten die alte Instanz am Leben. Vor Etappe 1 waren genau solche Lecks vorhanden. → Aufgabe 11. - ---- - -## Aufgabe 1: Toten JavaFX-Code entfernen - -**Dateien:** -- Löschen: `core/src/main/java/kst4contest/utils/BoundedDequeObservableList.java` -- Löschen: `core/src/main/java/kst4contest/utils/TestAudioPlayerUtils.java` -- Ändern: `core/src/main/java/kst4contest/controller/StartChat.java` - -**Schnittstellen:** -- Erzeugt: nichts. Reine Entfernung. - -- [ ] **Schritt 1: Nachweisen, dass die Klassen ungenutzt sind** - -Ausführen: -```bash -grep -rn "BoundedDequeObservableList\|TestAudioPlayerUtils" core/src app-desktop/src --include="*.java" | grep -v "utils/BoundedDequeObservableList.java\|utils/TestAudioPlayerUtils.java" -``` -Erwartet: keine Ausgabe. - -Gibt es Treffer, werden die Klassen doch benutzt. Dann diese Aufgabe überspringen, im Ledger als `Ruling:` festhalten und mit Aufgabe 2 weitermachen. - -- [ ] **Schritt 2: Löschen** - -```bash -git rm core/src/main/java/kst4contest/utils/BoundedDequeObservableList.java -git rm core/src/main/java/kst4contest/utils/TestAudioPlayerUtils.java -``` - -- [ ] **Schritt 3: Tote Importe in `StartChat` prüfen** - -Ausführen: `grep -n "SimpleStringProperty\|ObservableStringValue" core/src/main/java/kst4contest/controller/StartChat.java` -Erwartet: drei Zeilen — zwei Importe und eine auskommentierte Verwendung. - -Wird einer der Typen in nicht auskommentiertem Code benutzt, bleiben die Importe stehen und die Datei wird in Aufgabe 7 behandelt. Als `Ruling:` festhalten. - -- [ ] **Schritt 4: Importe entfernen** - -In `core/src/main/java/kst4contest/controller/StartChat.java` die beiden Zeilen - -```java -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.value.ObservableStringValue; -``` - -löschen. Die auskommentierte Zeile bleibt unverändert stehen — sie ist Dokumentation der früheren Umsetzung. - -- [ ] **Schritt 5: Übersetzen und testen** - -Ausführen: `./gradlew build` -Erwartet: BUILD SUCCESSFUL, 208 Testfälle grün. - -- [ ] **Schritt 6: Zählstand prüfen** - -Ausführen: `grep -rl "javafx" core/src/main/java | wc -l` -Erwartet: `12` — von 15 bleiben zwölf Dateien. - -- [ ] **Schritt 7: Commit** - -```bash -git add -A -git commit -m "Remove the unused JavaFX classes from core" -``` - ---- - -## Aufgabe 2: `ObservableValue` und `MutableValue` - -**Dateien:** -- Anlegen: `core/src/main/java/kst4contest/observe/ObservableValue.java` -- Anlegen: `core/src/main/java/kst4contest/observe/MutableValue.java` -- Anlegen: `core/src/main/java/kst4contest/observe/SimpleValue.java` -- Test: `core/src/test/java/kst4contest/observe/SimpleValueTest.java` - -**Schnittstellen:** -- Erzeugt: `ObservableValue` mit `T get()`, `void addListener(Consumer)`, `void removeListener(Consumer)`; `MutableValue extends ObservableValue` mit `void set(T)`; `SimpleValue implements MutableValue` mit Konstruktoren `SimpleValue()` und `SimpleValue(T initial)`. Konsumiert von den Aufgaben 7, 8, 9 und 12. - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -`core/src/test/java/kst4contest/observe/SimpleValueTest.java`: - -```java -package kst4contest.observe; - -import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.Consumer; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class SimpleValueTest { - - @Test - void anUnsetValueIsNullRatherThanEmpty() { - SimpleValue value = new SimpleValue<>(); - assertNull(value.get(), "an unset value must stay unavailable, not become an empty string"); - } - - @Test - void getReturnsTheInitialValue() { - assertEquals("144.300", new SimpleValue<>("144.300").get()); - } - - @Test - void setNotifiesListenersWithTheNewValue() { - SimpleValue value = new SimpleValue<>(); - List seen = new ArrayList<>(); - value.addListener(seen::add); - - value.set("432.200"); - - assertEquals(List.of("432.200"), seen); - } - - @Test - void setToAnEqualValueDoesNotNotify() { - SimpleValue value = new SimpleValue<>("144.300"); - List seen = new ArrayList<>(); - value.addListener(seen::add); - - value.set("144.300"); - - assertTrue(seen.isEmpty(), "an unchanged value must not wake listeners"); - } - - @Test - void settingBackToNullNotifiesAndReportsUnavailable() { - SimpleValue value = new SimpleValue<>("144.300"); - List seen = new ArrayList<>(); - value.addListener(seen::add); - - value.set(null); - - assertEquals(1, seen.size()); - assertNull(seen.get(0)); - assertNull(value.get()); - } - - @Test - void removedListenersStopBeingCalled() { - SimpleValue value = new SimpleValue<>(); - List seen = new ArrayList<>(); - Consumer listener = seen::add; - value.addListener(listener); - value.removeListener(listener); - - value.set("50.200"); - - assertTrue(seen.isEmpty()); - } - - @Test - void aListenerThrowingDoesNotStopTheOthers() { - SimpleValue value = new SimpleValue<>(); - List seen = new ArrayList<>(); - value.addListener(v -> { throw new IllegalStateException("boom"); }); - value.addListener(seen::add); - - value.set("1296.200"); - - assertEquals(List.of("1296.200"), seen, - "one failing listener must not keep the others from being notified"); - } -} -``` - -- [ ] **Schritt 2: Test laufen lassen und Fehlschlag sehen** - -Ausführen: `./gradlew :core:test --tests 'kst4contest.observe.SimpleValueTest'` -Erwartet: FAIL — Übersetzungsfehler, `SimpleValue` existiert nicht. - -- [ ] **Schritt 3: Die Schnittstellen schreiben** - -`core/src/main/java/kst4contest/observe/ObservableValue.java`: - -```java -package kst4contest.observe; - -import java.util.function.Consumer; - -/** - * A value that can be observed for changes. - * - *

Deliberately minimal: this replaces the JavaFX property types in the - * domain layer without pulling in a reactive framework. {@code null} means the - * value is unavailable, never a default. - */ -public interface ObservableValue { - - /** The current value, or {@code null} when it is unavailable. */ - T get(); - - /** Registers a listener that is called with the new value after each change. */ - void addListener(Consumer listener); - - /** Removes a previously registered listener. Unknown listeners are ignored. */ - void removeListener(Consumer listener); -} -``` - -`core/src/main/java/kst4contest/observe/MutableValue.java`: - -```java -package kst4contest.observe; - -/** An {@link ObservableValue} that can be written to. */ -public interface MutableValue extends ObservableValue { - - /** - * Replaces the value and notifies the listeners. Setting a value that - * equals the current one notifies nobody. - */ - void set(T value); -} -``` - -- [ ] **Schritt 4: Die Umsetzung schreiben** - -`core/src/main/java/kst4contest/observe/SimpleValue.java`: - -```java -package kst4contest.observe; - -import java.util.List; -import java.util.Objects; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.function.Consumer; - -/** - * The default {@link MutableValue}. Safe to read and write from any thread; - * listeners run on the thread that calls {@link #set}, so anything touching the - * user interface must hand over through {@link UiDispatcher}. - */ -public class SimpleValue implements MutableValue { - - private final List> listeners = new CopyOnWriteArrayList<>(); - private volatile T value; - - public SimpleValue() { - this(null); - } - - public SimpleValue(T initial) { - this.value = initial; - } - - @Override - public T get() { - return value; - } - - @Override - public void set(T newValue) { - if (Objects.equals(value, newValue)) { - return; - } - value = newValue; - for (Consumer listener : listeners) { - try { - listener.accept(newValue); - } catch (RuntimeException e) { - // A broken listener must not keep the remaining ones from running, - // and must not terminate the thread that produced the value. - System.err.println("[observe] value listener failed: " + e); - } - } - } - - @Override - public void addListener(Consumer listener) { - listeners.add(Objects.requireNonNull(listener, "listener")); - } - - @Override - public void removeListener(Consumer listener) { - listeners.remove(listener); - } -} -``` - -- [ ] **Schritt 5: Test laufen lassen** - -Ausführen: `./gradlew :core:test --tests 'kst4contest.observe.SimpleValueTest'` -Erwartet: PASS, 7 Testfälle. - -- [ ] **Schritt 6: Commit** - -```bash -git add core/src/main/java/kst4contest/observe core/src/test/java/kst4contest/observe -git commit -m "Add the observable value abstraction to core" -``` - ---- - -## Aufgabe 3: `ObservableRoster` - -Deckt Prüfschwerpunkt 2 ab. - -**Dateien:** -- Anlegen: `core/src/main/java/kst4contest/observe/ObservableRoster.java` -- Anlegen: `core/src/main/java/kst4contest/observe/SimpleRoster.java` -- Test: `core/src/test/java/kst4contest/observe/SimpleRosterTest.java` - -**Schnittstellen:** -- Konsumiert: nichts aus Aufgabe 2. -- Erzeugt: `ObservableRoster` mit `List snapshot()`, `int size()`, `void addListener(Consumer>)`, `void removeListener(Consumer>)`; `SimpleRoster implements ObservableRoster` mit zusätzlich `void add(T)`, `void addAll(Collection)`, `void remove(T)`, `void setAll(Collection)`, `void clear()` und `void mutate(Consumer>)`. Konsumiert von den Aufgaben 6, 9, 11 und 12. - -`mutate` ist der Sammelweg: es übergibt die interne Liste zur Bearbeitung und feuert danach **einen** Listener-Durchlauf. Damit bleibt die Bündelung aus `ChatController` erhalten, statt pro Element einen Snapshot zu erzeugen. - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -`core/src/test/java/kst4contest/observe/SimpleRosterTest.java`: - -```java -package kst4contest.observe; - -import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class SimpleRosterTest { - - @Test - void snapshotReflectsTheCurrentContent() { - SimpleRoster roster = new SimpleRoster<>(); - roster.add("DN9APW"); - roster.add("DO5AMF"); - - assertEquals(List.of("DN9APW", "DO5AMF"), roster.snapshot()); - assertEquals(2, roster.size()); - } - - @Test - void theSnapshotIsImmutable() { - SimpleRoster roster = new SimpleRoster<>(); - roster.add("DN9APW"); - - List snapshot = roster.snapshot(); - assertThrows(UnsupportedOperationException.class, () -> snapshot.add("DL1ABC")); - } - - @Test - void theSnapshotDoesNotChangeWhenTheRosterDoes() { - SimpleRoster roster = new SimpleRoster<>(); - roster.add("DN9APW"); - List taken = roster.snapshot(); - - roster.add("DO5AMF"); - - assertEquals(List.of("DN9APW"), taken, "a handed-out snapshot must stay as it was"); - } - - @Test - void eachMutationNotifiesOnceWithTheNewContent() { - SimpleRoster roster = new SimpleRoster<>(); - List> seen = new ArrayList<>(); - roster.addListener(seen::add); - - roster.add("DN9APW"); - roster.add("DO5AMF"); - - assertEquals(2, seen.size()); - assertEquals(List.of("DN9APW"), seen.get(0)); - assertEquals(List.of("DN9APW", "DO5AMF"), seen.get(1)); - } - - @Test - void mutateNotifiesOnceForTheWholeBatch() { - SimpleRoster roster = new SimpleRoster<>(); - AtomicInteger calls = new AtomicInteger(); - roster.addListener(list -> calls.incrementAndGet()); - - roster.mutate(list -> { - for (int i = 0; i < 500; i++) { - list.add("CALL" + i); - } - }); - - assertEquals(1, calls.get(), "a batch must wake listeners once, not per element"); - assertEquals(500, roster.size()); - } - - @Test - void setAllReplacesTheContentAndNotifiesOnce() { - SimpleRoster roster = new SimpleRoster<>(); - roster.add("OLD"); - AtomicInteger calls = new AtomicInteger(); - roster.addListener(list -> calls.incrementAndGet()); - - roster.setAll(List.of("DN9APW", "DO5AMF")); - - assertEquals(1, calls.get()); - assertEquals(List.of("DN9APW", "DO5AMF"), roster.snapshot()); - } - - @Test - void aListenerThrowingDoesNotStopTheOthers() { - SimpleRoster roster = new SimpleRoster<>(); - List> seen = new ArrayList<>(); - roster.addListener(list -> { throw new IllegalStateException("boom"); }); - roster.addListener(seen::add); - - roster.add("DN9APW"); - - assertEquals(1, seen.size()); - } - - @Test - void concurrentWritersDoNotLoseEntries() throws Exception { - SimpleRoster roster = new SimpleRoster<>(); - int threads = 8; - int perThread = 250; - CountDownLatch start = new CountDownLatch(1); - CountDownLatch done = new CountDownLatch(threads); - - for (int t = 0; t < threads; t++) { - final int base = t * perThread; - new Thread(() -> { - try { - start.await(); - for (int i = 0; i < perThread; i++) { - roster.add(base + i); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - done.countDown(); - } - }).start(); - } - start.countDown(); - assertTrue(done.await(20, TimeUnit.SECONDS), "writer threads did not finish"); - - assertEquals(threads * perThread, roster.size()); - } -} -``` - -- [ ] **Schritt 2: Test laufen lassen und Fehlschlag sehen** - -Ausführen: `./gradlew :core:test --tests 'kst4contest.observe.SimpleRosterTest'` -Erwartet: FAIL — `SimpleRoster` existiert nicht. - -- [ ] **Schritt 3: Die Schnittstelle schreiben** - -`core/src/main/java/kst4contest/observe/ObservableRoster.java`: - -```java -package kst4contest.observe; - -import java.util.List; -import java.util.function.Consumer; - -/** - * An observable collection that hands out immutable snapshots. - * - *

There is deliberately no incremental change protocol: consumers receive - * the whole new content and decide for themselves what to do with it. That - * matches how a declarative user interface redraws, and it keeps the domain - * layer testable without a running toolkit. - * - *

Listeners run on the thread that performed the mutation. Anything that - * touches the user interface must hand over through {@link UiDispatcher}. - */ -public interface ObservableRoster { - - /** An immutable view of the current content. */ - List snapshot(); - - int size(); - - /** Registers a listener that is called with the new content after each change. */ - void addListener(Consumer> listener); - - /** Removes a previously registered listener. Unknown listeners are ignored. */ - void removeListener(Consumer> listener); -} -``` - -- [ ] **Schritt 4: Die Umsetzung schreiben** - -`core/src/main/java/kst4contest/observe/SimpleRoster.java`: - -```java -package kst4contest.observe; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.function.Consumer; - -/** - * The default {@link ObservableRoster}. Writes are serialised on the roster - * itself; reads hand out an immutable copy taken under the same lock. - */ -public class SimpleRoster implements ObservableRoster { - - private final Object lock = new Object(); - private final List items = new ArrayList<>(); - private final List>> listeners = new CopyOnWriteArrayList<>(); - - @Override - public List snapshot() { - synchronized (lock) { - return List.copyOf(items); - } - } - - @Override - public int size() { - synchronized (lock) { - return items.size(); - } - } - - public void add(T item) { - mutate(list -> list.add(item)); - } - - public void addAll(Collection newItems) { - mutate(list -> list.addAll(newItems)); - } - - public void remove(T item) { - mutate(list -> list.remove(item)); - } - - public void setAll(Collection newItems) { - mutate(list -> { - list.clear(); - list.addAll(newItems); - }); - } - - public void clear() { - mutate(List::clear); - } - - /** - * Applies several changes and notifies listeners once. This is what keeps - * a batch of several thousand chat members from producing a snapshot and a - * listener round per element. - */ - public void mutate(Consumer> change) { - Objects.requireNonNull(change, "change"); - List snapshot; - synchronized (lock) { - change.accept(items); - snapshot = List.copyOf(items); - } - notifyListeners(snapshot); - } - - private void notifyListeners(List snapshot) { - for (Consumer> listener : listeners) { - try { - listener.accept(snapshot); - } catch (RuntimeException e) { - // A broken listener must not keep the remaining ones from running, - // and must not terminate the message-processing thread. - System.err.println("[observe] roster listener failed: " + e); - } - } - } - - @Override - public void addListener(Consumer> listener) { - listeners.add(Objects.requireNonNull(listener, "listener")); - } - - @Override - public void removeListener(Consumer> listener) { - listeners.remove(listener); - } -} -``` - -- [ ] **Schritt 5: Test laufen lassen** - -Ausführen: `./gradlew :core:test --tests 'kst4contest.observe.SimpleRosterTest'` -Erwartet: PASS, 8 Testfälle. - -- [ ] **Schritt 6: Commit** - -```bash -git add core/src/main/java/kst4contest/observe core/src/test/java/kst4contest/observe -git commit -m "Add the observable roster abstraction to core" -``` - ---- - -## Aufgabe 4: `UiDispatcher` - -Deckt Prüfschwerpunkt 2 ab. - -**Dateien:** -- Anlegen: `core/src/main/java/kst4contest/observe/UiDispatcher.java` -- Anlegen: `core/src/main/java/kst4contest/observe/DirectUiDispatcher.java` -- Anlegen: `app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java` -- Test: `core/src/test/java/kst4contest/observe/DirectUiDispatcherTest.java` - -**Schnittstellen:** -- Erzeugt: `UiDispatcher` mit `void runOnUi(Runnable)` und `boolean isUiThread()`; `DirectUiDispatcher` (führt sofort im aufrufenden Thread aus, für Tests); `JavaFxUiDispatcher` in `app-desktop` (delegiert an `Platform.runLater` und `Platform.isFxApplicationThread`). Konsumiert von den Aufgaben 5, 11 und 12. - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -`core/src/test/java/kst4contest/observe/DirectUiDispatcherTest.java`: - -```java -package kst4contest.observe; - -import org.junit.jupiter.api.Test; - -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class DirectUiDispatcherTest { - - @Test - void runsTheTaskImmediately() { - AtomicInteger runs = new AtomicInteger(); - new DirectUiDispatcher().runOnUi(runs::incrementAndGet); - assertEquals(1, runs.get()); - } - - @Test - void reportsEveryThreadAsTheUiThread() { - assertTrue(new DirectUiDispatcher().isUiThread()); - } - - @Test - void aFailingTaskDoesNotPropagateToTheCaller() { - AtomicBoolean reached = new AtomicBoolean(); - new DirectUiDispatcher().runOnUi(() -> { throw new IllegalStateException("boom"); }); - reached.set(true); - assertTrue(reached.get(), "a failing UI task must not terminate the calling thread"); - } -} -``` - -- [ ] **Schritt 2: Test laufen lassen und Fehlschlag sehen** - -Ausführen: `./gradlew :core:test --tests 'kst4contest.observe.DirectUiDispatcherTest'` -Erwartet: FAIL — `DirectUiDispatcher` existiert nicht. - -- [ ] **Schritt 3: Die Schnittstelle und die Testumsetzung schreiben** - -`core/src/main/java/kst4contest/observe/UiDispatcher.java`: - -```java -package kst4contest.observe; - -/** - * Hands work over to the thread that owns the user interface. - * - *

This is the boundary the project has always had, named explicitly: worker - * threads never mutate user-interface state themselves, they hand it over. - * Before this interface existed the boundary was {@code Platform.runLater}; - * the rule is unchanged, only the dependency is gone. - */ -public interface UiDispatcher { - - /** Runs the task on the user-interface thread, now or later. */ - void runOnUi(Runnable task); - - /** Whether the calling thread is the user-interface thread. */ - boolean isUiThread(); -} -``` - -`core/src/main/java/kst4contest/observe/DirectUiDispatcher.java`: - -```java -package kst4contest.observe; - -/** - * Runs every task on the calling thread. Intended for tests and for headless - * use; it is not a user-interface thread in any real sense. - */ -public class DirectUiDispatcher implements UiDispatcher { - - @Override - public void runOnUi(Runnable task) { - try { - task.run(); - } catch (RuntimeException e) { - System.err.println("[observe] UI task failed: " + e); - } - } - - @Override - public boolean isUiThread() { - return true; - } -} -``` - -- [ ] **Schritt 4: Test laufen lassen** - -Ausführen: `./gradlew :core:test --tests 'kst4contest.observe.DirectUiDispatcherTest'` -Erwartet: PASS, 3 Testfälle. - -- [ ] **Schritt 5: Die JavaFX-Umsetzung schreiben** - -`app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java`: - -```java -package kst4contest.view; - -import javafx.application.Platform; -import kst4contest.observe.UiDispatcher; - -/** The {@link UiDispatcher} backed by the JavaFX application thread. */ -public class JavaFxUiDispatcher implements UiDispatcher { - - @Override - public void runOnUi(Runnable task) { - if (Platform.isFxApplicationThread()) { - runGuarded(task); - } else { - Platform.runLater(() -> runGuarded(task)); - } - } - - @Override - public boolean isUiThread() { - return Platform.isFxApplicationThread(); - } - - private static void runGuarded(Runnable task) { - try { - task.run(); - } catch (RuntimeException e) { - // Must not tear down the JavaFX application thread. - System.err.println("[observe] UI task failed: " + e); - } - } -} -``` - -- [ ] **Schritt 6: Übersetzen und testen** - -Ausführen: `./gradlew build` -Erwartet: BUILD SUCCESSFUL, 208 + 18 = 226 Testfälle grün. - -- [ ] **Schritt 7: Commit** - -```bash -git add -A -git commit -m "Add the UI dispatcher boundary and its JavaFX implementation" -``` - ---- - -## Aufgabe 5: `Platform.runLater` durch `UiDispatcher` ersetzen - -**Dateien:** -- Ändern: `core/src/main/java/kst4contest/controller/ReachabilityService.java` (`:380`, `:383`, `:586`, `:589`) -- Ändern: `core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java` (`:587`, `:590`) -- Ändern: `core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java` (`:571`) -- Ändern: `core/src/main/java/kst4contest/controller/SkedReminderService.java` (`:3`) -- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -**Schnittstellen:** -- Konsumiert: `UiDispatcher`, `JavaFxUiDispatcher` aus Aufgabe 4. -- Erzeugt: die drei Klassen nehmen einen `UiDispatcher` im Konstruktor entgegen. - -`SkedReminderService` importiert `Platform`, benutzt es aber an keiner Stelle — dort wird nur der Import entfernt. - -- [ ] **Schritt 1: Die toten und echten Stellen bestätigen** - -Ausführen: -```bash -grep -c "Platform\." core/src/main/java/kst4contest/controller/SkedReminderService.java -grep -n "Platform\." core/src/main/java/kst4contest/controller/ReachabilityService.java core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java -``` -Erwartet: `0` für `SkedReminderService`; sieben Zeilen für die übrigen drei. - -- [ ] **Schritt 2: Toten Import entfernen** - -In `SkedReminderService.java` die Zeile `import javafx.application.Platform;` löschen. - -- [ ] **Schritt 3: Die drei Klassen auf `UiDispatcher` umstellen** - -In jeder der drei Dateien: - -1. `import javafx.application.Platform;` durch `import kst4contest.observe.UiDispatcher;` ersetzen. -2. Ein Feld `private final UiDispatcher uiDispatcher;` ergänzen und im Konstruktor setzen. Hat die Klasse mehrere Konstruktoren, bekommt jeder den Parameter; gibt es einen, der ohne auskommt, bekommt er `new DirectUiDispatcher()` als Vorgabe **nicht** — der Aufrufer muss entscheiden. -3. Jedes Muster - -```java -if (Platform.isFxApplicationThread()) { - task.run(); -} else { - Platform.runLater(task); -} -``` - -durch den einen Aufruf `uiDispatcher.runOnUi(task);` ersetzen. `JavaFxUiDispatcher.runOnUi` enthält genau diese Fallunterscheidung bereits. - -4. Ein alleinstehendes `Platform.runLater(x)` wird zu `uiDispatcher.runOnUi(x)`. - -- [ ] **Schritt 4: Die Erzeugungsstellen in der Oberfläche nachziehen** - -Ausführen: -```bash -grep -n "new ReachabilityService\|new ReadUDPbyUCXMessageThread\|new ReadUDPByWintestThread" core/src app-desktop/src -r -``` - -Jeder Fundstelle den Dispatcher mitgeben. In `Kst4ContestApplication` einmalig ein Feld anlegen: - -```java -private final UiDispatcher uiDispatcher = new JavaFxUiDispatcher(); -``` - -und es an die Konstruktoren durchreichen. - -- [ ] **Schritt 5: Übersetzen und testen** - -Ausführen: `./gradlew build` -Erwartet: BUILD SUCCESSFUL, 226 Testfälle grün. - -- [ ] **Schritt 6: Anwendung starten** - -Ausführen: `./gradlew :app-desktop:run` -Erwartet: Hauptfenster erscheint. Verbindungsanzeige, Sked-Erinnerung und die UDP-Empfänger verhalten sich unverändert. - -- [ ] **Schritt 7: Commit** - -```bash -git add -A -git commit -m "Route UI hand-offs in core through the UI dispatcher" -``` - ---- - -## Aufgabe 6: `ObservableList` als gewöhnliche Liste - -**Dateien:** -- Ändern: `core/src/main/java/kst4contest/model/AirPlaneReflectionInfo.java` (`:3`, `:10`, `:44`, `:48`) -- Ändern: `core/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java` (`:12`, `:13`, `:278`) - -`risingAirplanes` wird nirgends beobachtet — es ist eine Ergebnisliste. Sie wird zu `List`. - -- [ ] **Schritt 1: Nachweisen, dass niemand darauf hört** - -Ausführen: `grep -rn "getRisingAirplanes()\." core/src app-desktop/src` -Erwartet: nur Aufrufe wie `.size()`, `.get(…)`, `.isEmpty()`, `.stream()` — **kein** `.addListener(`. - -Findet sich ein `addListener`, wird das Feld stattdessen ein `SimpleRoster` aus Aufgabe 3. Als `Ruling:` festhalten. - -- [ ] **Schritt 2: Umstellen** - -In `AirPlaneReflectionInfo.java`: `import javafx.collections.ObservableList;` durch `import java.util.List;` ersetzen und die drei Vorkommen von `ObservableList` zu `List` ändern. - -In `ReadUDPbyAirScoutMessageThread.java`: beide JavaFX-Importe entfernen, `import java.util.ArrayList;` und `import java.util.List;` ergänzen (falls nicht vorhanden), und `:278` ändern von - -```java -ObservableList airplaneList = FXCollections.observableArrayList(); -``` - -zu - -```java -List airplaneList = new ArrayList<>(); -``` - -- [ ] **Schritt 3: Übersetzen und testen** - -Ausführen: `./gradlew build` -Erwartet: BUILD SUCCESSFUL, 226 Testfälle grün. - -- [ ] **Schritt 4: Commit** - -```bash -git add -A -git commit -m "Use a plain list for the AirScout reflection result" -``` - ---- - -## Aufgabe 7: `SimpleStringProperty` als Wertbehälter - -**Dateien:** -- Ändern: `core/src/main/java/kst4contest/controller/Utils4KST.java` (`:3`, `:123`) - -**Schnittstellen:** -- Konsumiert: `ObservableValue` aus Aufgabe 2. - -`MessageBusManagementThread` (`:15`, `:1435`, `:1475`) wird **nicht** hier behandelt: seine beiden `new SimpleStringProperty(...)` sind Argumente für `ChatMember.setFrequency` und fallen mit Aufgabe 8. - -- [ ] **Schritt 1: Die Verwendung ansehen** - -Ausführen: `sed -n '115,135p' core/src/main/java/kst4contest/controller/Utils4KST.java` - -Der Parameter `optionalPrefix` wird gelesen, nicht beobachtet. - -- [ ] **Schritt 2: Umstellen** - -`import javafx.beans.property.SimpleStringProperty;` durch `import kst4contest.observe.ObservableValue;` ersetzen und den Parametertyp von `SimpleStringProperty` auf `ObservableValue` ändern. Lesende Zugriffe `optionalPrefix.get()` und `optionalPrefix.getValue()` werden beide zu `optionalPrefix.get()`. - -- [ ] **Schritt 3: Aufrufer nachziehen** - -Ausführen: `grep -rn "optionalPrefix\|notify_optionalFrequencyPrefix" core/src app-desktop/src | grep -v "Utils4KST.java"` - -Jeder Aufrufer übergibt heute die `SimpleStringProperty` aus `ChatPreferences:296`. Diese wird in Aufgabe 9 zu einem `MutableValue`, das `ObservableValue` erfüllt. Bis dahin wird an der Aufrufstelle ein `new SimpleValue<>(prefs.getNotify_optionalFrequencyPrefix().get())` übergeben. - -- [ ] **Schritt 4: Übersetzen und testen** - -Ausführen: `./gradlew build` -Erwartet: BUILD SUCCESSFUL, 226 Testfälle grün. - -- [ ] **Schritt 5: Commit** - -```bash -git add -A -git commit -m "Take an observable value instead of a JavaFX property in Utils4KST" -``` - ---- - -## Aufgabe 8: `ChatMember.frequency` - -Deckt Prüfschwerpunkt 4 ab. - -**Dateien:** -- Ändern: `core/src/main/java/kst4contest/model/ChatMember.java` (`:9`-`:12`, `:42`, `:510`, `:516`, `:616`) -- Ändern: `core/src/main/java/kst4contest/controller/MessageBusManagementThread.java` (`:15`, `:1435`, `:1475`) -- Ändern: alle weiteren Aufrufstellen von `getFrequency()` / `setFrequency(…)` -- Test: `core/src/test/java/kst4contest/model/ChatMemberFrequencyTest.java` - -**Schnittstellen:** -- Konsumiert: `MutableValue`, `SimpleValue` aus Aufgabe 2. -- Erzeugt: `ChatMember.getFrequency()` liefert `MutableValue`. - -Es gibt 31 Aufrufstellen, davon sieben lesende (`.get()` zweimal, `.getValue()` fünfmal). `SimpleValue.get()` ersetzt beide. - -- [ ] **Schritt 1: Den Test schreiben, der das heutige Verhalten festschreibt** - -`core/src/test/java/kst4contest/model/ChatMemberFrequencyTest.java`: - -```java -package kst4contest.model; - -import kst4contest.observe.SimpleValue; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertEquals; - -class ChatMemberFrequencyTest { - - @Test - void aFreshMemberHasNoFrequency() { - ChatMember member = new ChatMember(); - assertNotNull(member.getFrequency(), "the holder itself must exist"); - assertNull(member.getFrequency().get(), - "an unknown frequency must stay unavailable, never become an empty string or a default band"); - } - - @Test - void theFrequencyCanBeSetAndReadBack() { - ChatMember member = new ChatMember(); - member.setFrequency(new SimpleValue<>("144.300")); - assertEquals("144.300", member.getFrequency().get()); - } - - @Test - void clearingTheFrequencyReturnsToUnavailable() { - ChatMember member = new ChatMember(); - member.setFrequency(new SimpleValue<>("144.300")); - member.getFrequency().set(null); - assertNull(member.getFrequency().get()); - } -} -``` - -- [ ] **Schritt 2: Test laufen lassen und Fehlschlag sehen** - -Ausführen: `./gradlew :core:test --tests 'kst4contest.model.ChatMemberFrequencyTest'` -Erwartet: FAIL — `SimpleValue` passt nicht auf `StringProperty`. - -- [ ] **Schritt 3: `ChatMember` umstellen** - -Die vier JavaFX-Importe (`:9`-`:12`) durch - -```java -import kst4contest.observe.MutableValue; -import kst4contest.observe.SimpleValue; -``` - -ersetzen. `:42` wird zu - -```java - MutableValue frequency = new SimpleValue<>(); -``` - -`getFrequency()` (`:510`) liefert `MutableValue`, `setFrequency` (`:516`) nimmt `MutableValue`, und die Neuanlage bei `:616` wird zu `frequency = new SimpleValue<>();`. - -> `SimpleValue` ohne Argument setzt `null`, genau wie `new SimpleStringProperty()`. Das ist der Punkt aus Prüfschwerpunkt 4: ein Vorgabewert `""` würde aus „unbekannt" ein „bekannt und leer" machen. - -Die Importe `SimpleBooleanProperty`/`BooleanProperty` (`:9`, `:10`) prüfen: werden sie noch benutzt? - -Ausführen: `grep -n "BooleanProperty" core/src/main/java/kst4contest/model/ChatMember.java` -Erwartet: nur die auskommentierte Zeile `:22`. Dann entfallen beide Importe ersatzlos. - -- [ ] **Schritt 4: Die Aufrufstellen nachziehen** - -Ausführen: `grep -rn "etFrequency(" core/src app-desktop/src --include="*.java" | grep -v "model/ChatMember.java"` - -In `MessageBusManagementThread` (`:1435`, `:1475`) wird `new SimpleStringProperty(x)` zu `new SimpleValue<>(x)` und der Import `:15` entsprechend getauscht. Jedes `.getValue()` auf dem Ergebnis von `getFrequency()` wird zu `.get()`. - -- [ ] **Schritt 5: Tests laufen lassen** - -Ausführen: `./gradlew build` -Erwartet: BUILD SUCCESSFUL, 229 Testfälle grün. - -- [ ] **Schritt 6: Anwendung prüfen** - -Ausführen: `./gradlew :app-desktop:run` - -Im laufenden Programm die Stationsliste ansehen. -Erwartet: Die Spalte mit der Frequenz zeigt für Stationen ohne bekannte QRG **nichts**, nicht `0` und nicht `144`. - -- [ ] **Schritt 7: Commit** - -```bash -git add -A -git commit -m "Hold the chat member frequency in an observable value" -``` - ---- - -## Aufgabe 9: `ChatPreferences` - -**Dateien:** -- Ändern: `core/src/main/java/kst4contest/model/ChatPreferences.java` (`:26`, `:35`, `:36`, `:228`, `:266`, `:268`, `:296`, `:298`, `:307`, `:308`, `:324`, `:325` und die zugehörigen Zugriffsmethoden) -- Ändern: die Aufrufstellen in `app-desktop` -- Test: `core/src/test/java/kst4contest/model/ChatPreferencesObservableTest.java` - -**Schnittstellen:** -- Konsumiert: `MutableValue`, `SimpleValue`, `SimpleRoster` aus den Aufgaben 2 und 3. -- Erzeugt: `actualQTF` wird `MutableValue`, `MYQRGFirstCat`/`MYQRGSecondCat`/`notify_optionalFrequencyPrefix`/`notify_DXCSrv_SpottersCallSign` werden `MutableValue`, die vier Listen werden `SimpleRoster`. - -**Was unverändert bleibt:** Das `preferences.xml`-Format. Version 7, dieselben Elementnamen, dieselben Vorgabewerte. Diese Aufgabe ändert Typen im Speicher, nicht auf der Platte. - -- [ ] **Schritt 1: Den Test schreiben, der die Vorgabewerte festschreibt** - -`core/src/test/java/kst4contest/model/ChatPreferencesObservableTest.java`: - -```java -package kst4contest.model; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class ChatPreferencesObservableTest { - - @Test - void theDefaultsMatchTheFormerJavaFxProperties() { - ChatPreferences prefs = new ChatPreferences(); - - assertEquals(360.0, prefs.getActualQTF().get(), 0.0001, - "actualQTF defaulted to 360 as a SimpleDoubleProperty"); - assertEquals("144", prefs.getNotify_optionalFrequencyPrefix().get()); - assertEquals("DO5AMF", prefs.getNotify_DXCSrv_SpottersCallSign().get()); - } - - @Test - void theQrgValuesStartUnavailable() { - ChatPreferences prefs = new ChatPreferences(); - - assertNull(prefs.getMYQRGFirstCat().get(), - "an unset own QRG must stay unavailable rather than becoming a fixed band"); - assertNull(prefs.getMYQRGSecondCat().get()); - } - - @Test - void theListSettingsStartEmpty() { - ChatPreferences prefs = new ChatPreferences(); - - assertTrue(prefs.getLstNotify_QSOSniffer_sniffedWordsList().snapshot().isEmpty()); - assertTrue(prefs.getLstNotify_QSOSniffer_sniffedPrefixLocList().snapshot().isEmpty()); - assertTrue(prefs.getLst_txtSnipList().snapshot().isEmpty()); - assertTrue(prefs.getLst_txtShortCutBtnList().snapshot().isEmpty()); - } -} -``` - -- [ ] **Schritt 2: Test laufen lassen und Fehlschlag sehen** - -Ausführen: `./gradlew :core:test --tests 'kst4contest.model.ChatPreferencesObservableTest'` -Erwartet: FAIL — die Rückgabetypen passen nicht. - -- [ ] **Schritt 3: Die Felder umstellen** - -Die drei JavaFX-Importe (`:26`, `:35`, `:36`) durch - -```java -import kst4contest.observe.MutableValue; -import kst4contest.observe.SimpleRoster; -import kst4contest.observe.SimpleValue; -``` - -ersetzen. Dann: - -```java - MutableValue actualQTF = new SimpleValue<>(360.0); - MutableValue MYQRGFirstCat = new SimpleValue<>(); - MutableValue MYQRGSecondCat = new SimpleValue<>(); - MutableValue notify_optionalFrequencyPrefix = new SimpleValue<>("144"); - MutableValue notify_DXCSrv_SpottersCallSign = new SimpleValue<>("DO5AMF"); - SimpleRoster lstNotify_QSOSniffer_sniffedWordsList = new SimpleRoster<>(); - SimpleRoster lstNotify_QSOSniffer_sniffedPrefixLocList = new SimpleRoster<>(); - SimpleRoster lst_txtSnipList = new SimpleRoster<>(); - SimpleRoster lst_txtShortCutBtnList = new SimpleRoster<>(); -``` - -Die Kommentare an den Feldern bleiben unverändert stehen. - -> `actualQTF` war ein `DoubleProperty` mit primitivem `double`. Als `MutableValue` kann es `null` werden. Es darf nicht: der Vorgabewert 360 bleibt, und keine Stelle setzt `null`. Wer `getActualQTF().get()` auspackt, bekommt weiterhin einen Wert. - -- [ ] **Schritt 4: Die Zugriffsmethoden nachziehen** - -Ausführen: `grep -n "Property()\|getActualQTF\|getMYQRG\|getNotify_\|getLst_txt\|getLstNotify_" core/src/main/java/kst4contest/model/ChatPreferences.java` - -Alle `…Property()`-Methoden im JavaFX-Stil entfallen; die `get…()`-Methoden liefern den `MutableValue` beziehungsweise den `SimpleRoster`. Die Setter, die heute eine `ObservableList` entgegennehmen, nehmen jetzt eine `Collection` und rufen `setAll(…)` auf dem vorhandenen Roster auf — **nicht** das Feld ersetzen, sonst verlieren angemeldete Listener ihre Quelle. - -- [ ] **Schritt 5: Lese- und Schreibpfad von `preferences.xml` prüfen** - -Ausführen: `grep -rn "actualQTF\|MYQRGFirstCat\|lst_txtSnipList" core/src/main/java/kst4contest/model/ChatPreferences.java | grep -iE "element|getTextContent|appendChild|setTextContent"` - -Jede dieser Stellen liest oder schreibt den Wert. Sie werden auf `.get()` beziehungsweise `.set(…)` umgestellt. Die XML-Elementnamen und die Reihenfolge bleiben unangetastet. - -- [ ] **Schritt 6: Die Oberfläche nachziehen** - -Ausführen: `grep -rn "getActualQTF\|getMYQRG\|getNotify_optionalFrequencyPrefix\|getNotify_DXCSrv_SpottersCallSign\|getLst_txtSnipList\|getLst_txtShortCutBtnList\|getLstNotify_QSOSniffer" app-desktop/src` - -Für einfache Lesezugriffe genügt `.get()`. Wo eine `ObservableList` an ein Bedienelement gebunden war (`ListView.setItems`, `ComboBox.setItems`), wird der Adapter aus Aufgabe 11 benutzt; bis dahin genügt `FXCollections.observableArrayList(roster.snapshot())` an der Bindungsstelle. Solche Stellen als `Ruling:` notieren, damit Aufgabe 11 sie findet. - -- [ ] **Schritt 7: Tests laufen lassen** - -Ausführen: `./gradlew build` -Erwartet: BUILD SUCCESSFUL, 232 Testfälle grün. - -- [ ] **Schritt 8: Einstellungen im laufenden Programm prüfen** - -Ausführen: `./gradlew :app-desktop:run` - -Einstellungsfenster öffnen, unter **Messagehandling** einen Textbaustein ergänzen, **Save Settings** drücken, Programm beenden und neu starten. -Erwartet: Der Baustein ist noch da. `~/.praktiKST/preferences.xml` enthält ihn, und die Konfigurationsversion steht weiterhin auf 7. - -- [ ] **Schritt 9: Commit** - -```bash -git add -A -git commit -m "Hold the observable preferences in the core abstractions" -``` - ---- - -## Aufgabe 10: Stationsliste filtern und sortieren als reine Funktion - -Deckt Prüfschwerpunkt 1 ab. Das ist der fachliche Kern dieser Etappe. - -**Dateien:** -- Anlegen: `core/src/main/java/kst4contest/controller/ChatMemberListView.java` -- Test: `core/src/test/java/kst4contest/controller/ChatMemberListViewTest.java` - -**Schnittstellen:** -- Erzeugt: `ChatMemberListView.compute(List members, List> filters, Comparator order)` liefert `List` — unveränderlich. Konsumiert von Aufgabe 11. - -Heute leisten das `FilteredList` (`ChatController:1766`) und `SortedList` (`:1767`). Beide sind JavaFX-Typen und deshalb nicht ohne laufendes Toolkit testbar. Die reine Funktion macht dieses Verhalten **erstmals prüfbar** — das ist der eigentliche Gewinn der Etappe. - -**Verhalten, das erhalten bleiben muss:** `FilteredList` verknüpft mehrere Prädikate nicht selbst; `ChatController` setzt ein zusammengesetztes. Die neue Funktion verknüpft die Liste mit UND. Eine leere Prädikatliste lässt alles durch. Ein `null`-Komparator lässt die Reihenfolge unverändert. - -**Bewusst geändertes Verhalten:** `SortedList` fügte per Binärsuche ein und liess bei Gleichstand die Position davon abhängen, in welcher Reihenfolge Stationen historisch eingetroffen und wieder gegangen waren. Die neue Funktion sortiert bei jeder Änderung die kanonische Liste vollständig neu, also stabil über der Ankunftsreihenfolge. Das ist deterministisch und reproduzierbar, wo die alte Position undefiniert war. - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -`core/src/test/java/kst4contest/controller/ChatMemberListViewTest.java`: - -```java -package kst4contest.controller; - -import kst4contest.model.ChatMember; -import org.junit.jupiter.api.Test; - -import java.util.Comparator; -import java.util.List; -import java.util.function.Predicate; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ChatMemberListViewTest { - - private static ChatMember member(String call) { - ChatMember m = new ChatMember(); - m.setCallSign(call); - return m; - } - - private static List calls(List members) { - return members.stream().map(ChatMember::getCallSign).toList(); - } - - @Test - void withoutFiltersOrOrderTheInputIsReturnedUnchanged() { - List input = List.of(member("DN9APW"), member("DO5AMF"), member("DL1ABC")); - - List result = ChatMemberListView.compute(input, List.of(), null); - - assertEquals(List.of("DN9APW", "DO5AMF", "DL1ABC"), calls(result)); - } - - @Test - void severalFiltersAreCombinedWithAnd() { - List input = List.of(member("DN9APW"), member("DO5AMF"), member("DL1ABC")); - Predicate startsWithD = m -> m.getCallSign().startsWith("D"); - Predicate endsWithF = m -> m.getCallSign().endsWith("F"); - - List result = ChatMemberListView.compute(input, List.of(startsWithD, endsWithF), null); - - assertEquals(List.of("DO5AMF"), calls(result)); - } - - @Test - void anEmptyFilterListLetsEverythingThrough() { - List input = List.of(member("DN9APW"), member("DO5AMF")); - - assertEquals(2, ChatMemberListView.compute(input, List.of(), null).size()); - } - - @Test - void theOrderIsStableForEqualRanks() { - List input = List.of(member("BBB"), member("AAA"), member("CCC"), member("AAA")); - Comparator allEqual = (a, b) -> 0; - - List result = ChatMemberListView.compute(input, List.of(), allEqual); - - assertEquals(List.of("BBB", "AAA", "CCC", "AAA"), calls(result), - "equal ranks must keep their incoming order, the way SortedList did"); - } - - @Test - void theComparatorDecidesTheOrder() { - List input = List.of(member("CCC"), member("AAA"), member("BBB")); - - List result = ChatMemberListView.compute( - input, List.of(), Comparator.comparing(ChatMember::getCallSign)); - - assertEquals(List.of("AAA", "BBB", "CCC"), calls(result)); - } - - @Test - void filteringHappensBeforeOrdering() { - List input = List.of(member("CCC"), member("AAA"), member("BBB")); - Predicate notB = m -> !m.getCallSign().equals("BBB"); - - List result = ChatMemberListView.compute( - input, List.of(notB), Comparator.comparing(ChatMember::getCallSign)); - - assertEquals(List.of("AAA", "CCC"), calls(result)); - } - - @Test - void theResultIsImmutable() { - List result = ChatMemberListView.compute(List.of(member("DN9APW")), List.of(), null); - - assertThrows(UnsupportedOperationException.class, () -> result.add(member("DL1ABC"))); - } - - @Test - void aFilterThrowingDoesNotDiscardTheWholeList() { - List input = List.of(member("DN9APW"), member("DO5AMF")); - Predicate broken = m -> { throw new IllegalStateException("boom"); }; - - List result = ChatMemberListView.compute(input, List.of(broken), null); - - assertEquals(List.of("DN9APW", "DO5AMF"), calls(result), - "a broken filter must not empty the station list mid-contest"); - } -} -``` - -- [ ] **Schritt 2: Test laufen lassen und Fehlschlag sehen** - -Ausführen: `./gradlew :core:test --tests 'kst4contest.controller.ChatMemberListViewTest'` -Erwartet: FAIL — `ChatMemberListView` existiert nicht. - -- [ ] **Schritt 3: Die Umsetzung schreiben** - -`core/src/main/java/kst4contest/controller/ChatMemberListView.java`: - -```java -package kst4contest.controller; - -import kst4contest.model.ChatMember; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.function.Predicate; - -/** - * Derives the visible station list from the canonical one. - * - *

This replaces the JavaFX {@code FilteredList}/{@code SortedList} pair that - * used to sit in {@link ChatController}. Being a pure function, the filtering - * and ordering rules are testable without a running toolkit for the first time. - */ -public final class ChatMemberListView { - - private ChatMemberListView() { - } - - /** - * @param members the canonical list; never modified - * @param filters combined with AND; an empty list lets everything through - * @param order {@code null} keeps the incoming order; sorting is stable - * @return an immutable result list - */ - public static List compute(List members, - List> filters, - Comparator order) { - List result = new ArrayList<>(members.size()); - for (ChatMember member : members) { - if (matches(member, filters)) { - result.add(member); - } - } - if (order != null) { - // List.sort is stable, which is what SortedList guaranteed. - result.sort(order); - } - return List.copyOf(result); - } - - private static boolean matches(ChatMember member, List> filters) { - for (Predicate filter : filters) { - try { - if (!filter.test(member)) { - return false; - } - } catch (RuntimeException e) { - // A broken filter must not empty the station list during a contest. - System.err.println("[station list] filter failed, keeping the member: " + e); - } - } - return true; - } -} -``` - -- [ ] **Schritt 4: Test laufen lassen** - -Ausführen: `./gradlew :core:test --tests 'kst4contest.controller.ChatMemberListViewTest'` -Erwartet: PASS, 8 Testfälle. - -- [ ] **Schritt 5: Commit** - -```bash -git add core/src/main/java/kst4contest/controller/ChatMemberListView.java core/src/test/java/kst4contest/controller/ChatMemberListViewTest.java -git commit -m "Derive the visible station list with a pure function" -``` - ---- - -## Aufgabe 11: `ChatController` und der Adapter in der Oberfläche - -Deckt die Prüfschwerpunkte 3 und 5 ab. Das ist die größte Aufgabe dieses Plans. - -**Dateien:** -- Ändern: `core/src/main/java/kst4contest/controller/ChatController.java` (Importe `:14`-`:20`, Felder und rund 30 öffentliche Methoden) -- Ändern: `core/src/main/java/kst4contest/controller/MessageBusManagementThread.java` (`:16`, `:577`) -- Anlegen: `app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java` -- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Test: `app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java` - -**Schnittstellen:** -- Konsumiert: `SimpleRoster`, `ObservableRoster` (Aufgabe 3), `UiDispatcher` (Aufgabe 4), `ChatMemberListView.compute` (Aufgabe 10). -- Erzeugt: `FxRosterBinding.mirror(ObservableRoster, UiDispatcher)` liefert eine `ObservableList`, die dem Roster folgt, plus `void dispose()` zum Abmelden. - -Die Oberfläche greift heute an rund 15 Stellen auf diese Listen zu: fünfmal `.size()`, einmal `.stream()`, einmal `.addListener(`, dazu acht Rohübergaben an `TableView.setItems` und Hilfsmethoden. - -- [ ] **Schritt 1: Den Test für den Adapter schreiben** - -`app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java`: - -```java -package kst4contest.view; - -import javafx.collections.ObservableList; -import kst4contest.observe.DirectUiDispatcher; -import kst4contest.observe.SimpleRoster; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class FxRosterBindingTest { - - @Test - void theMirrorStartsWithTheCurrentContent() { - SimpleRoster roster = new SimpleRoster<>(); - roster.add("DN9APW"); - - FxRosterBinding binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); - - assertEquals(List.of("DN9APW"), List.copyOf(binding.list())); - } - - @Test - void theMirrorFollowsLaterChanges() { - SimpleRoster roster = new SimpleRoster<>(); - FxRosterBinding binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); - - roster.add("DN9APW"); - roster.add("DO5AMF"); - - assertEquals(List.of("DN9APW", "DO5AMF"), List.copyOf(binding.list())); - } - - @Test - void theMirrorIsTheSameInstanceThroughout() { - SimpleRoster roster = new SimpleRoster<>(); - FxRosterBinding binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); - ObservableList list = binding.list(); - - roster.add("DN9APW"); - - assertTrue(list == binding.list(), - "a TableView holds on to the instance it was given; it must not be replaced"); - } - - @Test - void disposeStopsFollowingAndReleasesTheListener() { - SimpleRoster roster = new SimpleRoster<>(); - FxRosterBinding binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); - - binding.dispose(); - roster.add("DN9APW"); - - assertTrue(binding.list().isEmpty(), - "a disposed binding must not keep the old runtime alive across a profile switch"); - } -} -``` - -- [ ] **Schritt 2: Test laufen lassen und Fehlschlag sehen** - -Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.FxRosterBindingTest'` -Erwartet: FAIL — `FxRosterBinding` existiert nicht. - -- [ ] **Schritt 3: Den Adapter schreiben** - -`app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java`: - -```java -package kst4contest.view; - -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; -import kst4contest.observe.ObservableRoster; -import kst4contest.observe.UiDispatcher; - -import java.util.List; -import java.util.function.Consumer; - -/** - * Mirrors an {@link ObservableRoster} into an {@link ObservableList} so the - * existing {@code TableView} bindings keep working. - * - *

The mirror instance never changes: a {@code TableView} holds on to the - * list it was handed. {@link #dispose()} unregisters the listener, which is - * what keeps a discarded runtime from being held alive across a profile switch. - */ -public final class FxRosterBinding { - - private final ObservableRoster source; - private final ObservableList mirror; - private final Consumer> listener; - private boolean disposed; - - private FxRosterBinding(ObservableRoster source, UiDispatcher dispatcher) { - this.source = source; - this.mirror = FXCollections.observableArrayList(source.snapshot()); - this.listener = snapshot -> dispatcher.runOnUi(() -> { - if (!disposed) { - mirror.setAll(snapshot); - } - }); - source.addListener(listener); - } - - public static FxRosterBinding mirror(ObservableRoster source, UiDispatcher dispatcher) { - return new FxRosterBinding<>(source, dispatcher); - } - - /** The list to hand to a {@code TableView}. Always the same instance. */ - public ObservableList list() { - return mirror; - } - - /** Unregisters from the source and empties the mirror. Idempotent. */ - public void dispose() { - if (disposed) { - return; - } - disposed = true; - source.removeListener(listener); - mirror.clear(); - } -} -``` - -- [ ] **Schritt 4: Test laufen lassen** - -Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.FxRosterBindingTest'` -Erwartet: PASS, 4 Testfälle. - -- [ ] **Schritt 5: Die Felder in `ChatController` umstellen** - -Die sieben `ObservableList`-Felder werden `SimpleRoster`: - -```java - private final SimpleRoster activeSkeds = new SimpleRoster<>(); - private final SimpleRoster lst_globalChatMessageList = new SimpleRoster<>(); - private final SimpleRoster chatMemberList = new SimpleRoster<>(); - private final SimpleRoster> lst_chatMemberListFilterPredicates = new SimpleRoster<>(); - private final SimpleRoster lst_clusterMemberList = new SimpleRoster<>(); - private final SimpleRoster lst_DBBasedWkdCallSignList = new SimpleRoster<>(); - private final SimpleRoster lstNotify_QSOSniffer_sniffedCallSignList = new SimpleRoster<>(); -``` - -`SimpleRoster` ist selbst synchronisiert; die bisherige Hülle `FXCollections.synchronizedObservableList(chatMemberList)` entfällt, und `lst_chatMemberList` wird zum selben Feld wie `chatMemberList`. - -Die `FilteredList`/`SortedList`-Felder entfallen ersatzlos. An ihre Stelle tritt: - -```java - /** The visible station list, derived on demand. */ - public List visibleChatMembers() { - return ChatMemberListView.compute( - chatMemberList.snapshot(), - lst_chatMemberListFilterPredicates.snapshot(), - chatMemberOrder); - } -``` - -wobei `chatMemberOrder` der Komparator ist, den `SortedList` heute gesetzt bekommt. Ermitteln mit: - -Ausführen: `grep -rn "setComparator\|comparatorProperty" core/src app-desktop/src` - -- [ ] **Schritt 6: Die Bündelung erhalten** - -Ausführen: `grep -n "flushPending\|runLater\|coalesc\|pending" core/src/main/java/kst4contest/controller/ChatController.java` - -Jede gefundene Sammel- oder Entprellstelle bleibt. Der Unterschied: statt am Ende `Platform.runLater(this::flushPendingChatMessagesToUi)` zu rufen, sammelt die Methode in eine lokale Liste und übergibt sie mit **einem** `roster.mutate(list -> { list.clear(); list.addAll(gesammelt); })`. Damit feuert ein Sammelvorgang genau einen Listener-Durchlauf. - -> Prüfschwerpunkt 3: Wird hier pro Nachricht ein `roster.add(…)` gerufen, entsteht bei mehreren tausend Nutzern eine Snapshot-Flut. Das zeigt sich nicht im Test, sondern erst unter Last. - -- [ ] **Schritt 7: Die öffentlichen Methoden umstellen** - -Ausführen: `grep -nE "public .*(ObservableList|FilteredList|SortedList)" core/src/main/java/kst4contest/controller/ChatController.java` - -Jede Rückgabe wird zu `ObservableRoster<…>`. Setter, die eine `ObservableList` entgegennahmen, nehmen eine `Collection<…>` und rufen `setAll(…)` auf dem vorhandenen Roster, statt das Feld zu ersetzen. - -Die Methoden `getLst_toAllMessageList`, `getLst_toMeMessageList`, `getLst_toOtherMessageList` und `getLst_selectedCallSignInfofilteredMessageList` gaben `FilteredList` zurück, deren Prädikat von außen gesetzt wurde (`Kst4ContestApplication:1426`, `:1460`, `:1483`, `:1507`). Sie werden zu je einem Paar aus Setzer und Abfrage in `ChatController`, das mit dem gespeicherten Prädikat aus `lst_globalChatMessageList.snapshot()` filtert: - -```java - public void setToAllMessageFilter(Predicate filter) { this.toAllMessageFilter = filter; } - public List toAllMessages() { return filtered(toAllMessageFilter); } - - public void setToMeMessageFilter(Predicate filter) { this.toMeMessageFilter = filter; } - public List toMeMessages() { return filtered(toMeMessageFilter); } - - public void setToOtherMessageFilter(Predicate filter) { this.toOtherMessageFilter = filter; } - public List toOtherMessages() { return filtered(toOtherMessageFilter); } - - public void setSelectedCallSignInfoFilter(Predicate filter) { this.selectedCallSignInfoFilter = filter; } - public List selectedCallSignInfoMessages() { return filtered(selectedCallSignInfoFilter); } - - private List filtered(Predicate filter) { - if (filter == null) { - return lst_globalChatMessageList.snapshot(); - } - return lst_globalChatMessageList.snapshot().stream().filter(filter).toList(); - } -``` - -Ein `null`-Prädikat lässt alles durch, genau wie `FilteredList.setPredicate(null)` — `ChatController:3057` verlässt sich darauf. - -- [ ] **Schritt 8: Die Oberfläche nachziehen** - -Ausführen: `grep -rn "getLst_chatMemberList\|getLst_chatMemberSortedFilteredList\|getLst_chatMemberListFiltered\|getLst_globalChatMessageList\|getLst_clusterMemberList\|getActiveSkeds\|getLstNotify_QSOSniffer_sniffedCallSignList" app-desktop/src` - -In `Kst4ContestApplication` je ein Feld für die Spiegel anlegen und in `shutdownRuntime()` abräumen: - -```java - private final List> rosterBindings = new ArrayList<>(); - - private ObservableList mirrorOf(ObservableRoster roster) { - FxRosterBinding binding = FxRosterBinding.mirror(roster, uiDispatcher); - rosterBindings.add(binding); - return binding.list(); - } -``` - -und in `shutdownRuntime()`: - -```java - rosterBindings.forEach(FxRosterBinding::dispose); - rosterBindings.clear(); -``` - -> Prüfschwerpunkt 5: Ohne dieses Abräumen hält ein Spiegel die verworfene `Kst4ContestApplication` am Leben. Genau solche Lecks gab es vor den Aufräumarbeiten in `shutdownRuntime()` schon einmal. - -`.size()` wird zu `.size()` auf dem Roster, `.stream()` zu `.snapshot().stream()`, `.addListener(…)` zu `roster.addListener(snapshot -> …)`. Rohübergaben an `TableView.setItems` werden zu `setItems(mirrorOf(roster))`. - -Die Stationsliste bekommt `chatcontroller.visibleChatMembers()` statt der `SortedList`; nach jeder Änderung an Mitgliedern oder Filtern wird sie neu berechnet und über den Spiegel gesetzt. - -- [ ] **Schritt 8b: `MessageBusManagementThread` von `ObservableList` lösen** - -Die Methode `checkListForChatMemberIndexByCallSign` nimmt heute eine `ObservableList` entgegen (`MessageBusManagementThread:577`). Sie liest die Liste nur. - -Ausführen: `grep -n "ObservableList\|checkListForChatMemberIndexByCallSign" core/src/main/java/kst4contest/controller/MessageBusManagementThread.java` - -Den Import `javafx.collections.ObservableList` entfernen, `import java.util.List;` ergänzen falls nicht vorhanden, und die Signatur ändern zu: - -```java - private int checkListForChatMemberIndexByCallSign(List list, ChatMember lookForThis) { -``` - -Die Aufrufer übergeben künftig `roster.snapshot()`. - -> Das ist die Stelle, an der `AGENTS.md` ausdrücklich verlangt, dass dieser Thread keine UI-gebundene Sammlung anfasst. Mit einem Snapshot statt einer lebenden Liste ist das strukturell erfüllt statt nur verabredet. - -- [ ] **Schritt 9: Übersetzen und testen** - -Ausführen: `./gradlew build` -Erwartet: BUILD SUCCESSFUL, 244 Testfälle grün (232 nach Aufgabe 9, plus 8 aus Aufgabe 10 und 4 aus Schritt 1). - -- [ ] **Schritt 10: Verhalten im laufenden Programm prüfen** - -Ausführen: `./gradlew :app-desktop:run` - -Erwartet, der Reihe nach: -- Die Stationsliste füllt sich und steht in derselben Reihenfolge wie vor der Umstellung. -- Ein Filter in der Filterleiste verkleinert die Liste und hebt sie beim Zurücksetzen wieder auf. -- Die drei Chat-Reiter zeigen ihre jeweiligen Nachrichten getrennt. -- Ein Profilwechsel über das Einstellungsfenster funktioniert, und die Stationsliste des neuen Profils füllt sich. - -- [ ] **Schritt 11: Commit** - -```bash -git add -A -git commit -m "Hold the controller collections in observable rosters" -``` - ---- - -## Aufgabe 12: `ScoreService` - -**Dateien:** -- Ändern: `core/src/main/java/kst4contest/controller/ScoreService.java` (Importe `:3`-`:9`, `:20`-`:22`; `:45`, `:62`, `:66`, `:74`, `:91`, `:100`, `:195`, `:352`) -- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -**Schnittstellen:** -- Konsumiert: `SimpleRoster`, `SimpleValue`, `UiDispatcher`. -- Erzeugt: `getTopCandidatesFx()` heißt künftig `topCandidates()` und liefert `ObservableRoster`; `selectedCallPriorityScoreProperty()` wird `ObservableValue selectedCallPriorityScore()`; `uiPulseProperty()` wird `ObservableValue uiPulse()`; `selectedChatMemberProperty()` wird `ObservableValue selectedChatMember()`. - -Diese Klasse ist bereits auf die Trennung gebaut — es gibt ein Snapshot-Modell, und der Klassenkommentar hält „No per-member Platform.runLater flooding" fest. Diese Eigenschaft bleibt erhalten. - -- [ ] **Schritt 1: Die heutige Bündelung ansehen** - -Ausführen: `sed -n '85,110p;185,205p' core/src/main/java/kst4contest/controller/ScoreService.java` - -Notieren, an welchen Stellen gebündelt wird. Diese Stellen werden übernommen, nicht vereinfacht. - -- [ ] **Schritt 2: Umstellen** - -Die zehn JavaFX-Importe durch - -```java -import kst4contest.observe.MutableValue; -import kst4contest.observe.ObservableRoster; -import kst4contest.observe.ObservableValue; -import kst4contest.observe.SimpleRoster; -import kst4contest.observe.SimpleValue; -import kst4contest.observe.UiDispatcher; -``` - -ersetzen. `topCandidatesFx` wird `SimpleRoster`, die drei Properties werden `SimpleValue`. Jedes - -```java -if (Platform.isFxApplicationThread()) { … } else { Platform.runLater(() -> …); } -``` - -wird zu `uiDispatcher.runOnUi(() -> …)`; der Dispatcher kommt über den Konstruktor. - -- [ ] **Schritt 3: Die Oberfläche nachziehen** - -Ausführen: `grep -rn "getTopCandidatesFx\|selectedCallPriorityScoreProperty\|uiPulseProperty\|selectedChatMemberProperty" app-desktop/src` - -`getTopCandidatesFx()` wird zu `mirrorOf(scoreService.topCandidates())` aus Aufgabe 11. Bindungen wie `label.textProperty().bind(x.selectedCallPriorityScoreProperty())` werden zu einem Listener: - -```java -scoreService.selectedCallPriorityScore().addListener(value -> - uiDispatcher.runOnUi(() -> label.setText(formatScore(value)))); -``` - -- [ ] **Schritt 4: Übersetzen und testen** - -Ausführen: `./gradlew build` -Erwartet: BUILD SUCCESSFUL, 244 Testfälle grün. Diese Aufgabe fügt keine Tests hinzu; die Zahl muss unverändert bleiben. - -- [ ] **Schritt 5: Prüfen** - -Ausführen: `./gradlew :app-desktop:run` - -Eine Station in der Liste auswählen. -Erwartet: Die Prioritätsanzeige und die Top-Prioritätenliste aktualisieren sich wie zuvor. - -- [ ] **Schritt 6: Commit** - -```bash -git add -A -git commit -m "Publish the score service state through the core abstractions" -``` - ---- - -## Aufgabe 13: JavaFX aus `core` entfernen - -**Dateien:** -- Ändern: `core/build.gradle.kts` -- Ändern: `docs/PROJECT_CONTEXT.md`, `AGENTS.md` - -- [ ] **Schritt 1: Nachweisen, dass kein JavaFX mehr referenziert wird** - -Ausführen: `grep -rn "javafx" core/src` -Erwartet: keine Ausgabe. - -Bleibt ein Treffer, ist eine Datei übersehen worden. Sie wird nach demselben Muster umgestellt; als `Ruling:` festhalten. - -- [ ] **Schritt 2: Den JavaFX-Block entfernen** - -In `core/build.gradle.kts` den `alias(libs.plugins.javafx)`-Eintrag im `plugins`-Block und den gesamten `javafx { … }`-Block löschen, samt des Kommentars, der auf Etappe 2 verweist. - -- [ ] **Schritt 3: Der Härtetest** - -Ausführen: `./gradlew clean build` -Erwartet: BUILD SUCCESSFUL. `core` übersetzt **ohne JavaFX auf dem Klassenpfad** — das ist das Ziel dieser Etappe. - -Ausführen: `./gradlew :core:dependencies --configuration compileClasspath | grep -c javafx` -Erwartet: `0`. - -- [ ] **Schritt 4: Die Anwendung vollständig prüfen** - -Ausführen: `./gradlew :app-desktop:packageImage && ./app-desktop/build/jpackage/praktiKST/bin/praktiKST` - -Erwartet, der Reihe nach: -- Hauptfenster erscheint, Stationsliste füllt sich. -- Die drei Chat-Reiter zeigen getrennte Nachrichten. -- Filterleiste verkleinert und erweitert die Stationsliste. -- Einstellungsfenster öffnet, alle zwölf Reiter sind bedienbar, **Save Settings** schreibt `preferences.xml` in Version 7. -- Das Kartenfenster zeigt Kacheln und Stationsmarker. -- Ein Profilwechsel im laufenden Betrieb funktioniert ohne doppelte Listener. - -- [ ] **Schritt 5: Dokumentation nachziehen** - -In `docs/PROJECT_CONTEXT.md` unter „Current Architecture" ergänzen, dass `core` keine UI-Technologie mehr kennt und die Beobachtungsgrenze `kst4contest.observe` heißt. Den Abschnitt „JavaFX/threading" umbenennen in „Threading" und `Platform.runLater` durch `UiDispatcher` ersetzen; die Aussage, `ObservableList` sei eine UI-Projektion, wird zur Aussage über `ObservableRoster`. - -In `AGENTS.md` im Abschnitt „Java and JavaFX architecture" dieselben Begriffe angleichen. Die inhaltliche Regel bleibt wortgleich: Worker-Threads mutieren keine UI-gebundenen Sammlungen. - -- [ ] **Schritt 6: Commit** - -```bash -git add -A -git commit -m "Drop the JavaFX dependency from core" -``` - ---- - -## Abnahme der Etappe - -Etappe 2 ist abgeschlossen, wenn alles davon zutrifft: - -- [ ] `grep -rn "javafx" core/src` liefert keine Ausgabe. -- [ ] `./gradlew :core:dependencies --configuration compileClasspath | grep -c javafx` liefert `0`. -- [ ] `./gradlew clean build` läuft durch; alle Testfälle grün, darunter die neuen für `observe`, `ChatMemberListView`, `ChatMember.frequency`, `ChatPreferences` und `FxRosterBinding`. -- [ ] Das jpackage-Image startet und zeigt Stationsliste, Chat-Reiter, Filter, Einstellungsfenster, Karte und Profilwechsel unverändert. -- [ ] Die Stationsliste folgt bei gleichem Rang der Ankunftsreihenfolge, und ein Spaltenwechsel ordnet ranggleiche Zeilen nicht nach der vorher gewählten Spalte. (Nicht: „dieselbe Reihenfolge wie vorher" — `SortedList` hatte dort keine definierte Reihenfolge.) -- [ ] Eine Station ohne bekannte QRG zeigt weiterhin nichts an, nicht `0` und nicht `144`. -- [ ] `preferences.xml` bleibt in Version 7 und behält unbekannte Knoten. - ---- - -# Stand 2026-09-25: angehalten nach Aufgabe 11 - -Aufgaben 1 bis 11 sind umgesetzt, Commit `9ca5c7bf`. `./gradlew clean build` grün, -**249 Testfälle, 0 Fehler**. Nichts gepusht. `core` enthält JavaFX nur noch in -`controller/ScoreService.java`. Nicht begonnen: Aufgabe 12 und 13. - -Aufgabe 11 wurde von einem Subagenten umgesetzt und von einem zweiten, frischen -Prüfer nachgeprüft. Das vollständige Ledger liegt unter -`.superpowers/sdd/2026-09-25-etappe2-core-ohne-javafx/progress.md` — **git-ignoriert**, -also flüchtig. Was davon zählt, steht deshalb hier. - -## Offen: ein Fehler zum Beheben - -**Die Infofläche der gewählten Station wird bei jedem Chat-Zu- und -Abgang -zurückgesetzt.** `chatMemberListBinding` hängt direkt am kanonischen Roster; die -Schutzflagge `programmaticChatMemberSelectionChange` -(`Kst4ContestApplication.java:1718-1723`) umschliesst nur den ausdrücklichen -`refresh()`, nicht den vom Roster ausgelösten Weg. Der Auswahl-Listener (`:8124`) -läuft deshalb ungeschützt durch, `selectedCallSignFurtherInfoPane` wird neu gebaut -und die Radiofilter springen auf die Vorgabe (`:1657-1659`). - -Auswahl, Index und Scrollposition bleiben erhalten — gemessen bei 3000 Zeilen und -50 `setAll`-Durchläufen, Scrollposition in alter und neuer Fassung identisch. Es ist -also kein Datenverlust, aber eine laufende Sichtstörung: der Arbeitsgang „Station -wählen, Infofläche lesen, `/cq` schicken" wird auf einem gut besuchten Server alle -paar Sekunden unterbrochen. Verstösst gegen `AGENTS.md`, „Do not change ... selection, -focus, sorting, tab choice or prefilled text as an incidental side effect". - -**Vorgeschlagener Fix:** im Auswahl-Listener früh abbrechen, wenn -`isSameLogicalChatMember(selectedCallSignInfoStageChatMember, newSelectedMember)`. - -Nebenbefund: die Schutzflagge ist für den Filterknopf-Pfad wirkungslos, weil der -Spiegel beim geschützten Aufruf bereits gleich ist und `applyNow` früh zurückkehrt. - -## Korrektur: die Prämisse zur Sortierung war falsch - -Dieser Plan behauptet an drei Stellen, `SortedList` habe bei Gleichstand die -Ankunftsreihenfolge erhalten. **Das stimmt nicht.** `SortedList` fügte einzelne -Elemente per Binärsuche ein und landete bei Gleichstand an undefinierter Position; -gemessen ergab sich `A3, A4, A2, A1`. Über viele Zu- und Abgänge driftete der -Gleichstandsblock von der Ankunftsreihenfolge weg — und genau das sah dem Operator -wie springende Zeilen aus. - -`ChatMemberListView.compute` leitet den Block bei jeder Änderung vollständig aus der -kanonischen Liste ab, also immer in Ankunftsreihenfolge. Das ist deterministisch und -damit **besser** als vorher, aber eben nicht „dieselbe Reihenfolge wie vor der -Umstellung". Die `setSortPolicy`-Verdrahtung wurde gemessen und ist korrekt -(`sortOrderSize=1`, Sortierpfeil bleibt, zweiter Klick schlägt durch, -`getSortOrder().clear()` stellt die kanonische Ordnung her, keine Rekursion). - -**Zu korrigieren ist der Text, nicht der Code**, an drei Stellen: -1. Prüfschwerpunkt 1 oben in dieser Datei -2. Aufgabe 10, Absatz „Verhalten, das erhalten bleiben muss" („sortiert **stabil**") -3. Abnahmekriterium „Die Stationsliste steht in derselben Reihenfolge wie vor der - Umstellung, auch bei gleichem Rang" -4. Testkommentar in `ChatMemberListViewTest.theOrderIsStableForEqualRanks` - („the way SortedList did") - -Neue Formulierung: *bei Gleichstand gilt die Ankunftsreihenfolge; das ersetzt die -undefinierte Einfügeposition der `SortedList` bewusst.* - -## Kleinere Befunde des Prüfers — abgearbeitet 2026-09-27 (Commit 121ddd5c) - -- `List.copyOf` verträgt kein `null`; `runGuarded` würde eine NPE verschlucken und - der Spiegel erstarrte still. Nachgeprüft: `null` kann die Mitgliederliste nicht - erreichen, `addOrUpdateActiveChatMember` bricht bei `null`-Schlüssel ab. -- Bündelung nur schreibseitig: jeder Nachrichtenreiter filtert den ganzen Speicher - neu, ~1,7 ms statt ~0,1 ms je Schub. Unsichtbar, aber gemessen die ~17-fache Arbeit. -- `Kst4ContestApplication.java:7745` sortiert mehrere tausend Mitglieder nur für eine - Zahl im Statustext. -- `:2440` und `:2450` verdrahten die Ordnung doppelt; harmlos nur, weil der - `equals`-Vergleich in `FxRosterBinding:112` die Rekursion bricht — Kommentar fehlt. -- `ChatMember.equals(ChatMember)` ist eine Überladung, keine Überschreibung; der - Spiegelvergleich hängt daran, dass `List.equals` deshalb auf Identität fällt. -- `clearChatMemberListFilterPredicates` prüft `size() == 0`; `ObservableRoster` hat - kein `isEmpty()`. - -## Vom Prüfer ohne Beanstandung bestätigt - -Bündelung (ein `mutate` je Schub in beiden Flush-Methoden und im Kategorie-Austausch, -kein Pro-Element-Pfad), Lebensdauer der Spiegel (20 Aufrufstellen; die wiederholt -laufende wird entsorgt und aus `rosterBindings` entfernt; `shutdownRuntime()` räumt -vollständig ab; kein Roster wird unter laufender Bindung ausgetauscht). -Selbst nachgeprüft: `filtered(null)` liefert den vollen Schnappschuss, die -`setPredicate(null)`-Semantik ist erhalten; Prädikate 18-mal hinzugefügt, 18-mal -entfernt. - -## Nur von Hand prüfbar - -1. Spaltenköpfe der Stationsliste durchklicken, besonders Spalten mit vielen - Gleichständen (Worked, Kategorie, NOT-QRV). Sortierpfeil und Richtungswechsel. -2. Auswahlerhalt unter Last — durch die Messungen oben weitgehend entkräftet, - aber nie mit der Maus erlebt. - ---- - -# Nachtrag 2026-09-27: zwei Fehler, die erst der Lasttest zeigte - -Geprüft mit `src/kstsimulator.py` (777 Stationen in Kategorie 2), `LOGIN_LOGOUT_INTERVAL` -vorübergehend von 60 auf 3 Sekunden gesetzt. Beide Fehler betrafen den Tastaturfokus -und waren durch Lesen nicht zu finden — drei Leseläufe von zwei Agenten und dem -Umsetzer sind daran vorbeigegangen. - -## 1. Der Fokus wurde beim Tippen gestohlen (Regression aus Etappe 2) - -`prepareCqTextForCallsign` endet in `requestFocus()` auf dem Sendefeld. Aufgerufen -wurde es, sobald das Auswahlmodell eine Änderung meldete — und ein Spiegelwechsel -meldet bei jedem Chat-Zu- und -Abgang `[null, dieselbeStation]`. Wer in `Radio2` oder -`QRB` tippte, verlor bei dieser Rate alle 3 Sekunden den Fokus. - -Behoben: die `/cq`-Vorbereitung läuft nur noch, wenn die Station wirklich wechselt -oder der Operator selbst eine Zeile gewählt hat. Der Parameter `previousMember` ist -entfallen — der Vergleich dagegen war die Ursache, denn im Zwischenzustand ist er -`null` und sieht wie ein Wechsel aus. - -**Der Fix vom Vormittag war unvollständig:** er sicherte nur den Neuaufbau der -Infofläche. Begründung damals: `forceOverwrite=false` schütze getippten Text. Das -stimmt — aber der Fokus ist eine zweite Sache, und `AGENTS.md` nennt ihn ausdrücklich -neben dem Text. - -## 2. Ein Klick in die Userlist holte den Fokus nicht (Altbestand) - -`requestFocus()` lief innerhalb der Mausereignis-Verarbeitung; die `TableView` holt -sich den Fokus danach im Rahmen ihrer Standardbehandlung und macht die Anforderung -still zunichte. Behoben durch `Platform.runLater`. - -Der Fehler ist alt. Er war unsichtbar, weil der nächste Chat-Zugang den Fokus -Sekunden später ins Sendefeld zog — dasselbe Verhalten wie Fehler 1, nur zufällig in -die gewünschte Richtung. Fehler 1 abzuschalten machte ihn sichtbar. - -**Wichtig für spätere Etappen:** dort steht bewusst `Platform.runLater` und nicht -`uiDispatcher.runOnUi`. Der Dispatcher führt die Aufgabe inline aus, wenn der Aufrufer -schon auf dem UI-Thread ist — hier wäre das genau der Fall und würde nichts -verschieben. Ein „Aufräumen" auf den Dispatcher bringt den Fehler zurück. - -## Was der Lasttest sonst belegt hat - -- Speicher stabil über eine Minute unter Volllast: 1220 → 1218 → 1223 MB. Kein - Listener-Leck. -- Keine einzige `[observe]`-Meldung. Dort würde protokolliert, wenn ein Listener in - den neuen Abstraktionen wirft — nichts wurde verschluckt. -- Verbindungsabbruch des Simulators mitten im Betrieb: sauber protokolliert, - Wiederverbindung von selbst, kein abgestorbener Thread. -- Sortierung, Auswahlerhalt und der Fix an der Infofläche wurden vom Operator - bedient und bestätigt. Damit sind die beiden Handgriffe aus dem Haltepunkt erledigt. - - ---- - -# Nachtrag 2026-09-27: die kleinen Befunde sind erledigt - -Commit `121ddd5c`. 255 Testfälle, 0 Fehler; jpackage-Image gebaut und gestartet. - -| Befund | Erledigt durch | -|---|---| -| Vier Reiter filtern je Schub den ganzen Speicher neu | `SimpleRoster` hält den Schnappschuss zwischen, bis sich der Inhalt ändert. Die Kopie entsteht einmal je Änderung statt einmal je Leser. | -| `List.copyOf` verträgt kein `null` | `SimpleRoster.snapshot()` und `ChatMemberListView.compute` nutzen `Collections.unmodifiableList`. Die JavaFX-Liste trug `null` mit; eine NPE wäre im Listener-Schutz verschluckt worden und der Spiegel hätte stillschweigend aufgehört zu folgen. | -| `visibleChatMembers().size()` sortiert für eine Zahl | Neu `ChatMemberListView.countMatching` und `ChatController.visibleChatMemberCount()`; der Statustext zählt, statt zu sortieren. | -| `size() == 0` statt `isEmpty()` | `ObservableRoster.isEmpty()` als Standardmethode. | -| Rekursionsbremse in `FxRosterBinding` unkommentiert | Der Kommentar nennt jetzt beides: das Unterdrücken unnötiger Listenereignisse **und** das Brechen der Kette Sortierpolitik → `refresh()` → `setAll` → Sortierung. | -| `ChatMember.equals` ist Überladung, keine Überschreibung | An beiden Stellen vermerkt. Wer die Signatur „repariert", lässt zwei verschiedene Zeilen mit demselben Rufzeichen gleich erscheinen — dann kann `FxRosterBinding` eine echte Änderung verwerfen. | -| Voll qualifizierte Typen statt Importe | `ObservableRoster` ist jetzt importiert. `kst4contest.observe.ObservableValue` **bleibt** qualifiziert, mit Begründung im Code: `javafx.beans.value.ObservableValue` besitzt in dieser Klasse den einfachen Namen. | - -Sechs neue Tests: zwischengespeicherter Schnappschuss, `null` im Roster, `isEmpty`, -`countMatching` gegen `compute`, ein werfendes Prädikat beim Zählen, `null` in -`compute`. diff --git a/docs/superpowers/plans/2026-09-27-etappe3a-kotlin-compose-toolchain.md b/docs/superpowers/plans/2026-09-27-etappe3a-kotlin-compose-toolchain.md deleted file mode 100644 index 0b84e28e..00000000 --- a/docs/superpowers/plans/2026-09-27-etappe3a-kotlin-compose-toolchain.md +++ /dev/null @@ -1,893 +0,0 @@ -# Etappe 3a — Kotlin, Compose und Packaging: Umsetzungsplan - -> **Für agentische Bearbeiter:** ERFORDERLICHE UNTER-SKILL: superpowers:subagent-driven-development (empfohlen) oder superpowers:executing-plans, um diesen Plan Aufgabe für Aufgabe umzusetzen. Schritte nutzen Checkbox-Syntax (`- [ ]`). - -**Ziel:** Kotlin und Compose Multiplatform sind im Projekt, das Packaging läuft über `compose.desktop.nativeDistributions`, und **ein echtes Fenster der Anwendung ist in Compose geschrieben**. - -**Architektur:** `app-desktop` wird ein gemischtes Java/Kotlin-Modul. Die JavaFX-Oberfläche bleibt vollständig erhalten; ein einzelnes, kleines Fenster — der Operator-Profil-Auswahldialog, 145 Zeilen — wird durch eine Compose-Fassung ersetzt. Damit ist bewiesen, dass beide Toolkits im selben Prozess koexistieren, was die Voraussetzung für die Etappen 3b, 4 und 5 ist. - -**Tech-Stack:** Kotlin 2.2.0, Compose Multiplatform 1.8.2, Gradle 9.7.1, Java 21. Keine Änderung an `core`. - -**Spec:** `docs/superpowers/specs/2026-09-25-compose-migration-design.md` - -## Warum Etappe 3 in zwei Pläne geteilt ist - -Das Spec beschreibt Etappe 3 als eine Etappe: Kotlin und Compose einziehen, Packaging umstellen, die `DataTable` bauen, das Einstellungsfenster portieren. Gemessen am Code ist das mehr als Etappe 1 und 2 zusammen: - -| Teil | Umfang | -|---|---| -| Einstellungsfenster | 2341 Zeilen in `Kst4ContestApplication` plus 488 in `OperatorProfileSettingsPane` | -| `DataTable`-Vorarbeit | `TableLayoutManager` 280, `MessageTextTableCell` 326, `TruncatedTextTableCell` 109, `PrivateMessageRowStyleResolver` 83, `TruncatedTextTooltipSupport` 28 | -| Toolchain | Sprache, Toolkit und Packaging auf drei Plattformen | - -**Dieser Plan (3a)** liefert die Toolchain und ein kleines, echtes Compose-Fenster. **Plan 3b** liefert die `DataTable` und das Einstellungsfenster. Die Teilung folgt einer Abhängigkeit: die `DataTable` lässt sich nur an einem echten Verbraucher prüfen, und der kleinste echte Verbraucher ist ein Reiter des Einstellungsfensters. Beide gehören deshalb zusammen — die Toolchain nicht. - -## Das Risiko, das dieser Plan zuerst prüft - -Compose Desktop zeichnet über Skiko in ein AWT-Fenster und betreibt seinen eigenen Ereignis-Thread. JavaFX hat seinen eigenen. Ob ein Compose-Fenster aus einer laufenden JavaFX-Anwendung aufgehen kann, ohne dass eines von beiden stehen bleibt, ist **nicht belegt** — und die Etappen 3b, 4 und 5 setzen es voraus, weil sie Fenster einzeln umstellen. - -Aufgabe 3 klärt das, bevor Arbeit darauf aufbaut. **Scheitert sie, endet dieser Plan dort**, und die Etappenfolge im Spec muss neu gedacht werden: dann bleibt nur ein Umschalttag, an dem die gesamte Oberfläche wechselt. Das wäre eine Änderung am Spec, nicht am Plan. - -Zusätzlich gilt für den Profilwechsel: `Platform.setImplicitExit(false)` hält den Prozess am Leben, wenn alle JavaFX-Fenster zu sind. Ein offenes Compose-Fenster verlängert die Prozesslebensdauer auf eine zweite, unabhängige Weise. Aufgabe 6 prüft, dass das Beenden weiterhin über `ApplicationRuntimeLauncher.exitApplication()` geht und der Prozess wirklich endet. - -## Globale Randbedingungen - -- Java 21, Gradle 9.7.1. `core` wird **nicht** angefasst und bleibt frei von UI-Technologie. -- Die JavaFX-Oberfläche bleibt funktionsfähig. Diese Etappe ersetzt genau ein Fenster. -- Versionen zu Beginn von Aufgabe 1 gegen die aktuellen Veröffentlichungen prüfen; die hier genannten sind der Stand vom 2026-09-27. -- Kommentare und Javadoc auf Englisch, auch in Kotlin (KDoc). Kommunikation auf Deutsch. -- Die Invarianten aus dem Spec-Abschnitt „Invarianten" gelten unverändert, insbesondere `preferences.xml` Version 7 und das Verhalten der Operator-Profile. -- **`Platform.runLater` und `uiDispatcher.runOnUi` sind nicht dasselbe.** Der Dispatcher führt inline aus, wenn der Aufrufer schon auf dem UI-Thread ist. Wer innerhalb des UI-Threads nach hinten verschieben will, braucht `Platform.runLater` ausdrücklich. Siehe `prepareCqTextForCallsign`; das hat in Etappe 2 einen Fehler gekostet. - -## Prüfschwerpunkte - -Fünf Fehlerklassen, die kein Übersetzungslauf zeigt. - -1. **Die beiden Toolkits blockieren sich.** Ein Compose-Fenster, das den JavaFX-Thread anhält oder umgekehrt, äußert sich als hängende Oberfläche, nicht als Ausnahme. → Aufgabe 3. -2. **Der Prozess endet nicht mehr.** Mit `Platform.setImplicitExit(false)` und einem zusätzlichen AWT-Fenster kann ein Nicht-Daemon-Thread den Prozess überleben lassen. Der Operator schließt die Anwendung und sie läuft weiter. → Aufgabe 6. -3. **Das Paket wird größer oder unvollständig.** `compose.desktop.nativeDistributions` ermittelt die JDK-Module selbst. Die in Etappe 1 mühsam ermittelte Liste — `jdk.jsobject` für die Karten-Brücke, `jdk.unsupported` für den Marlin-Renderer — kann dabei verloren gehen, und das zeigt sich erst beim Start des Pakets. → Aufgabe 5. -4. **Die Schriftgröße und das Erscheinungsbild springen.** Compose kennt die CSS-Themen des Projekts nicht. Ein Compose-Fenster neben JavaFX-Fenstern kann anders skaliert aussehen, besonders bei der einheitlichen Basis-Schriftgröße aus 1.50. → Aufgabe 6. -5. **Der Profilwechsel bricht.** Der Auswahldialog läuft im Startpfad, **vor** dem Aufbau der Hauptoberfläche (`OperatorProfileBootstrap`). Ein Compose-Fenster dort verschiebt die Reihenfolge von Toolkit-Initialisierungen. → Aufgabe 6. - ---- - -## Aufgabe 1: Kotlin ins Projekt - -**Dateien:** -- Ändern: `gradle/libs.versions.toml` -- Ändern: `app-desktop/build.gradle.kts` -- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/BuildInfo.kt` -- Test: `app-desktop/src/test/kotlin/kst4contest/view/BuildInfoTest.kt` - -**Schnittstellen:** -- Erzeugt: das Quellverzeichnis `app-desktop/src/main/kotlin`, von allen folgenden Aufgaben benutzt. - -- [ ] **Schritt 1: Aktuelle Versionen prüfen** - -Nachsehen, welche Fassungen aktuell sind: -- Kotlin (Stand 2026-09-27: **2.2.0** neueste stabile) -- Compose Multiplatform (Stand 2026-09-27: **1.8.2** neueste stabile) - -**Kotlin darf hier nicht beliebig alt sein:** das Kotlin-Gradle-Plugin unterstützt Gradle 9 erst ab 2.2. Das Projekt läuft auf Gradle 9.7.1, also ist 2.2.0 die Untergrenze, nicht nur die neueste Wahl. - -Die beiden müssen zueinander passen: jede Compose-Multiplatform-Fassung nennt in ihren Veröffentlichungshinweisen die Kotlin-Fassungen, mit denen sie arbeitet. Weicht etwas ab, die passende Kombination verwenden und die Abweichung im Ledger vermerken. - -- [ ] **Schritt 2: Versionskatalog erweitern** - -In `gradle/libs.versions.toml` unter `[versions]` ergänzen: - -```toml -kotlin = "2.2.0" -composeMultiplatform = "1.8.2" -``` - -und unter `[plugins]`: - -```toml -kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } -composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } -``` - -> Das Compose-Compiler-Plugin gehört seit Kotlin 2.0 zur Kotlin-Auslieferung und trägt deshalb die Kotlin-Version, nicht die von Compose. Das sind zwei verschiedene Versionsnummern für zwei verschiedene Dinge. - -- [ ] **Schritt 3: Kotlin in `app-desktop` einschalten** - -In `app-desktop/build.gradle.kts` den `plugins`-Block erweitern: - -```kotlin -plugins { - java - application - alias(libs.plugins.javafx) - alias(libs.plugins.kotlinJvm) -} -``` - -und nach dem `javafx`-Block ergänzen: - -```kotlin -kotlin { - jvmToolchain(21) -} -``` - -- [ ] **Schritt 4: Eine Kotlin-Datei mit echtem Inhalt schreiben** - -Kein leeres Gerüst: diese Datei soll etwas tun, das die Oberfläche später braucht. - -`app-desktop/src/main/kotlin/kst4contest/view/BuildInfo.kt`: - -```kotlin -package kst4contest.view - -/** - * Build identity shown in window titles and in the about text. - * - * Reads the version the Gradle build stamped into the jar manifest and falls - * back to a marker that is obviously not a release, so an unstamped build can - * never be mistaken for one. - */ -object BuildInfo { - - const val UNKNOWN_VERSION: String = "dev-unstamped" - - val version: String - get() = BuildInfo::class.java.`package`?.implementationVersion ?: UNKNOWN_VERSION - - fun windowTitle(base: String): String = "$base $version" -} -``` - -- [ ] **Schritt 5: Den fehlschlagenden Test schreiben** - -`app-desktop/src/test/kotlin/kst4contest/view/BuildInfoTest.kt`: - -```kotlin -package kst4contest.view - -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue - -class BuildInfoTest { - - @Test - fun `an unstamped build reports a version that cannot be mistaken for a release`() { - // Tests run from class directories, where no jar manifest exists. - assertEquals(BuildInfo.UNKNOWN_VERSION, BuildInfo.version) - assertFalse(BuildInfo.version.first().isDigit(), - "an unstamped version must not look like a release number") - } - - @Test - fun `the window title carries the version`() { - val title = BuildInfo.windowTitle("KST4Contest") - assertTrue(title.startsWith("KST4Contest ")) - assertTrue(title.endsWith(BuildInfo.version)) - } -} -``` - -- [ ] **Schritt 6: Test laufen lassen und Fehlschlag sehen** - -Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.BuildInfoTest'` -Erwartet: FAIL — Kotlin-Testquellen werden noch nicht übersetzt, oder `BuildInfo` fehlt. - -- [ ] **Schritt 7: Kotlin-Testquellen einschalten** - -Das Kotlin-Plugin richtet `src/test/kotlin` selbst ein. Schlägt der Lauf weiterhin fehl, prüfen: - -Ausführen: `./gradlew :app-desktop:dependencies --configuration testCompileClasspath | grep -i kotlin` -Erwartet: `kotlin-stdlib` ist enthalten. - -- [ ] **Schritt 8: Test laufen lassen** - -Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.BuildInfoTest'` -Erwartet: PASS, 2 Testfälle. - -- [ ] **Schritt 9: Gesamtlauf** - -Ausführen: `./gradlew clean build` -Erwartet: BUILD SUCCESSFUL. Vorher sind es **255** Testfälle, jetzt **257**, 0 Fehler. - -Ausführen: `grep -rn "javafx" core/src | wc -l` -Erwartet: `0` — Kotlin darf `core` nicht berühren. - -- [ ] **Schritt 10: Commit** - -```bash -git add -A -git commit -m "Compile Kotlin in the desktop module" -``` - ---- - -## Aufgabe 2: Compose-Plugin, nur Übersetzung - -Noch kein Fenster, noch kein Packaging. Nur: Compose-Code übersetzt. - -**Dateien:** -- Ändern: `app-desktop/build.gradle.kts` -- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt` - -**Schnittstellen:** -- Erzeugt: `kst4contest.view.compose.Kst4ContestTheme`, von Aufgabe 4 und von Plan 3b benutzt. - -- [ ] **Schritt 1: Plugins ergänzen** - -```kotlin -plugins { - java - application - alias(libs.plugins.javafx) - alias(libs.plugins.kotlinJvm) - alias(libs.plugins.composeCompiler) - alias(libs.plugins.composeMultiplatform) -} -``` - -und im `dependencies`-Block: - -```kotlin - implementation(compose.desktop.currentOs) - implementation(compose.material3) -``` - -- [ ] **Schritt 1b: Googles Maven-Repository ergänzen** - -Compose Multiplatform holt `androidx.lifecycle` und `androidx.annotation` aus Googles Repository; auf Maven Central liegen sie nicht. Ohne diesen Eintrag scheitert schon das Auflösen des Klassenpfads mit `Could not find androidx.lifecycle:lifecycle-common`. - -In `build.gradle.kts` im `repositories`-Block von `allprojects` ergänzen: - -```kotlin - google() -``` - -- [ ] **Schritt 2: Ein Thema schreiben, das die Vorgaben des Projekts aufnimmt** - -`app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt`: - -```kotlin -package kst4contest.view.compose - -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable - -/** - * The application theme for Compose windows. - * - * Release 1.50 gave the JavaFX side one base font size per operator profile. - * Compose windows take the same value so a Compose window next to a JavaFX one - * does not look scaled differently. - * - * @param darkMode the operator's configured day/evening design - * @param baseFontSizeSp the configured base font size, in scale-independent points - */ -@Composable -fun Kst4ContestTheme( - darkMode: Boolean, - baseFontSizeSp: Float, - content: @Composable () -> Unit, -) { - val colors = if (darkMode) darkColorScheme() else lightColorScheme() - val typography = MaterialTheme.typography.scaledTo(baseFontSizeSp) - - MaterialTheme(colorScheme = colors, typography = typography, content = content) -} -``` - -Die Hilfsfunktion `scaledTo` wird in Schritt 3 geschrieben. - -- [ ] **Schritt 3: Die Schriftskalierung schreiben, mit Test** - -Zuerst der Test, `app-desktop/src/test/kotlin/kst4contest/view/compose/TypographyScalingTest.kt`: - -```kotlin -package kst4contest.view.compose - -import androidx.compose.material3.Typography -import androidx.compose.ui.unit.sp -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.assertEquals - -class TypographyScalingTest { - - @Test - fun `the base size replaces the body size and the rest keeps its ratio`() { - val original = Typography() - val originalBody = original.bodyMedium.fontSize.value - val originalTitle = original.titleMedium.fontSize.value - - val scaled = original.scaledTo(originalBody * 2f) - - assertEquals(originalBody * 2f, scaled.bodyMedium.fontSize.value, 0.01f) - assertEquals(originalTitle * 2f, scaled.titleMedium.fontSize.value, 0.01f, - "every style scales by the same factor, so the design keeps its proportions") - } - - @Test - fun `a base size of zero or less is ignored`() { - val original = Typography() - - assertEquals(original.bodyMedium.fontSize.value, - original.scaledTo(0f).bodyMedium.fontSize.value, 0.01f) - assertEquals(original.bodyMedium.fontSize.value, - original.scaledTo(-5f).bodyMedium.fontSize.value, 0.01f) - } -} -``` - -Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.TypographyScalingTest'` -Erwartet: FAIL — `scaledTo` existiert nicht. - -Dann die Umsetzung, an `Theme.kt` angefügt: - -```kotlin -/** - * Scales every text style by the factor that turns the body size into - * [targetBodySizeSp]. A value of zero or less is ignored, so a missing or - * malformed configured size keeps the default design rather than collapsing it. - */ -internal fun Typography.scaledTo(targetBodySizeSp: Float): Typography { - if (targetBodySizeSp <= 0f) { - return this - } - val factor = targetBodySizeSp / bodyMedium.fontSize.value - if (factor == 1f) { - return this - } - fun TextStyle.scaled() = copy(fontSize = fontSize.value.times(factor).sp) - return copy( - displayLarge = displayLarge.scaled(), displayMedium = displayMedium.scaled(), - displaySmall = displaySmall.scaled(), headlineLarge = headlineLarge.scaled(), - headlineMedium = headlineMedium.scaled(), headlineSmall = headlineSmall.scaled(), - titleLarge = titleLarge.scaled(), titleMedium = titleMedium.scaled(), - titleSmall = titleSmall.scaled(), bodyLarge = bodyLarge.scaled(), - bodyMedium = bodyMedium.scaled(), bodySmall = bodySmall.scaled(), - labelLarge = labelLarge.scaled(), labelMedium = labelMedium.scaled(), - labelSmall = labelSmall.scaled(), - ) -} -``` - -Die Importe `androidx.compose.material3.Typography`, `androidx.compose.ui.text.TextStyle` und `androidx.compose.ui.unit.sp` ergänzen. - -- [ ] **Schritt 4: Test laufen lassen** - -Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.TypographyScalingTest'` -Erwartet: PASS, 2 Testfälle. - -- [ ] **Schritt 5: Gesamtlauf** - -Ausführen: `./gradlew clean build` -Erwartet: BUILD SUCCESSFUL, **259** Testfälle, 0 Fehler. - -- [ ] **Schritt 6: Größenzuwachs festhalten** - -Ausführen: `./gradlew :app-desktop:collectRuntime && du -sh app-desktop/build/dist-libs` - -Den Wert im Ledger notieren. Vor Compose liegt er bei etwa 60 MB; Skiko bringt plattformabhängige Bibliotheken mit. Aufgabe 5 vergleicht die Paketgröße dagegen. - -- [ ] **Schritt 7: Commit** - -```bash -git add -A -git commit -m "Compile Compose in the desktop module and carry the configured font size into it" -``` - ---- - -## Aufgabe 3: Machbarkeitsprobe — Compose-Fenster neben JavaFX - -Deckt Prüfschwerpunkt 1 ab. **Diese Aufgabe entscheidet, ob die Etappenfolge 3b bis 5 tragfähig ist.** - -**Dateien:** -- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/CoexistenceProbe.kt` — **Wegwerfcode**, wird in Schritt 6 gelöscht - -**Schnittstellen:** -- Erzeugt: nichts Dauerhaftes. Das Ergebnis ist eine Antwort, kein Code. - -Compose Desktop zeichnet über Skiko in ein AWT-Fenster und betreibt einen eigenen Ereignis-Thread. JavaFX betreibt seinen. Beide in einem Prozess zu haben, ist nicht dasselbe wie beide einzeln zu haben. - -- [ ] **Schritt 1: Die Probe schreiben** - -`app-desktop/src/main/kotlin/kst4contest/view/compose/CoexistenceProbe.kt`: - -```kotlin -package kst4contest.view.compose - -import androidx.compose.material3.Text -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application -import javafx.application.Platform - -/** - * THROWAWAY. Answers one question: can a Compose window open while a JavaFX - * toolkit is running in the same process, with both staying responsive? - * - * Deleted once the answer is recorded. - */ -object CoexistenceProbe { - - @JvmStatic - fun main(args: Array) { - Platform.startup { - println("[probe] JavaFX toolkit started on " + Thread.currentThread().name) - } - - // A heartbeat on the JavaFX thread. If Compose blocks it, this stops. - val fxTicks = java.util.concurrent.atomic.AtomicInteger() - val timer = java.util.Timer(true) - timer.scheduleAtFixedRate(object : java.util.TimerTask() { - override fun run() { - Platform.runLater { fxTicks.incrementAndGet() } - } - }, 0L, 250L) - - Thread { - Thread.sleep(8000) - println("[probe] fx ticks after 8 s: " + fxTicks.get() + " (expect roughly 32)") - println("[probe] compose window still up: see whether it repaints") - }.start() - - application { - Window(onCloseRequest = ::exitApplication, title = "Coexistence probe") { - Text("If this window paints and the tick count keeps rising, both toolkits live.") - } - } - println("[probe] compose application() returned; fx ticks: " + fxTicks.get()) - } -} -``` - -- [ ] **Schritt 2: Die Probe laufen lassen** - -Ausführen: -```bash -./gradlew :app-desktop:collectRuntime -q -java -cp "app-desktop/build/dist-libs/*" kst4contest.view.compose.CoexistenceProbe 2>&1 | tee /tmp/probe.log -``` - -Erwartet: Ein Fenster erscheint mit dem Text. Nach acht Sekunden erscheint `fx ticks after 8 s:` mit einem Wert **um 32**. - -Bewertung: -- **Fenster erscheint und Ticks steigen** → beide Toolkits koexistieren. Weiter mit Schritt 3. -- **Fenster erscheint, Ticks bleiben bei 0 oder stehen** → Compose blockiert den JavaFX-Thread. Weiter mit Schritt 4. -- **Kein Fenster, Ausnahme oder Absturz** → Weiter mit Schritt 4. - -- [ ] **Schritt 3: Die umgekehrte Richtung prüfen** - -Der eigentliche Fall ist nicht „Compose startet JavaFX", sondern „die laufende JavaFX-Anwendung öffnet ein Compose-Fenster". Die Probe umbauen: `Platform.startup` behalten, eine JavaFX-`Stage` zeigen, und **aus einem JavaFX-Knopfdruck heraus** das Compose-Fenster öffnen. - -`application { }` blockiert den aufrufenden Thread bis zum Schließen. Aus dem JavaFX-Thread darf das deshalb **nicht** direkt aufgerufen werden. Zu prüfen ist, ob ein eigener Thread mit `application { }` funktioniert, oder ob die `ComposeWindow`-Schnittstelle ohne `application { }` der richtige Weg ist. - -Erwartet: Nach dem Knopfdruck erscheint das Compose-Fenster, das JavaFX-Fenster bleibt bedienbar — Knopf drückbar, Fenster verschiebbar —, und die Tickzahl steigt weiter. - -Das Ergebnis samt des benutzten Wegs (`application { }` im eigenen Thread, oder `ComposeWindow` direkt) im Ledger festhalten. **Plan 3b baut darauf auf.** - -- [ ] **Schritt 4: Wenn die Probe scheitert** - -Dann endet dieser Plan hier. Im Ledger festhalten, was genau passiert ist, und berichten: - -> Die Etappenfolge im Spec setzt voraus, dass Fenster einzeln umgestellt werden können. Wenn Compose und JavaFX nicht koexistieren, ist das nicht möglich, und es bleibt nur ein Umschalttag, an dem die gesamte Oberfläche wechselt. Das ist eine Änderung am Spec, nicht am Plan, und gehört vor jeder weiteren Arbeit entschieden. - -Aufgabe 4 bis 7 **nicht** ausführen. - -- [ ] **Schritt 5: Das Ergebnis festhalten** - -Ins Ledger, wörtlich messbar: erscheint das Fenster, bleibt JavaFX bedienbar, wie viele Ticks nach acht Sekunden, welcher Weg funktioniert hat. - -- [ ] **Schritt 6: Die Probe löschen** - -```bash -git rm app-desktop/src/main/kotlin/kst4contest/view/compose/CoexistenceProbe.kt -``` - -Eine Probe ist eine Antwort, kein Bestandteil. Die Antwort steht im Ledger. - -- [ ] **Schritt 7: Commit** - -```bash -git add -A -git commit -m "Record the Compose/JavaFX coexistence probe result" -``` - ---- - -## Aufgabe 4: Der Profil-Auswahldialog in Compose - -**Dateien:** -- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt` -- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` (`:6546` als Methodenverweis `OperatorProfilePickerDialog::showAndSelect`, `:6612` als direkter Aufruf) -- Löschen: `app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java` (145 Zeilen) -- Test: `app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerStateTest.kt` - -**Schnittstellen:** -- Konsumiert: `Kst4ContestTheme` aus Aufgabe 2, das Ergebnis von Aufgabe 3. -- Erzeugt: `OperatorProfilePickerWindow.showAndSelect(profiles, preselectedId)` mit **derselben Signatur und demselben Rückgabetyp** wie `OperatorProfilePickerDialog.showAndSelect`. Der Name bleibt, weil `Kst4ContestApplication:6546` ihn als Methodenverweis `::showAndSelect` benutzt — eine abweichende Signatur bricht dort nicht sichtbar, sondern erst beim Übersetzen an einer unerwarteten Stelle. - -Dieses Fenster ist mit Bedacht gewählt: 145 Zeilen, in sich abgeschlossen, und es läuft **vor** dem Aufbau der Hauptoberfläche. Es ist damit der einzige Ort, an dem ein Compose-Fenster ohne gleichzeitig offene JavaFX-Fenster steht — der einfachste mögliche Anfang. - -- [ ] **Schritt 1: Den bisherigen Vertrag ablesen** - -Ausführen: -```bash -sed -n '1,60p' app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java -grep -rn "OperatorProfilePickerDialog" app-desktop/src core/src -``` - -Notieren: Signatur, Rückgabewert bei Abbruch, und wer den Dialog aufruft. Der neue Weg muss **denselben** Vertrag erfüllen, insbesondere bei Abbruch: laut `PROJECT_CONTEXT.md` fragt ein Start mit keinem oder genau einem Profil gar nichts und schreibt nichts. - -- [ ] **Schritt 2: Den Zustand von der Darstellung trennen, mit Test** - -Die Auswahllogik gehört in eine testbare Klasse ohne Compose-Bezug. Zuerst der Test: - -`app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerStateTest.kt`: - -```kotlin -package kst4contest.view.compose - -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue - -class OperatorProfilePickerStateTest { - - @Test - fun `the preselected profile is selected when it is in the list`() { - val state = OperatorProfilePickerState(listOf("root", "contest"), preselectedId = "contest") - assertEquals("contest", state.selectedId) - assertTrue(state.canConfirm) - } - - @Test - fun `an unknown preselection falls back to the first profile`() { - val state = OperatorProfilePickerState(listOf("root", "contest"), preselectedId = "gone") - assertEquals("root", state.selectedId, - "a profile that disappeared must not leave the dialog unusable") - } - - @Test - fun `an empty list cannot be confirmed`() { - val state = OperatorProfilePickerState(emptyList(), preselectedId = null) - assertNull(state.selectedId) - assertFalse(state.canConfirm) - } - - @Test - fun `selecting changes the selection`() { - val state = OperatorProfilePickerState(listOf("root", "contest"), preselectedId = "root") - state.select("contest") - assertEquals("contest", state.selectedId) - } - - @Test - fun `selecting something absent is ignored`() { - val state = OperatorProfilePickerState(listOf("root"), preselectedId = "root") - state.select("nope") - assertEquals("root", state.selectedId, "an unknown id must not clear the selection") - } -} -``` - -Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.OperatorProfilePickerStateTest'` -Erwartet: FAIL — `OperatorProfilePickerState` existiert nicht. - -- [ ] **Schritt 3: Den Zustand schreiben** - -In `OperatorProfilePickerWindow.kt`: - -```kotlin -package kst4contest.view.compose - -/** - * Selection state of the operator profile picker, free of Compose so the rules - * are testable without a toolkit. - */ -class OperatorProfilePickerState( - val profileIds: List, - preselectedId: String?, -) { - var selectedId: String? = preselectedId.takeIf { profileIds.contains(it) } ?: profileIds.firstOrNull() - private set - - val canConfirm: Boolean - get() = selectedId != null - - /** Ignores an id that is not in the list: a vanished profile must not clear the selection. */ - fun select(id: String) { - if (profileIds.contains(id)) { - selectedId = id - } - } -} -``` - -- [ ] **Schritt 4: Test laufen lassen** - -Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.OperatorProfilePickerStateTest'` -Erwartet: PASS, 5 Testfälle. - -- [ ] **Schritt 5: Das Fenster schreiben** - -Den in Aufgabe 3 Schritt 3 ermittelten Weg benutzen. Der Aufruf ist blockierend und liefert die Wahl oder `null` bei Abbruch — dieselbe Form wie bisher, damit `OperatorProfileChoiceRequester` sich nicht ändert. - -Das Fenster nutzt `Kst4ContestTheme` mit dem konfigurierten Dunkelmodus und der Basis-Schriftgröße. Woher beide zu diesem frühen Zeitpunkt kommen, ist zu klären: der Dialog läuft, bevor ein Profil gewählt ist, also bevor dessen `preferences.xml` gelesen wurde. Zu benutzen sind die Werte des Wurzelprofils; ist auch das nicht lesbar, die Vorgaben. Diese Entscheidung als `Ruling:` festhalten. - -- [ ] **Schritt 6: Den Aufrufer umstellen und den alten Dialog löschen** - -```bash -git rm app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java -``` - -Beide Aufrufstellen in `Kst4ContestApplication` umstellen: den Methodenverweis `:6546` und den direkten Aufruf `:6612`. Bleibt die Signatur gleich, genügt der Austausch des Klassennamens. - -Ausführen: `grep -rn "OperatorProfilePickerDialog" app-desktop/src core/src` -Erwartet: keine Ausgabe. - -- [ ] **Schritt 7: Übersetzen und testen** - -Ausführen: `./gradlew clean build` -Erwartet: BUILD SUCCESSFUL, **264** Testfälle, 0 Fehler. - -- [ ] **Schritt 8: Den Dialog wirklich sehen** - -Der Dialog erscheint nur, wenn mehr als ein Operator-Profil vorhanden ist. Ein zweites anlegen, falls nötig — im laufenden Programm über **Einstellungen → Profiles**, damit `profiles.xml` auf dem vorgesehenen Weg entsteht und nicht von Hand. - -Dann: `./gradlew :app-desktop:run` - -Erwartet: Das Compose-Fenster erscheint, listet beide Profile, die Vorauswahl steht auf dem zuletzt benutzten. Auswählen und bestätigen startet die Anwendung mit diesem Profil. Abbrechen verhält sich wie zuvor. - -- [ ] **Schritt 9: Commit** - -```bash -git add -A -git commit -m "Pick the operator profile in a Compose window" -``` - ---- - -## Aufgabe 5: Packaging auf `compose.desktop.nativeDistributions` - -Deckt Prüfschwerpunkt 3 ab. - -**Dateien:** -- Ändern: `app-desktop/build.gradle.kts` -- Ändern: `gradle.properties` -- Ändern: `.github/workflows/nightly-artifacts.yml`, `.github/workflows/tagged-release.yml`, `.github/workflows/pr-compile-check.yml` -- Ändern: `packaging/macos/build-signed-dmg.sh`, `packaging/aur/kst4contest/PKGBUILD`, `packaging/aur/kst4contest-git/PKGBUILD` - -**Schnittstellen:** -- Konsumiert: nichts. -- Erzeugt: die Gradle-Aufgaben des Compose-Plugins (`packageDistributionForCurrentOS`, `createDistributable`) an der Stelle von `packageImage`. - -Etappe 1 hat die Modulliste in `gradle.properties` unter `jpackageAddModules` hinterlegt, weil `AddModules.java` entfiel. Zwei Einträge darin wurden **teuer erkauft**: `jdk.jsobject` für die Karten-Brücke und `jdk.unsupported`, ohne das der Marlin-Renderer an `sun/misc/Unsafe` stirbt. Das Compose-Plugin ermittelt Module selbst — deshalb ist die erste Frage dieser Aufgabe, ob beide noch enthalten sind. - -- [ ] **Schritt 1: Die heutigen Werte festhalten** - -Ausführen: -```bash -grep -n "^jpackageAddModules" gradle.properties -./gradlew :app-desktop:packageImage -q -du -sh app-desktop/build/jpackage/praktiKST -grep -o "MODULES=.*" app-desktop/build/jpackage/praktiKST/lib/runtime/release | tr ' ' '\n' | sort > /tmp/module-liste-vorher.txt -wc -l < /tmp/module-liste-vorher.txt -``` - -Paketgröße und Modulzahl im Ledger notieren. Sie sind der Vergleichsmaßstab. - -- [ ] **Schritt 2: `nativeDistributions` einrichten** - -An `app-desktop/build.gradle.kts` anfügen: - -```kotlin -compose.desktop { - application { - mainClass = "kst4contest.view.Main" - - nativeDistributions { - targetFormats( - org.jetbrains.compose.desktop.application.dsl.TargetFormat.AppImage, - org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb, - org.jetbrains.compose.desktop.application.dsl.TargetFormat.Rpm, - org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi, - org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg, - ) - packageName = "praktiKST" - packageVersion = providers.gradleProperty("composePackageVersion").get() - - /* - * Kept from the former module-info.java, minus the javafx.* modules. - * jdk.jsobject carries netscape.javascript for the map bridge, and - * jdk.unsupported carries sun.misc.Unsafe, without which the JavaFX - * Marlin renderer fails to start. Neither is inferred: JavaFX comes - * from the classpath, so nothing declares them. - */ - modules( - "java.desktop", "java.net.http", "java.sql", - "jdk.crypto.ec", "jdk.jsobject", "jdk.net", - "jdk.xml.dom", "jdk.unsupported", - ) - - linux { iconFile.set(rootProject.file("packaging/icons/kst4contest.png")) } - windows { iconFile.set(rootProject.file("packaging/icons/kst4contest.ico")) } - macOS { - iconFile.set(rootProject.file("packaging/icons/kst4contest.icns")) - bundleID = "de.x08.KST4Contest" - } - } - } -} -``` - -> `mainClass` ist `kst4contest.view.Main`, nicht `Kst4ContestApplication`. Der Grund steht in Etappe 1: eine `Application`-Unterklasse startet vom Klassenpfad nicht. - -> `bundleID` muss `de.x08.KST4Contest` bleiben — `packaging/macos/build-signed-dmg.sh` signiert dagegen, und ein geänderter Wert bricht die Signatur. - -- [ ] **Schritt 3: Die Paketversion bereitstellen** - -`nativeDistributions` verlangt eine Version nach den Regeln des Zielformats: MSI und DMG akzeptieren **kein** `1.50.0-nightly`, sondern nur `MAJOR.MINOR.PATCH` mit Zahlen. - -In `gradle.properties` ergänzen: - -```properties -# nativeDistributions requires MAJOR.MINOR.PATCH with digits only. MSI and DMG -# reject a qualifier, so the nightly suffix from `version` cannot be used here. -composePackageVersion=1.50.0 -``` - -Ausführen: `grep -n "^version=\|^composePackageVersion=" gradle.properties` -Erwartet: beide Zeilen; `version` behält den Zusatz `-nightly`, `composePackageVersion` nicht. - -- [ ] **Schritt 4: Lokal paketieren und vergleichen** - -Ausführen: -```bash -./gradlew :app-desktop:createDistributable -q -find app-desktop/build/compose/binaries -maxdepth 4 -name "praktiKST" -type d -``` - -Die Modulliste des neuen Runtime gegen die alte prüfen: -```bash -NEW=$(find app-desktop/build/compose/binaries -name release -path "*runtime*" | head -1) -grep -o "MODULES=.*" "$NEW" | tr ' ' '\n' | sort > /tmp/module-liste-nachher.txt -diff /tmp/module-liste-vorher.txt /tmp/module-liste-nachher.txt -``` - -Erwartet: `jdk.jsobject` und `jdk.unsupported` sind **enthalten**. Fehlt einer, ist die `modules(...)`-Angabe aus Schritt 2 nicht angekommen — nicht weitergehen, bis sie drin ist. - -- [ ] **Schritt 5: Das Paket starten** - -Ausführen: den Launcher unter `app-desktop/build/compose/binaries/main/app/praktiKST/bin/praktiKST` - -Erwartet: Das Fenster erscheint. Im Protokoll **kein** `NoClassDefFoundError: sun/misc/Unsafe`, **kein** Fehler zu `JSObject`. Das Kartenfenster öffnen und Kacheln sehen — das ist die Prüfung auf `jdk.jsobject`. - -- [ ] **Schritt 6: Größe vergleichen** - -Ausführen: `du -sh app-desktop/build/compose/binaries/main/app/praktiKST` - -Gegen den Wert aus Schritt 1 stellen und beide ins Ledger. Ein Zuwachs ist zu erwarten: Skiko bringt eigene Bibliotheken mit. Wächst das Paket um mehr als etwa 100 MB, im Ledger festhalten und berichten — die Download-Größe ist für Anwender im Feld ein echtes Thema. - -- [ ] **Schritt 7: Die CI umstellen** - -In den Workflows die Aufrufe von `:app-desktop:collectRuntime` plus dem folgenden `jpackage`-Block ersetzen durch die passende Compose-Aufgabe: - -| Job | Aufgabe | -|---|---| -| AppImage | `:app-desktop:createDistributable`, dann das bestehende AppDir-Verfahren auf dem neuen Pfad | -| Deb | `:app-desktop:packageDeb` | -| Rpm | `:app-desktop:packageRpm` | -| Windows ZIP | `:app-desktop:createDistributable`, dann ZIP wie bisher | -| macOS DMG | `:app-desktop:createDistributable`, danach `build-signed-dmg.sh` | -| Arch, Flatpak | `:app-desktop:createDistributable` | - -Der Ausgabepfad wechselt von `app-desktop/build/jpackage/praktiKST` auf `app-desktop/build/compose/binaries/main/app/praktiKST`. Vor dem Bearbeiten den echten Pfad aus Schritt 4 ablesen und nicht aus diesem Plan übernehmen — er hängt von der Plugin-Fassung ab. - -`--add-modules`-Argumente und die `ADD_MODULES`-Zeilen entfallen in der CI: die Modulliste steht jetzt in `build.gradle.kts`. `jpackageAddModules` in `gradle.properties` wird gelöscht, **nachdem** alle Verwendungen weg sind. - -Ausführen: `grep -rn "jpackageAddModules\|collectRuntime\|--add-modules" .github/ packaging/` -Erwartet: keine Ausgabe. - -- [ ] **Schritt 8: Die Linux-Jobs lokal prüfen** - -Wie in Etappe 1, mit `act`: - -```bash -act -j build-linux-appimage -W .github/workflows/nightly-artifacts.yml -act -j build-linux-deb -W .github/workflows/nightly-artifacts.yml -act -j build-linux-rpm -W .github/workflows/nightly-artifacts.yml -act -j build-linux-arch -W .github/workflows/nightly-artifacts.yml -act --privileged --pull=false -j build-flatpak -W .github/workflows/nightly-artifacts.yml -``` - -Erwartet: Alle Bauschritte erfolgreich. Der jeweils letzte Schritt — Artefakt-Upload beziehungsweise Flatpak-Signierschlüssel — scheitert an `act`, nicht am Build; das war in Etappe 1 genauso. - -- [ ] **Schritt 9: Windows und macOS** - -Beide sind lokal nur eingeschränkt prüfbar. Der Weg aus Etappe 1 gilt weiter: WinBoat für Windows, ein erreichbarer Mac für macOS. Die Modulprüfung aus Schritt 4 und der Start aus Schritt 5 sind auf **jeder** Plattform zu wiederholen — `jdk.unsupported` fiel in Etappe 1 erst beim Start auf, nicht beim Bauen. - -Die **MSI-Strecke mit WiX** ist bis heute nie gelaufen. `packageMsi` ist neu und hier der Punkt mit dem größten Restrisiko. - -- [ ] **Schritt 10: Commit** - -```bash -git add -A -git commit -m "Package through compose.desktop.nativeDistributions" -``` - ---- - -## Aufgabe 6: Lebensdauer, Beenden und Erscheinungsbild - -Deckt die Prüfschwerpunkte 2, 4 und 5 ab. - -**Dateien:** -- Ändern: `app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java`, falls nötig -- Ändern: `app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt`, falls nötig - -- [ ] **Schritt 1: Beenden nach dem Profil-Dialog prüfen** - -`Platform.setImplicitExit(false)` hält den Prozess am Leben, wenn alle JavaFX-Fenster zu sind; alle Ausgänge laufen über `ApplicationRuntimeLauncher.exitApplication()`. Ein AWT-Fenster von Compose bringt eine zweite, unabhängige Ursache für einen laufenden Prozess mit. - -Ausführen: Anwendung starten, Profil wählen, Anwendung über das Fenster-Kreuz schließen. - -```bash -pgrep -f Kst4ContestApplication || echo "Prozess beendet" -``` -Erwartet: `Prozess beendet`. - -Dasselbe für den Abbruch im Profil-Dialog: Dialog abbrechen, dann prüfen. -Erwartet: `Prozess beendet`. - -Bleibt der Prozess stehen, ist es ein Nicht-Daemon-Thread von AWT. Im Ledger festhalten, welcher — `jstack` zeigt es — und den Compose-Fensterweg so ändern, dass er beim Schließen aufräumt. - -- [ ] **Schritt 2: Profilwechsel im laufenden Betrieb** - -Ausführen: Anwendung starten, **Einstellungen → Profiles**, auf ein anderes Profil wechseln. - -Erwartet: Der Wechsel funktioniert wie vor dieser Etappe. Die Stationsliste des neuen Profils füllt sich, keine doppelten Listener, der Prozess läuft weiter. Laut `PROJECT_CONTEXT.md` reißt ein Wechsel die Laufzeit ab und baut eine neue `Kst4ContestApplication`; das Compose-Fenster des Dialogs darf davon nicht betroffen sein, weil es zu diesem Zeitpunkt längst geschlossen ist. - -- [ ] **Schritt 3: Erscheinungsbild vergleichen** - -Zwei Profile mit **unterschiedlicher** Basis-Schriftgröße anlegen, dann jeweils starten. - -Erwartet: Die Schrift im Compose-Dialog folgt der Größe des Wurzelprofils (die Entscheidung aus Aufgabe 4 Schritt 5) und wirkt neben den JavaFX-Fenstern nicht auffällig größer oder kleiner. Der Dunkelmodus stimmt mit der Vorgabe überein. - -Weicht es sichtbar ab, ist das ein Befund für Plan 3b — dort entsteht das Einstellungsfenster, das die Werte kennt. Im Ledger festhalten, nicht hier reparieren. - -- [ ] **Schritt 4: Commit** - -```bash -git add -A -git commit -m "Keep process lifetime and appearance intact with a Compose window in the mix" -``` - ---- - -## Abnahme der Etappe 3a - -- [ ] `./gradlew clean build` läuft durch, mindestens **264** Testfälle, 0 Fehler. -- [ ] `grep -rn "javafx" core/src` liefert keine Ausgabe — `core` blieb unberührt. -- [ ] Das Ergebnis der Koexistenzprobe steht im Ledger, mit dem benutzten Weg. -- [ ] Der Profil-Auswahldialog ist in Compose, `OperatorProfilePickerDialog.java` existiert nicht mehr. -- [ ] `createDistributable` erzeugt ein startfähiges Paket, dessen Runtime `jdk.jsobject` und `jdk.unsupported` enthält. -- [ ] Das Kartenfenster zeigt im gepackten Stand Kacheln — die Prüfung auf `jdk.jsobject`. -- [ ] Alle fünf Linux-Jobs laufen unter `act` bis zum Upload-Schritt durch. -- [ ] Der Prozess endet nach dem Schließen und nach einem Abbruch im Profil-Dialog. -- [ ] Der Profilwechsel im laufenden Betrieb funktioniert unverändert. -- [ ] Paketgröße vorher und nachher stehen im Ledger. - -## Was danach kommt - -**Plan 3b: `DataTable` und Einstellungsfenster.** Er wird geschrieben, wenn dieser Plan durch ist — und zwar aus zwei Gründen erst dann. Der Zuschnitt der `DataTable` hängt am Ergebnis der Koexistenzprobe, und die Erfahrung aus Etappe 2 gehört hinein: - -- Ein Spiegel, der die ganze Liste ersetzt, kostet Auswahl und Scrollposition. In Compose ist der Hebel dagegen ein **stabiler Schlüssel** je Zeile, nicht ein Gleichheitsvergleich. -- Sortierung wird aus der kanonischen Liste abgeleitet, nie aus dem vorher Angezeigten. -- Fokus ist ausdrücklich zu führen. Zwei der drei Fehler aus Etappe 2 waren Fokusfehler, und keiner war durch Lesen zu finden. -- Ein Sammelvorgang ist **eine** Zustandsänderung, nicht eine je Element. diff --git a/docs/superpowers/plans/2026-09-27-etappe3b-datatable-einstellungen.md b/docs/superpowers/plans/2026-09-27-etappe3b-datatable-einstellungen.md deleted file mode 100644 index c949425f..00000000 --- a/docs/superpowers/plans/2026-09-27-etappe3b-datatable-einstellungen.md +++ /dev/null @@ -1,852 +0,0 @@ -# Etappe 3b — `DataTable` und Einstellungsfenster: Umsetzungsplan - -> **Für agentische Bearbeiter:** ERFORDERLICHE UNTER-SKILL: superpowers:subagent-driven-development (empfohlen) oder superpowers:executing-plans, um diesen Plan Aufgabe für Aufgabe umzusetzen. Schritte nutzen Checkbox-Syntax (`- [ ]`). - -**Ziel:** Das Einstellungsfenster mit seinen zwölf Reitern ist in Compose, funktionsgleich, und schreibt unverändert dasselbe `preferences.xml`. - -**Architektur:** Ein Compose-Fenster ersetzt `settingsStage`. Jeder Reiter bekommt eine Zustandsklasse ohne Compose-Bezug — eine **typisierte Fassade**, die jede Eigenschaft direkt aus `ChatPreferences` liest und **sofort** dorthin zurückschreibt. Eine `DataTable`-Komponente bedient die drei einspaltigen, editierbaren Listen des Fensters. - -**Tech-Stack:** Kotlin 2.2.0, Compose Multiplatform 1.8.2, Gradle 9.7.1, Java 21. - -**Spec:** `docs/superpowers/specs/2026-09-25-compose-migration-design.md` - -## Korrektur am Plan: es gibt kein Bestätigen, nur Durchschreiben - -Dieser Plan schrieb zunächst vor, jeder Reiter solle aus den Einstellungen lesen und **nur bei `apply()`** zurückschreiben, mit der Begründung, unbestätigte Werte dürften nicht in `preferences.xml` landen. **Am Code geprüft ist das falsch**, und zwar nicht im Detail, sondern im Modell: - -- **„Save settings"** (`Kst4ContestApplication:12523`) ruft ausschliesslich `writePreferencesToXmlFile()` und bricht einen anstehenden Layout-Autosave ab. Es **sammelt keine Werte ein**. -- **„Apply/Close prefs"** (`:12382`) ruft `settingsStage.hide()`. Nichts weiter. - -Jedes Bedienelement des JavaFX-Fensters schreibt also **beim Ändern sofort** in das `ChatPreferences`-Objekt im Speicher; die beiden Knöpfe schreiben nur auf die Platte beziehungsweise schliessen. Was `PROJECT_CONTEXT` verbietet, ist etwas anderes: dass **selektive Layout-Schreibvorgänge** unbestätigte funktionale Einstellungen mitpersistieren. Das betrifft den Autosave, nicht die Bedienelemente. - -**Folge:** Eine Zustandsklasse mit Puffer und `apply()` wäre eine Verhaltensänderung, auf die sich ein Operator verlassen kann — heute wirkt eine geänderte Einstellung sofort in der laufenden Sitzung, auch wenn sie nie auf die Platte kommt. Die Zustandsklassen sind deshalb **durchschreibende Fassaden ohne `apply()`**: - -```kotlin -var stn_loginCallSign: String - get() = prefs.getStn_loginCallSign() - set(value) { prefs.setStn_loginCallSign(value) } -``` - -Das macht sie zugleich einfacher: kein Puffer, kein Abgleich, keine Frage wann geschrieben wird. - -## Das zweite Netz: Schreibstellen ausserhalb der Einstellungen - -`SettingsFieldCoverageTest` prüft nur `ChatPreferences`-Setter. Eine Schreibstelle auf einem anderen Objekt fällt durch — und das JavaFX-Fenster hat **fünf** davon, mechanisch ermittelt: die Setter im Bereich, die `ChatPreferences` nicht deklariert, geschnitten mit den Settern der eigenen Projektklassen. - -| Setter | Klasse | Wofür | -|---|---|---| -| `setChatCategoryMain` | `ChatController` | bei jeder Kategorieauswahl, **zusätzlich** zum Einstellungs-Setter | -| `setChatCategorySecondChat` | `ChatController` | dito; `null` bedeutet „kein zweiter Chat" | -| `setCallSign`, `setFrequency`, `setQra` | `ChatMember` | das eigene Stationsobjekt, im Gleichschritt mit den Stationsfeldern | - -Fällt der `ChatController`-Teil weg, bleibt die laufende Sitzung auf der alten Kategorie, während die gespeicherte Einstellung etwas anderes sagt — und **kein Test des ersten Netzes würde das merken**. `SettingsForeignWriteCoverageTest` deckt es ab. - -Weil eine Zustandsklasse dafür einen `ChatController` bräuchte und damit im Test eine Datenbank öffnen würde, nimmt sie stattdessen **Rückrufe** entgegen: - -```kotlin -class StationTabState( - private val prefs: ChatPreferences, - private val applyMainCategory: (ChatCategory?) -> Unit, - private val applySecondCategory: (ChatCategory?) -> Unit, -) -``` - -## Korrektur am Spec: die `DataTable` wird hier nicht validiert - -Das Spec nennt die `DataTable` „den wichtigsten einzelnen Baustein" und erwartet, dass das Einstellungsfenster sie belastet: *„Wird diese Komponente gut, ist der Rest Fleißarbeit."* Gemessen am Code trägt das nicht. - -Die sieben Tabellen mit gespeicherten Spaltenbreiten sitzen **alle** in den Hauptfenstern: - -| Tabelle | `TableLayoutManager`-ID | -|---|---| -| `tbl_chatMemberTable` | `chat-members` | -| `tbl_generalMSGTable` | `public-messages` | -| `tbl_privateMSGTable` | `private-messages` | -| `tbl_toOtherMSGTable` | — | -| `tbl_furtherInfoAbtCallsignMSGTable` | `selected-station-messages` | -| `tbl_DXCTable` | `dx-cluster-main` / `dx-cluster-monitor` | -| `tbl_chatMemberWkdDBTable` | `worked-database` | - -Die drei Tabellen im Einstellungsfenster — `tblVw_shortcuts`, `tblVw_textsnippets`, `tblVw_notify_sniffCallSigns` — sind **einspaltige, editierbare Zeichenkettenlisten** mit `TextFieldTableCell` und `setOnEditCommit`. Sie haben **keine** gespeicherten Breiten (null `install`-Aufrufe), keine Sortierung, kein Zell-Styling, keine mehrspaltige Struktur. - -**Folge für diesen Plan:** Die `DataTable` wird hier für genau das gebaut, was diese drei Listen brauchen, mit einer Schnittstelle, die die Anforderungen der Hauptfenster später aufnehmen kann, ohne umgebaut zu werden. Spaltenbreiten mit stabilen IDs, Sortierung und Zeilen-Styling entstehen in **Etappe 5**, wo ihre Verbraucher sitzen. Sie jetzt zu bauen hieße, blind zu entwerfen. - -Das ist keine Verkleinerung des Ziels, sondern eine Verschiebung an die Stelle, an der sie prüfbar ist. - -## Was aus Etappe 2 in die `DataTable` einfließt - -Vier Dinge, die dort Fehler gekostet haben und hier von Anfang an richtig gehören: - -1. **Schlüssel statt Gleichheit.** Der JavaFX-Spiegel ersetzte bei jeder Änderung die ganze Liste und kostete Auswahl und Scrollposition; dagegen half ein `equals`-Vergleich, der wiederum an `ChatMember.equals` als Überladung hing. In Compose ist der Hebel ein **stabiler Schlüssel je Zeile** (`key = { … }` in `LazyColumn`). Kein Gleichheitsvergleich auf Fachobjekten. -2. **Sortierung leitet aus der kanonischen Liste ab**, nie aus dem vorher Angezeigten. `SortedList` fügte per Binärsuche ein und liess bei Gleichstand eine undefinierte Position — die neue Regel ist Ankunftsreihenfolge, festgeschrieben in `ChatMemberListViewTest`. -3. **Fokus wird ausdrücklich geführt.** Zwei der drei Fehler aus Etappe 2 waren Fokusfehler, und keiner war durch Lesen zu finden. In Compose ist Fokus explizit (`FocusRequester`) — das ist eine Chance, keine Last. -4. **Ein Sammelvorgang ist eine Zustandsänderung**, nicht eine je Element. - -## Globale Randbedingungen - -- **`preferences.xml` bleibt bitweise gleichwertig.** Version 7, dieselben Elementnamen, dieselbe Reihenfolge, unbekannte Knoten bleiben erhalten. Das ist die härteste Vorgabe dieses Plans und der Gegenstand der Abnahme. -- **Kein Funktionsverlust.** Jede Einstellung, die das JavaFX-Fenster setzen konnte, muss das Compose-Fenster setzen können. Ein Reiter gilt erst als fertig, wenn seine Felder vollständig sind. -- `core` wird **nicht** angefasst. -- **`Platform.runLater` und `uiDispatcher.runOnUi` sind nicht dasselbe.** Der Dispatcher führt inline aus, wenn der Aufrufer schon auf dem UI-Thread ist. Siehe `prepareCqTextForCallsign`; das hat in Etappe 2 einen Fehler gekostet. -- **`application(exitProcessOnExit = false)`** bei jedem Compose-Fenster. Die Vorgabe `true` beendet den Prozess, wenn das letzte Compose-Fenster schliesst — in Etappe 3a hätte das die Anwendung nach der Profilwahl beendet. -- Kommentare und KDoc auf Englisch, Kommunikation auf Deutsch. -- Die Invarianten aus dem Spec-Abschnitt „Invarianten" gelten unverändert. - -## Prüfschwerpunkte - -1. **Eine Einstellung verschwindet still.** 2341 Zeilen mit Hunderten Feldern: ein beim Portieren vergessenes Feld fällt nicht beim Übersetzen auf, sondern wenn ein Operator im Contest merkt, dass seine Einstellung weg ist. → Aufgabe 2 und die Abnahme. -2. **`preferences.xml` verliert Knoten.** Selektives Schreiben muss unbekannte XML-Knoten erhalten; **Save Settings** bleibt der vollständige Schreiber. Ein Compose-Fenster, das die Datei neu aufbaut statt zu aktualisieren, löscht stillschweigend, was es nicht kennt. → Aufgabe 2. -3. **Eine Liste verliert Einträge beim Bearbeiten.** Die drei Listen sind editierbar; ein Bearbeitungsvorgang, der den Roster ersetzt statt den Eintrag zu ändern, wirft angemeldete Zuhörer ab. In Etappe 2 war das der Grund, Setter auf `setAll` umzustellen statt das Feld zu tauschen. → Aufgabe 1. -4. **Der Profilwechsel bricht.** Der Reiter **Profiles** löst einen Laufzeitwechsel aus, der die ganze `Kst4ContestApplication` neu baut — aus einem Compose-Fenster heraus, das dabei selbst verschwinden muss. → Aufgabe 6. -5. **Zwei Einstellungsfenster gleichzeitig.** Solange das JavaFX-Fenster noch existiert, können beide offen sein und gegeneinander schreiben. → Aufgabe 7. - ---- - -## Aufgabe 1: `DataTable` für editierbare einspaltige Listen - -Deckt Prüfschwerpunkt 3 ab. - -**Dateien:** -- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt` -- Test: `app-desktop/src/test/kotlin/kst4contest/view/compose/EditableListStateTest.kt` - -**Schnittstellen:** -- Erzeugt: `EditableListState` mit `entries: List`, `selectedIndex: Int?`, `addAtTop(String)`, `updateAt(Int, String)`, `removeAt(Int)`, `select(Int?)`, `commitTo(SimpleRoster)`; und die Composable `DataTable(state, onCommit, modifier)`. Konsumiert von den Aufgaben 4 und 5. - -Die drei Listen des Einstellungsfensters verhalten sich heute so: **Add** legt einen Platzhaltertext an **Position 0** ein, wählt ihn aus und öffnet die Bearbeitung; ein Eintrag, dessen Text vollständig geleert wird, **verschwindet**. Beides ist in `Kst4ContestApplication` beim Knopf „Add shortcut" und im `setOnEditCommit` nachzulesen und muss erhalten bleiben. - -- [ ] **Schritt 1: Das heutige Verhalten ablesen** - -Ausführen: -```bash -grep -n -A24 "private TableView initShortcutTable" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -grep -n -B2 -A12 "btn_Short_addLine.setOnAction" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Notieren: was `setOnEditCommit` mit einem leeren Text tut, wo ein neuer Eintrag landet, und was mit der Auswahl passiert. Der Test im nächsten Schritt schreibt **dieses** Verhalten fest, nicht ein besseres. - -- [ ] **Schritt 2: Den fehlschlagenden Test schreiben** - -`app-desktop/src/test/kotlin/kst4contest/view/compose/EditableListStateTest.kt`: - -```kotlin -package kst4contest.view.compose - -import kst4contest.observe.SimpleRoster -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertSame - -class EditableListStateTest { - - @Test - fun `a new entry lands at the top and becomes the selection`() { - val state = EditableListState(listOf("alt")) - - state.addAtTop("neu") - - assertEquals(listOf("neu", "alt"), state.entries) - assertEquals(0, state.selectedIndex, - "the JavaFX table selected row 0 and opened it for editing") - } - - @Test - fun `an entry edited to blank disappears`() { - val state = EditableListState(listOf("a", "b", "c")) - - state.updateAt(1, " ") - - assertEquals(listOf("a", "c"), state.entries, - "clearing the text was how the operator removed an entry") - } - - @Test - fun `an edit keeps the other entries and their order`() { - val state = EditableListState(listOf("a", "b", "c")) - - state.updateAt(1, "B") - - assertEquals(listOf("a", "B", "c"), state.entries) - } - - @Test - fun `removing the last entry clears the selection`() { - val state = EditableListState(listOf("a")) - - state.select(0) - state.removeAt(0) - - assertEquals(emptyList(), state.entries) - assertNull(state.selectedIndex) - } - - @Test - fun `an index outside the list is ignored`() { - val state = EditableListState(listOf("a")) - - state.updateAt(5, "x") - state.removeAt(-1) - - assertEquals(listOf("a"), state.entries, - "a stale index from a pending edit must not corrupt the list") - } - - @Test - fun `committing fills the existing roster instead of replacing it`() { - val roster = SimpleRoster() - roster.add("alt") - val before = roster - val state = EditableListState(listOf("neu")) - - state.commitTo(roster) - - assertEquals(listOf("neu"), roster.snapshot()) - assertSame(before, roster, - "replacing the roster would orphan every listener registered on it") - } - - @Test - fun `committing an unchanged list still yields the same content`() { - val roster = SimpleRoster() - roster.addAll(listOf("a", "b")) - val state = EditableListState(roster.snapshot()) - - state.commitTo(roster) - - assertEquals(listOf("a", "b"), roster.snapshot()) - } -} -``` - -- [ ] **Schritt 3: Test laufen lassen und Fehlschlag sehen** - -Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.EditableListStateTest'` -Erwartet: FAIL — `Unresolved reference 'EditableListState'`. - -- [ ] **Schritt 4: Den Zustand schreiben** - -`app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt`, erster Teil: - -```kotlin -package kst4contest.view.compose - -import kst4contest.observe.SimpleRoster - -/** - * Editing state of a single-column string list, free of Compose so the rules are - * testable without a toolkit. - * - * Reproduces what the JavaFX tables in the settings window did: a new entry goes - * to the top and becomes the selection, and an entry edited to blank disappears — - * that was how the operator removed one. - */ -class EditableListState(initial: List) { - - private val items: MutableList = initial.toMutableList() - - val entries: List - get() = items.toList() - - var selectedIndex: Int? = null - private set - - fun select(index: Int?) { - selectedIndex = index?.takeIf { it in items.indices } - } - - fun addAtTop(entry: String) { - items.add(0, entry) - selectedIndex = 0 - } - - /** An index outside the list is ignored: a pending edit can outlive its row. */ - fun updateAt(index: Int, text: String) { - if (index !in items.indices) { - return - } - if (text.isBlank()) { - removeAt(index) - return - } - items[index] = text - } - - fun removeAt(index: Int) { - if (index !in items.indices) { - return - } - items.removeAt(index) - if (selectedIndex?.let { it >= items.size } == true) { - selectedIndex = items.indices.lastOrNull() - } - } - - /** - * Fills the given roster. Deliberately setAll and not a field swap: replacing - * the roster would orphan every listener registered on it, which is the same - * rule the ChatPreferences setters follow since Etappe 2. - */ - fun commitTo(roster: SimpleRoster) { - roster.setAll(items) - } -} -``` - -- [ ] **Schritt 5: Test laufen lassen** - -Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.EditableListStateTest'` -Erwartet: PASS, 7 Testfälle. - -- [ ] **Schritt 6: Die Composable schreiben** - -An `DataTable.kt` anfügen. Zwei Punkte sind nicht verhandelbar: - -- `LazyColumn` mit `key = { index, _ -> index }` ist **falsch** — bei einer Einfügung an Position 0 verschieben sich alle Schlüssel und die Auswahl springt. Der Schlüssel muss die **Identität des Eintrags** tragen. Weil Zeichenketten doppelt vorkommen können, bekommt jeder Eintrag beim Anlegen eine laufende Nummer, die `EditableListState` mitführt. -- Der Fokus geht beim Anlegen eines Eintrags ausdrücklich in das neue Textfeld, über einen `FocusRequester`. Nicht hoffen, dass es von selbst passiert: in Etappe 2 waren zwei von drei Fehlern Fokusfehler. - -Ergibt sich daraus, dass `EditableListState` Einträge nicht als `String`, sondern als `(id, text)` führen muss, ist der Test aus Schritt 2 entsprechend nachzuziehen — **vor** der Umsetzung, und als `Ruling:` ins Ledger. - -- [ ] **Schritt 7: Gesamtlauf und Commit** - -Ausführen: `./gradlew clean build` -Erwartet: BUILD SUCCESSFUL, **272** Testfälle (265 + 7), 0 Fehler. - -```bash -git add -A -git commit -m "Add an editable single-column table for Compose windows" -``` - ---- - -## Aufgabe 2: Das Sicherheitsnetz — Bestandsaufnahme der Einstellungsfelder - -Deckt die Prüfschwerpunkte 1 und 2 ab. **Ohne diese Aufgabe ist der Rest nicht abnehmbar.** - -**Dateien:** -- Anlegen: `app-desktop/src/test/resources/settings-fields.txt` (erzeugt, dann eingecheckt) -- Anlegen: `app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsFieldCoverageTest.kt` - -**Schnittstellen:** -- Erzeugt: die Prüfung, an der jede folgende Aufgabe gemessen wird. - -Das JavaFX-Einstellungsfenster ruft **72 verschiedene** `ChatPreferences`-Setter. - -> Korrigiert am 2026-09-27: Der Plan nannte zuvor 93. Das waren rohe Setter-**Aufrufe** im Bereich, inklusive JavaFX-Steuerelementen wie `setDisable` und `setPrefWidth`. Die belastbare Zahl entsteht durch Schnitt mit den Settern, die `ChatPreferences` wirklich deklariert: **72**. Ein beim Portieren vergessenes Feld fällt nicht beim Übersetzen auf, sondern wenn ein Operator im Contest merkt, dass seine Einstellung weg ist. Diese Aufgabe macht daraus eine Zahl, die der Build prüft. - -Korrektur zum Spec: es sind **elf** Reiter, nicht zwölf. `Macros` ist auskommentiert (`Kst4ContestApplication:12346`). Aktiv sind Station, Log synch, TRX synch, Airscout, Notification, Shortcuts, Beacon, Messagehandling, Workedstn database, GUI und Profiles. - -- [ ] **Schritt 1: Die Bestandsaufnahme erzeugen** - -Mechanisch, nicht nach Urteil: die Setter-Aufrufe im Bereich, geschnitten mit den Settern, die `ChatPreferences` wirklich deklariert. Damit fallen JavaFX-Steuerelement-Setter ohne Einzelfallentscheidung heraus. - -```bash -awk 'NR>=9588 && NR<=12400' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - | grep -oE "\.set[A-Za-z_0-9]+\(" | sed 's/^\.//;s/(//' | sort -u > /tmp/setters-raw.txt -grep -oE "public void (set[A-Za-z_0-9]+)\(" core/src/main/java/kst4contest/model/ChatPreferences.java \ - | sed 's/public void //;s/(//' | sort -u > /tmp/prefs-setters.txt -comm -12 /tmp/setters-raw.txt /tmp/prefs-setters.txt > app-desktop/src/test/resources/settings-fields.txt -wc -l < app-desktop/src/test/resources/settings-fields.txt -``` -Erwartet: **72**. - -Die Zeilennummern 9588 und 12400 vorher gegen den Ist-Stand prüfen: -```bash -grep -n "settingsStage = new Stage()" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -grep -n 'Tab tbProfiles = new Tab' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Dann die **ausgeschlossenen** durchsehen — `comm -23 /tmp/setters-raw.txt /tmp/prefs-setters.txt` — und bei jedem Namen prüfen, der nicht offensichtlich ein Steuerelement ist. Zwei Fälle sind belegt: `setLoginChatCategory` steht nur in einer auskommentierten Zeile, die echten Aufrufe heissen `…Main` und `…Second`; `setMYQRGFirstCat` fehlt zu Recht, die setzt die TRX-Synchronisation zur Laufzeit. Jede solche Prüfung als `Ruling:` ins Ledger — hier fällt ein echtes Feld unbemerkt heraus, wenn man es übergeht. - -- [ ] **Schritt 2: Den Test schreiben, der die Abdeckung prüft** - -`app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsFieldCoverageTest.kt`: - -```kotlin -package kst4contest.view.compose - -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.assertTrue -import java.io.File - -/** - * Guards against a setting quietly disappearing during the port. - * - * The JavaFX settings window called 93 distinct ChatPreferences setters. Every one - * of them must still be called from the Compose settings sources, or an operator - * loses a setting they could configure before — and nothing about that fails to - * compile. - */ -class SettingsFieldCoverageTest { - - @Test - fun `every setting the JavaFX window could write is still written`() { - val expected = File("src/test/resources/settings-fields.txt") - .readLines().map { it.trim() }.filter { it.isNotEmpty() } - - val composeSources = File("src/main/kotlin/kst4contest/view/compose") - .walkTopDown().filter { it.extension == "kt" } - .joinToString("\n") { it.readText() } - - val missing = expected.filterNot { composeSources.contains("$it(") } - - assertTrue(missing.isEmpty(), - "settings not written by any Compose source (${missing.size} of ${expected.size}):\n" + - missing.joinToString("\n") { " $it" }) - } -} -``` - -- [ ] **Schritt 3: Test laufen lassen und Fehlschlag sehen** - -Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.SettingsFieldCoverageTest'` -Erwartet: FAIL, mit `settings not written by any Compose source (72 of 72)`. Das ist der Ausgangspunkt; jede folgende Aufgabe verkleinert die Liste. - -> Der Test prüft Textvorkommen, nicht Laufzeitverhalten. Er kann nicht erkennen, ob ein Setter mit dem richtigen Wert gerufen wird — nur, dass er überhaupt gerufen wird. Das ist wenig, aber es ist die einzige Prüfung, die 93 Felder mechanisch abdeckt, und sie fällt sofort auf, wenn eines fehlt. - -- [ ] **Schritt 4: Commit** - -```bash -git add -A -git commit -m "Pin down which settings the port must keep writable" -``` - ---- - -## Aufgabe 3: Das Fenster und der erste Reiter - -**Dateien:** -- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt` -- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt` -- Test: `app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt` -- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -**Schnittstellen:** -- Konsumiert: `Kst4ContestTheme` und den Weg aus Etappe 3a (`application(exitProcessOnExit = false)` in eigenem Thread). -- Erzeugt: `SettingsWindow.show(preferences, autosave, onProfileSwitch)` und das Muster `TabState`, dem die Aufgaben 4 bis 6 folgen. - -Anders als der Profil-Dialog ist dieses Fenster **nicht blockierend**: der Operator arbeitet weiter, während es offen ist. Es darf daher nicht mit einem Latch auf das Schließen warten. - -- [ ] **Schritt 1: Den heutigen Aufbau ablesen** - -Ausführen: `sed -n '9588,9640p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -Notieren: Modalität, Größe, Titel, ob das Fenster wiederverwendet oder neu gebaut wird, und wie **Save Settings** angebunden ist. Laut `PROJECT_CONTEXT` ist **Save Settings** der vollständige Schreiber und enthält das aktuelle Layout — das muss so bleiben. - -- [ ] **Schritt 2: Den Zustand des Station-Reiters mit Test** - -Zuerst der Test. Er prüft, dass der Zustand aus `ChatPreferences` liest und **nur beim Speichern** zurückschreibt — nicht bei jedem Tastendruck, sonst landen unbestätigte Werte in der Datei. `PROJECT_CONTEXT` verlangt genau das: *„Selective layout writes … must not persist unconfirmed functional settings from the current UI."* - -```kotlin -package kst4contest.view.compose.tabs - -import kst4contest.model.ChatPreferences -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotEquals - -class StationTabStateTest { - - @Test - fun `the state starts from the stored preferences`() { - val prefs = ChatPreferences() - prefs.stn_loginCallSign = "DN9APW" - - val state = StationTabState(prefs) - - assertEquals("DN9APW", state.callSign) - } - - @Test - fun `editing does not touch the preferences until apply`() { - val prefs = ChatPreferences() - prefs.stn_loginCallSign = "DN9APW" - val state = StationTabState(prefs) - - state.callSign = "DO5AMF" - - assertNotEquals("DO5AMF", prefs.stn_loginCallSign, - "an unconfirmed edit must not reach preferences.xml") - } - - @Test - fun `apply writes every field of this tab`() { - val prefs = ChatPreferences() - val state = StationTabState(prefs) - - state.callSign = "DO5AMF" - state.apply() - - assertEquals("DO5AMF", prefs.stn_loginCallSign) - } -} -``` - -Die Feldnamen gegen `ChatPreferences` prüfen, bevor der Test geschrieben wird — `grep -n "stn_login" core/src/main/java/kst4contest/model/ChatPreferences.java`. - -- [ ] **Schritt 3: Fehlschlag sehen, dann umsetzen** - -Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.tabs.StationTabStateTest'` -Erwartet: FAIL — `StationTabState` existiert nicht. - -Dann `StationTabState` und `StationTab` schreiben. **Alle** Felder des Reiters, nicht nur das Rufzeichen; die Liste aus `settings-fields.txt` und der Blick in `vbxStation` sagen, welche dazugehören. - -- [ ] **Schritt 4: Das Fenster schreiben und anbinden** - -`SettingsWindow` mit Reiterleiste, zunächst nur dem Station-Reiter, plus **Save Settings** und **Cancel**. Anbinden an denselben Menüpunkt, der heute `settingsStage` öffnet — das JavaFX-Fenster bleibt vorerst erreichbar, damit die noch nicht portierten Reiter benutzbar sind. - -> Prüfschwerpunkt 5: solange beide Fenster existieren, können sie gegeneinander schreiben. Der Menüpunkt öffnet deshalb **entweder** das eine **oder** das andere, nie beide. Welches, entscheidet ein Schalter im Quelltext, der in Aufgabe 7 verschwindet. - -- [ ] **Schritt 5: Prüfen** - -Ausführen: `./gradlew build && ./gradlew :app-desktop:run` - -Erwartet: Das Compose-Einstellungsfenster öffnet, zeigt den Station-Reiter mit den gespeicherten Werten, **Save Settings** schreibt sie, und nach einem Neustart sind sie noch da. Das Hauptfenster bleibt währenddessen bedienbar — das Fenster ist nicht blockierend. - -Ausführen: `grep -c "configVersion>7" ~/.praktiKST/preferences.xml` -Erwartet: `1`. - -- [ ] **Schritt 6: Commit** - -```bash -git add -A -git commit -m "Open the settings window in Compose with the station tab" -``` - ---- - -## Aufgaben 4 bis 6: Die übrigen zehn Reiter - -Drei Aufgaben, gruppiert nach Verwandtschaft, nicht nach Reihenfolge im Quelltext. Jede folgt demselben Muster wie Aufgabe 3 und endet damit, dass `SettingsFieldCoverageTest` weniger fehlende Felder meldet als vorher. - -> **Warum hier kein Quelltext steht, anders als in den Aufgaben 1 bis 3.** Zehn Reiter mit zusammen 93 Feldern auszuschreiben würde diesen Plan verdoppeln und dabei nur wiederholen, was schon zweimal festgeschrieben ist: in `settings-fields.txt` steht, **welche** Felder es sind, und Aufgabe 3 zeigt an einem vollständigen Beispiel, **wie** ein Reiter gebaut wird. Was dem Umsetzer fehlen könnte, ist keine Vorlage, sondern die Zuordnung Feld zu Reiter — und die steht im Quelltext, nicht in einem Plan, der sie abschreibt. -> -> Das ist eine Abweichung von der Regel, dass jeder Schritt seinen Inhalt mitbringt. Sie ist bewusst: bei mechanischer Wiederholung ist eine geprüfte Vorlage plus eine mechanische Abnahme mehr wert als zehn abgeschriebene Formulare, in denen sich Fehler verstecken. - -**Für jeden Reiter, in dieser Reihenfolge:** - -1. Die Felder des Reiters aus `settings-fields.txt` und dem zugehörigen `vbx…`-Block in `Kst4ContestApplication` heraussuchen. -2. Test für `TabState` schreiben: liest aus den Preferences und schreibt **sofort** zurück, für **alle** Felder des Reiters. Kein `apply()`. -3. Fehlschlag sehen. -4. `TabState` und `Tab` schreiben. -5. Test grün. -6. Reiter in `SettingsWindow` eintragen. -7. `SettingsFieldCoverageTest` laufen lassen und die neue Zahl fehlender Felder ins Ledger. -8. **Den JavaFX-Block des Reiters auf Schreibstellen ausserhalb der Einstellungen durchsehen.** Das erste Netz sieht sie nicht. Gefundene Stellen als Rückruf in die Zustandsklasse und in `settings-foreign-writes.txt` nachtragen, falls sie dort noch fehlen. - -### Aufgabe 4: Log synch, TRX synch, Airscout - -**Dateien:** `tabs/LogSynchTab.kt`, `tabs/TrxSynchTab.kt`, `tabs/AirscoutTab.kt` samt Zustandsklassen und Tests. - -Diese drei hängen an externen Schnittstellen. **Ports und Transporte werden nicht angetastet** — weder Vorgabewerte noch Wertebereiche. `AGENTS.md` ist hier ausdrücklich: Framing, Ports und Transportannahmen sind Protokollverhalten. - -Achtung bei TRX synch: laut `PROJECT_CONTEXT` verlangen automatische QRG-Aktualisierungen **beide** Dinge — eine aktivierte Quelle **und** gültige eingehende Daten. Das Einschalten allein liefert keine QRG. Der Reiter darf daher nicht suggerieren, dass eine aktivierte Quelle schon eine Frequenz bedeutet. - -### Aufgabe 5: Notification, Shortcuts, Messagehandling, Beacon - -**Dateien:** `tabs/NotificationTab.kt`, `tabs/ShortcutsTab.kt`, `tabs/MessageHandlingTab.kt`, `tabs/BeaconTab.kt` samt Zustandsklassen und Tests. - -**Hier kommt die `DataTable` aus Aufgabe 1 zum Einsatz**, dreimal: - -| Liste | Roster in `ChatPreferences` | -|---|---| -| Überwachte Rufzeichen (Notification) | `lstNotify_QSOSniffer_sniffedCallSignList` (über `ChatController`) | -| Textkürzel (Shortcuts) | `lst_txtShortCutBtnList` | -| Textbausteine (Messagehandling) | `lst_txtSnipList` | - -Beim Speichern wird `EditableListState.commitTo(roster)` gerufen — es füllt den vorhandenen Roster, statt ihn zu ersetzen. Ein Feldtausch würde angemeldete Zuhörer abwerfen; genau deshalb hat Etappe 2 die `ChatPreferences`-Setter auf `setAll` umgestellt. - -Der Beacon-Reiter enthält die Vorgabetexte mit Platzhaltern wie `MYCALL` und `MYQRG`. Diese Zeichenketten sind **Literale und bleiben unverändert** — `MessageVariableResolver` löst sie auf, und eine geänderte Schreibweise bricht das still. - -### Aufgabe 6: Workedstn database, GUI, Profiles - -**Dateien:** `tabs/WorkedDatabaseTab.kt`, `tabs/GuiOptionsTab.kt`, `tabs/ProfilesTab.kt` samt Zustandsklassen und Tests. - -Deckt Prüfschwerpunkt 4 ab. - -**Workedstn database** enthält den Zurücksetzen-Knopf. Die Worked-Semantik ist unverändert: normalisierter Basisruf als Schlüssel, Worked-Zustand über Suffixvarianten geteilt, drei Tage Ablauf, manuelles Zurücksetzen. Ein Zurücksetzen fragt vorher nach. - -**GUI** enthält unter anderem `GUIstationMapClusteringEnabled`. Laut `PROJECT_CONTEXT` ist das eine Layout-Einstellung unterhalb `guiOptions` mit Vorgabewert `true`, selektiv autogespeichert; fehlende oder fehlerhafte Werte behalten `true`. Diese drei Eigenschaften bleiben. - -**Profiles** ist der schwierigste Reiter dieses Plans. Er ersetzt `OperatorProfileSettingsPane` (488 Zeilen) und löst einen **Laufzeitwechsel** aus: `ApplicationRuntimeLauncher` reißt die Laufzeit ab und baut eine neue `Kst4ContestApplication`. Das Compose-Einstellungsfenster muss dabei selbst verschwinden — es gehört zur alten Laufzeit. - -Zu klären und als `Ruling:` festzuhalten: schließt das Fenster sich vor dem Wechsel selbst, oder räumt `shutdownRuntime()` es ab? Die zweite Variante braucht eine Rückmeldung von Compose nach Java. Die erste ist einfacher und vermutlich richtig. - -> In Etappe 3a wurde bestätigt, dass ein Profilwechsel mit einem Compose-Fenster im Spiel funktioniert. Damals war das Fenster der Profil-Dialog, der ohnehin schließt. Hier bleibt ein Fenster offen — das ist der neue Teil. - -Bekannter Nebenbefund, **vorbestehend**, der beim Prüfen im Protokoll erscheinen wird: Beim Profilwechsel stirbt der UCX-Log-UDP-Zuhörer der alten Laufzeit mit `NullPointerException`, `BindException` und der Meldung `Program Restart needed`, weil `ReadUDPbyUCXMessageThread` eine `while (true)`-Schleife ohne Abbruchbedingung hat. Für die neue Laufzeit kommt ein frischer Zuhörer hoch, die Funktion erholt sich. **Das ist nicht von dieser Etappe** und wird hier nicht behoben. - ---- - -## Aufgabe 7: Das JavaFX-Einstellungsfenster entfernen - -Deckt Prüfschwerpunkt 5 ab. - -**Dateien:** -- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` (`settingsStage` und der zugehörige Aufbau, etwa `:9588`–`:12400`) -- Löschen: `app-desktop/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java` (488 Zeilen) - -- [ ] **Schritt 1: Erst prüfen, dass nichts fehlt** - -Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.SettingsFieldCoverageTest'` -Erwartet: PASS. **Solange dieser Test rot ist, wird nichts gelöscht.** - -- [ ] **Schritt 2: Den Schalter und das alte Fenster entfernen** - -Den Schalter aus Aufgabe 3 Schritt 4 löschen, den `settingsStage`-Aufbau entfernen, `OperatorProfileSettingsPane` löschen. - -Ausführen: `grep -rn "settingsStage\|OperatorProfileSettingsPane" app-desktop/src` -Erwartet: keine Ausgabe. - -- [ ] **Schritt 3: Zählstand** - -Ausführen: `wc -l app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -Die Datei begann Etappe 2 mit 13.564 Zeilen. Der neue Wert gehört ins Ledger: er ist das erste harte Maß dafür, dass die God-Klasse schrumpft. - -- [ ] **Schritt 4: Die Abnahme von Hand** - -Ausführen: `./gradlew clean build && ./gradlew :app-desktop:packageImage` — beziehungsweise `createDistributable`, je nachdem was Etappe 3a hinterlassen hat — und das Paket starten. - -Alle elf Reiter durchgehen und in **jedem** mindestens einen Wert ändern. Dann **Save Settings**, Programm beenden, neu starten, und prüfen dass alle Änderungen noch da sind. - -Ausführen danach: -```bash -grep -c "configVersion>7" ~/.praktiKST/preferences.xml -python3 -c "import xml.etree.ElementTree as E; E.parse('$HOME/.praktiKST/preferences.xml'); print('XML gültig')" -``` -Erwartet: `1` und `XML gültig`. - -**Vorher eine Sicherung anlegen**: `cp ~/.praktiKST/preferences.xml ~/.praktiKST/preferences.xml.vor-etappe3b`. Diese Aufgabe schreibt in die echte Konfiguration des Operators. - -- [ ] **Schritt 5: Commit** - -```bash -git add -A -git commit -m "Remove the JavaFX settings window" -``` - ---- - -## Abnahme der Etappe 3b - -- [ ] `./gradlew clean build` läuft durch, 0 Fehler. -- [ ] `SettingsFieldCoverageTest` ist grün: alle 72 Einstellungen werden von Compose-Quellen geschrieben. -- [ ] `SettingsForeignWriteCoverageTest` ist grün: alle fünf Schreibstellen ausserhalb der Einstellungen werden ausgeführt. -- [ ] `grep -rn "settingsStage\|OperatorProfileSettingsPane" app-desktop/src` liefert keine Ausgabe. -- [ ] Alle elf Reiter sind bedienbar, und in jedem übersteht eine Änderung **Save Settings** plus Neustart. -- [ ] `preferences.xml` bleibt `configVersion 7`, ist gültiges XML, und unbekannte Knoten sind erhalten. -- [ ] Die drei editierbaren Listen können Einträge anlegen, ändern und durch Leeren entfernen — wie vorher. -- [ ] Der Profilwechsel über den Reiter **Profiles** funktioniert, und das Einstellungsfenster verschwindet dabei. -- [ ] `core` ist unberührt: `grep -rn "javafx" core/src` liefert nichts. -- [ ] Die neue Zeilenzahl von `Kst4ContestApplication.java` steht im Ledger. - -## Was danach kommt - -Etappe 4 nach Spec: Update-Fenster und Monitor-Fenster. Das Monitor-Fenster belastet die `DataTable` erstmals mit **Live-Daten** und mit mehreren Spalten — dort entsteht, was dieser Plan bewusst nicht gebaut hat: Spaltenbreiten mit stabilen IDs, Sortierung und Zeilen-Styling. Die Erfahrung aus diesem Plan gehört dann in dessen Entwurf. - -## Befunde aus Gruppe B (Aufgaben 4–6) - -Gruppe B hat ihre vier Tests geschrieben, bevor die Umsetzungen existierten. Ich -habe **jede** darin behauptete Verhaltensweise gegen den JavaFX-Quelltext geprüft, -bevor ich sie umgesetzt habe. Ergebnis: alle Behauptungen waren richtig. Belege: - -| Behauptung | Belegstelle in `Kst4ContestApplication.java` | -|---|---| -| Platzhaltertext neuer Listeneinträge | `:11743` und `:11797`, in beiden Listen wörtlich gleich | -| Leerer Text entfernt den Eintrag | `ShortCol.setOnEditCommit`, `newValue.isBlank()` → `remove` | -| „Move selected up/down" existiert in beiden Listen | `:11753`, `:11760`, `:11806`, `:11813` | -| Eine Ankreuzfläche schreibt beide Kategorien | `:12004` und `:12005` | -| Ein Textfeld schreibt beide Kategorietexte | `:12039` und `:12040` | -| QRG-Antwort ist davon unabhängig | `:12026` | -| Port 1–65535, Vorwert bei Ablehnung | `:11243`–`:11282` | -| Neustart nur bei geändertem Port | `:11262`, `port != previousPort` | -| Spotter-Rufzeichen wird großgeschrieben und geprüft | `:11390`–`:11420` | -| Unbekanntes Frequenzpräfix wird auf 144 repariert | `:11349`–`:11356` | -| Testspot: `DO5AMF`, `300`, `DXC test: You donated $100!` | `:11469`–`:11473` | -| Überwachte Rufzeichen: Basisrufzeichen, **angehängt**, Doppelprüfung ohne Groß-/Kleinschreibung | `:11635`–`:11700` | -| Vorlage prüfen, Vorwert bleibt bei Ablehnung | `applyBeaconTextSetting`, `:5271` | -| Intervall in beide Kategorien, Zeitgeber nur bei Erfolg | `applySharedBeaconInterval`, `:5306` | - -Zwei Stellen gehen bewusst über den JavaFX-Stand hinaus, beide dokumentiert im -Quelltext: - -1. **`beaconIntervalMinutes` klemmt beim Lesen auf den Mindestwert.** JavaFX wandte - `Math.max` nur im Fehlerzweig an; `ChatController:3425` klemmt aber ohnehin beim - Senden. Ein gespeicherter Nullwert wurde also als gültige Einstellung angezeigt, - obwohl gar nicht danach gesendet wurde. -2. **Das Auffrischen nach dem Hinzufügen eines Eintrags.** JavaFX frischte nur beim - Bearbeitungsabschluss und beim Verschieben auf, nicht beim Hinzufügen — dort folgte - das Bearbeiten aber unmittelbar. Beim Übergeben aufzufrischen ist derselbe Endstand. - -### Was mein eigener Aufgabe-1-Bericht übersehen hatte - -Ich hatte die Verschiebeknöpfe der Listen nicht erfasst. Gruppe B hat sie in -`EditableListState.moveSelected` vorgesehen; die Knöpfe existieren in beiden Listen -wirklich. Ohne die Prüfung hätte ich Bs Arbeit für erfunden gehalten. - -### Grenzen der Netze, erneut bestätigt - -Netz 2 sah die drei Testspot-Schreibvorgänge zunächst nicht, weil ich sie als -Kotlin-Eigenschaftszuweisung geschrieben hatte (`spot.qra = …`) und das Netz nach -`setQra(` sucht. Das Netz prüft **Text, nicht Verhalten**. Behoben, indem die -Java-Setzer namentlich aufgerufen werden — was hier ohnehin die treuere Entsprechung -ist. Wer künftig eine Zuweisung bevorzugt, muss das Netz mitändern, sonst wird es -still nutzlos. - -### Stand - -- Beide Netze grün: **72 von 72** Feldern erfasst, **5 von 5** Fremdschreibvorgängen. -- **362 Testfälle, 0 Fehler** (vorher 322 mit zwei absichtlich roten). -- Verdrahtung in `SettingsTabs.kt`, zehn Reiter in der Reihenfolge der JavaFX-`TabPane`. - -### Offen - -- `ProfilesTab` als Ersatz für `OperatorProfileSettingsPane` (488 Zeilen). -- Aufgabe 7: `settingsStage` und `OperatorProfileSettingsPane` löschen, den Aufruf - von `buildSettingsTabs`/`SettingsWindow.show` in `Kst4ContestApplication` setzen und - `SettingsHost` dort umsetzen. Neue Zeilenzahl festhalten (Anfang Etappe 2: 13 564). -- Abnahme von Hand: alle Reiter, je eine Änderung, „Save settings", Neustart, - `configVersion 7` unversehrt. Vorher `~/.praktiKST/preferences.xml` sichern. - -## Aufgabe 7: was der Plan nicht wusste - -Der Plan sagte „`settingsStage` und `OperatorProfileSettingsPane` löschen". Beim -Lesen des Blocks kam heraus, dass das Einstellungsfenster **mehr war als -Einstellungen**: seine Knopfleiste trug - -| Knopf | Wirkung | -|---|---| -| `btnOptionspnlConnect` | `chatcontroller.execute()` — die **einzige** Stelle, an der der Chat startet | -| `btnOptionspnlDisconnect` | `closeWindowEvent(null)`, Chat schließen | -| `btnOptionspnlDisconnectOnly` | `disconnect(DISCSTRING_DISCONNECTONLY)` | -| `btn_preferences_saveAsDefault` | Einstellungen sichern | -| `btnOptionsPnlApply` | Fenster schließen | - -Deshalb stand `settingsStage.show()` im Startpfad und nicht an einem Knopf: **das -Einstellungsfenster war das Anmeldefenster.** Ein Löschen ohne Portieren hätte den -Anmeldeweg entfernt. `ConnectionBar.kt` bildet die Leiste nach, in derselben -Reihenfolge. - -### Weitere Lücken, die dabei auffielen - -1. **Die Kategorie-Auswahl fehlte im Compose-Station-Reiter.** `StationTabState` - hatte `selectMainCategory`/`selectSecondCategory`, der Reiter zeigte sie nie. - Ohne sie lässt sich der Chatraum nicht wählen — die Anwendung wäre unbenutzbar - gewesen. -2. **Die Sperre der Anmeldefelder fehlte.** JavaFX sperrte Rufzeichen, Kennwort, - Name, Locator und beide Kategorien, sobald die Sitzung angemeldet war: eine - Änderung erreicht die laufende Verbindung nicht. -3. **Die Tabelle der gearbeiteten Stationen fehlte.** Ich hielt sie zunächst für - tote Oberfläche, weil `tblVw_worked.setItems()` auskommentiert ist — falsch: - `initWkdStnTable` setzt die Einträge selbst (`:5585`, - `getLst_DBBasedWkdCallSignList()`). Jetzt nachgebaut, eine Zeile je Rufzeichen, - eine Spalte je Band. -4. **Reihenfolge und Titel der Reiter waren bei mir falsch.** Ich hatte sortiert - und umbenannt; der JavaFX-Quelltext warnt ausdrücklich davor - („operators navigate these tabs by muscle memory"). Jetzt zeichengleich: - Station, Log synch, TRX synch, Airscout, Notification, Shortcuts, Beacon, - Messagehandling, Workedstn database, GUI, Profiles. - -### Bewusste Abweichungen - -- **„hide options" heißt jetzt „show options" und schaltet nicht um.** Ein - Compose-Fenster kann auch am eigenen Rahmen geschlossen werden; eine Beschriftung, - die den Zustand hier mitführt, würde veralten, ohne es zu erfahren. -- **Die Fenstergröße wird gelesen, aber nicht zurückgeschrieben.** JavaFX hatte - Listener auf `widthProperty`/`heightProperty`, die `GUIsettingsStageSceneSizeHW` - pflegten. Offen. -- **Grundschriftgröße 12sp festgelegt.** Es gibt keine Einstellung dafür; JavaFX - nahm die Vorgabe der Plattform. Gleicher Wert wie im Profil-Wähler. -- **`refreshWorkedStationsView` ist absichtlich leer.** Die Compose-Tabelle liest - den Roster selbst; die JavaFX-Tabelle brauchte den Aufruf nur, weil ein - `TableView` Änderungen in seinen Objekten nicht bemerkt. - -### Nebenbefund, nicht behoben - -`finalTblVwWorked` wurde von der **leeren** `new TableView<>()` zugewiesen, bevor -`tblVw_worked = initWkdStnTable()` neu zuwies. Die drei `.refresh()`-Aufrufe trafen -also ein weggeworfenes Objekt. Sichtbar wurde das nie, weil der Roster-Spiegel die -angezeigte Tabelle ohnehin nachzieht. Mit dem Block gelöscht. - -### Ergebnis - -- `Kst4ContestApplication.java`: **13 755 → 10 881 Zeilen** (−2 874). - Zum Vergleich: Anfang Etappe 2 waren es 13 564. -- `OperatorProfileSettingsPane.java` (488 Zeilen) gelöscht. -- **373 Testfälle, 0 Fehler.** Beide Netze grün. -- Rauchtest: Anwendung startet, Compose läuft, keine Ausnahme. Weiter als bis zum - Profil-Wähler kam der Test ohne Klick nicht. - -### Abnahme von Hand, offen - -1. Profil wählen → Einstellungsfenster muss erscheinen. -2. **Anmelden**: Kategorie wählen, Verbinden. Der Knopf muss die Kategorie(n) im - Text nennen. Danach müssen die Anmeldefelder gesperrt sein. -3. Trennen und „Disconnect & close Chat". -4. Je eine Änderung in allen elf Reitern, „Save settings", Neustart, - `configVersion 7` unversehrt. -5. Profil-Reiter: anlegen, umbenennen, verdoppeln, Betriebsart der gearbeiteten - Stationen ändern, wechseln. Löschen zuletzt und nur an einem Wegwerfprofil. -6. Menü „Options" und „Windows → show options" müssen das Fenster öffnen. - -## Abnahme von Hand — durchgeführt am 2026-09-27 - -Abgenommen von Philipp (DN9APW) am laufenden Programm. Die Belege stammen aus einem -Vergleich der `preferences.xml` gegen eine Sicherung, nicht aus Augenschein allein. - -### Persistenz - -- **`configVersion` bleibt 7.** 138 Werte vor der Abnahme, 138 danach, keiner neu, - keiner entfallen. Das war das Hauptrisiko beim Schreiben. -- `writePreferencesToXmlFile()` hat im ganzen Programm **genau einen** Aufrufer, - `savePreferencesFromSettings()` in `Kst4ContestApplication`. Die geänderten Werte - stehen also in der Datei, weil „Save settings" funktioniert — nicht, weil irgendein - Automatismus nebenher geschrieben hätte. Die Layoutwerte sind davon getrennt: - `LayoutAutosave` schreibt sie über `writeLayoutPreferencesToXmlFile()`. - -### Alle neun Reiter mit Feldern sind belegt - -| Reiter | Beleg in der XML | -|---|---| -| Station | `stn_antennaBeamWidthDeg` 50 → 20 | -| Log synch | `logsynch_ucxUDPWkdCallListenerEnabled`, `logsynch_wintestNetworkListenerEnabled` | -| TRX synch | `logsynch_wintestQrgSyncEnabled`, `logsynch_wintestUsePassQrg` | -| Airscout | `asQry_airScoutClientName` KST → KST4CONTEST | -| Notification | `notify_SimpleAudioNotificationsEnabled` true → false | -| Beacon | Intervall 5 → 7, beide Kategorien freigeschaltet | -| Messagehandling | `autoAnswerEnabled` und Antworttext, beide Kategorien | -| GUI | `guiOptions_darkModeActiveByDefault` true → false | -| Shortcuts | Listeneintrag `MYQRG` → `AAAA-TEST` | - -Die zwei Reiter ohne Felder — **Workedstn database** und **Profiles** — wurden über -ihre Aktionen abgenommen. - -### Was die Abnahme darüber hinaus bewiesen hat - -**Die „ein Bedienelement schreibt beide Kategorien"-Umsetzungen stimmen.** Beacon- -Intervall, Beacon-Freigabe, automatische Antwort und Antworttext haben jeweils beide -Kategoriefelder geschrieben. Das war gegen den JavaFX-Quelltext geprüft, aber bis zur -Abnahme nie bis in die Datei verfolgt. - -### Zwei Zuordnungsfehler bei der Auswertung - -Beide sind es wert, notiert zu werden, weil sie dieselbe Ursache haben: **von einem -Namen auf die Zugehörigkeit zu schließen.** - -1. `logsynch_wintestUsePassQrg` wurde nach dem Präfix dem Log-synch-Reiter - zugeschlagen. Tatsächlich gehört es zu `TrxSynchTabState` — der TRX-synch-Reiter - zeigt vier Win-Test-Felder, die in der XML unter `logsynch_` liegen. -2. Ein Skript, das Java-Setzernamen gegen XML-Elementnamen paarte, meldete vier - Reiter als unbelegt. Die Namen unterscheiden sich: - `setNotify_playSimpleSounds` schreibt `notify_SimpleAudioNotificationsEnabled`, - `setGUI_darkModeActiveByDefault` schreibt `guiOptions_darkModeActiveByDefault`. - -Wer die Abdeckung künftig auswertet, muss die Zugehörigkeit aus dem Quelltext der -`*TabState`-Klassen nehmen, nicht aus dem Feldnamen. - -### Nebenbefund, nicht behoben - -`ChatPreferences` schreibt einige Beacon-Werte doppelt: `bcn_beaconIntervalInMinutesMainCat` -und `beaconCQIntervalMinutes` tragen denselben Wert, ebenso die Freigabe-Flags. -Vorbestehend. Relevant für jeden, der die XML von Hand anfasst. diff --git a/docs/superpowers/plans/2026-09-27-etappe4-update-und-monitor-fenster.md b/docs/superpowers/plans/2026-09-27-etappe4-update-und-monitor-fenster.md deleted file mode 100644 index 303f8337..00000000 --- a/docs/superpowers/plans/2026-09-27-etappe4-update-und-monitor-fenster.md +++ /dev/null @@ -1,248 +0,0 @@ -# Umsetzungsplan Etappe 4 — Update-Fenster und Monitor-Fenster - -Spezifikation: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 4. - -## Was diese Etappe wirklich ist - -Die Spezifikation nennt zwei Fenster. Beim Lesen des Quelltextes zeigt sich, dass das -untertreibt: - -| Fenster | Stelle | braucht | -|---|---|---| -| Update | `:9458` | ein Textgitter, einen Verweis, **einen Baum** mit Änderungsprotokoll und bekannten Fehlern, Größenspeicherung | -| Monitor | `:9396` | `initDXClusterTable` mit **8 Spalten** über `ClusterMessage`, `initChatToOtherMSGTable` mit **10 Spalten** über `ChatMessage` | - -`DataTable` aus Etappe 3b kann **eine** Spalte Text — `ListEntry(key, text)`. Die -mehrspaltige Tabelle existiert nicht. Das Monitor-Fenster ist also nicht der Abnehmer -einer fertigen Komponente, sondern ihr Anlass. - -**Etappe 5 braucht dieselbe Komponente** für die Stationsliste mit Filterleiste und -Prioritätenspalten. Was hier entsteht, trägt also drei Tabellen, nicht zwei. Der -Zuschnitt wird danach entschieden, nicht nach dem, was für diese zwei Fenster gerade -reicht — aber auch nicht darüber hinaus: die Stationsliste wird gelesen, bevor die -Schnittstelle festgelegt wird, nicht danach. - -## Reihenfolge - -Update-Fenster zuerst, wie besprochen. Es ist klein und klärt die Fenster-Mechanik -aus einem Hintergrundfaden — dieselbe Stelle, an der in Etappe 3b der Profilwechsel -scheiterte, weil `closeOwnedStages` ein Compose-Fenster nicht erreichte. Das Monitor- -Fenster folgt mit dieser Gewissheit im Rücken, sonst käme die Fenster-Mechanik als -zweite Fehlerquelle zu den Live-Daten dazu. - -## Aufgaben - -### 1. `UpdateWindowState` mit Tests — **erledigt** - -Umgesetzt in `UpdateWindowState.kt`, neun Testfälle. Beim Lesen kam heraus, dass das -Fenster mehr enthält als die Spezifikation andeutet: neben dem Textgitter steht eine -`TreeView` mit verborgener Wurzel und zwei Zweigen, „ChangeLog" und „Known bugs". -Beide haben **dieselbe Form** — erstes Feld ist die Überschrift, der Rest sind Zeilen — -also trägt ein Typ `UpdateSection` beide. Eine allgemeine Baumkomponente braucht es -nicht: die Tiefe ist fest zwei. - -Zwei Entscheidungen sind im Quelltext begründet: fehlende Update-Informationen gelten -als „kein Update" (die Prüfung läuft beim Start, ein gescheiterter Abruf darf den -Start nicht aufhalten), und eine Überschrift ohne Zeilen bleibt erhalten, statt -weggefiltert zu werden. - -Die ursprüngliche Beschreibung der Aufgabe: - - - -Reine Fachlogik, ohne Oberfläche: Was wird angezeigt, und **wann überhaupt**. Die -Bedingung steht heute bei `:9583`: `getLatestVersionNumberOnServer() > -APPLICATION_CURRENTVERSIONNUMBER`. Dazu die vier Textfelder aus -`getUpdateInformation()` und der Verweis auf die Veröffentlichungsseite. - -Zu prüfen: gleiche Version zeigt nichts, ältere Serverversion zeigt nichts, fehlende -Update-Information führt nicht zu einer Ausnahme beim Start. - -### 2. Update-Fenster in Compose — **erledigt und abgenommen** - -Titel „Update information", die sechs Beschriftungen wörtlich. Der Verweis öffnet den -Systembrowser. Größe aus `GUIstage_updateStage_SceneSizeHW`. - -**Es folgt dem Design zur Laufzeit**, wie `SettingsWindow` seit Etappe 3b, und es -lässt sich von außen schließen. Beides ist keine Kür: ohne das erste steht ein helles -Fenster neben einem dunklen, ohne das zweite überlebt es den Profilwechsel. - -**Abgenommen am laufenden Programm**, Tag- und Abenddesign, einschließlich Umschalten -zur Laufzeit über „Windows → Use dark mode design". Damit ist auch die neue -`applyDarkMode`-Verdrahtung des Update-Fensters belegt. - -### 3. Den JavaFX-Block entfernen und nachmessen — **erledigt** - -`stage_updateStage` samt Feld, `closeOwnedStages`-Eintrag und -`registerThemedScene`-Aufruf. Zeilenzahl von `Kst4ContestApplication.java` festhalten -(Stand jetzt: 10 904). - -**Ergebnis: 10 904 → 10 802.** Der Block war 145 Zeilen; zurück kamen 36 für -`openUpdateWindowIfAvailable`, das nur noch die Entscheidung abfragt und den -Systembrowser erreicht. - -**Die Fenstermechanik ist herausgezogen.** `ComposeWindowHost` trägt jetzt das, was -jedes Compose-Fenster neben JavaFX braucht: nicht den Prozess beenden, höchstens -einmal öffnen, von außen schließbar sein, dem Design folgen. `SettingsWindow` wurde -darauf umgestellt und behält seine Schnittstelle zur Java-Seite unverändert. Das war -kein Aufräumen um seiner selbst willen: dieselbe Mechanik zum dritten Mal abzuschreiben -hätte bedeutet, die `close()`-Verdrahtung zum dritten Mal von Hand zu treffen — und -genau deren Fehlen war der Profilwechsel-Fehler aus Etappe 3b. - -`UpdateWindow.close()` und `applyDarkMode` sind in `closeOwnedStages` und `applyTheme` -eingetragen, nicht nachgereicht. - -### 4. Die Stationsliste lesen, **bevor** die Tabellenschnittstelle steht — **erledigt** - -**Ergebnis: die drei Tabellen sind ungleich schwer.** - -| Tabelle | Zeilen | Zellfabriken | Zeilenfabrik | Vergleicher | -|---|---|---|---|---| -| Monitor: DX-Cluster | 183 | 1 | 0 | 0 | -| Monitor: QSO der anderen | 222 | 1 | 0 | 0 | -| Stationsliste (Etappe 5) | **854** | **13** | **1** | **4** | - -Die beiden Monitor-Tabellen sind schlicht: Text in Spalten, keine Zeilenfärbung, keine -eigene Sortierung. Nach ihnen allein zugeschnitten entstünde eine Komponente, die die -Stationsliste nicht trägt — **derselbe Fehler wie bei der `DataTable` in Etappe 3b**. - -**Was die Stationsliste zusätzlich verlangt**, aus dem Quelltext gelesen: - -- **22 Spalten**: Callsign, Name, QRA, QRB, QTF, QRG, „AP [minutes / pot%]", Tropo, - Score, Act, worked, wkdany, die Bänder 50/70/144/432/23/13/9/6/3, „NOT QRV @", - Category. -- **Zellinhalt ist nicht gleich Zellenanzeige.** Eine Zellfabrik liest den Wert *und* - das Zeilenobjekt *und* Controllerzustand — Beispiel `qraCol`: Kürzen des Textes, - Kurzhinweis aus Rasterfeld und „schon gearbeitet", Einfärbung nur wenn der - Raster-Knopf aktiv ist. -- **Zeilenfärbung nach `getCurrentPriorityScore()`** mit vier Schwellen: über 1000 rot - („imminent sked"), ab 200 orange, ab 100 gelb, 0 und darunter ausgegraut. **Die - Farben unterscheiden sich nach Design** — im Quelltext steht heute ein `isDark`-Zweig - je Schwelle. -- **Eigene Vergleicher** für vier Spalten; die übrigen sortieren nach Text. -- **23 Spaltenbreiten** über `TableLayoutManager`, entprellt gespeichert. - -**Daraus der Zuschnitt: die Schnittstelle wird für die Stationsliste entworfen, die -Umsetzung nur so weit gebaut, wie Etappe 4 sie braucht.** Eine Spalte ist damit: - -- eine feste Kennung (für die Breitenspeicherung), -- eine Überschrift, -- eine Funktion Zeile → angezeigter Text, -- wahlweise ein Kurzhinweis aus (Zeile, Wert), -- wahlweise eine Zellenfärbung aus (Zeile, Wert), -- wahlweise ein Vergleicher. - -Die Tabelle nimmt zusätzlich wahlweise eine **Zeilenfärbung** aus der Zeile. So -braucht die Stationsliste in Etappe 5 keinen zweiten Zuschnitt, und die -Monitor-Tabellen benutzen schlicht die einfachen Fälle. - -Die ursprüngliche Beschreibung der Aufgabe: - - -`initChatMemberTable` und die Filterleiste aus Etappe 5 durchsehen und aufschreiben, -was sie verlangt: Spaltentypen, Sortierung, Zeilenfärbung, Auswahlverhalten, -Spaltenbreitenspeicherung. Erst danach die Schnittstelle festlegen. - -Begründung: In Etappe 3b wurde die `DataTable` nach dem Einstellungsfenster -zugeschnitten und kann deshalb keine zweite Spalte. Derselbe Fehler ein zweites Mal -wäre vermeidbar gewesen. - -### 5. Mehrspaltige Tabelle: Zustand und Regeln, mit Tests — **erledigt** - -`DataTableState` mit zehn Testfällen. Die Zeilenidentität war die Kernentscheidung, -und beim Lesen der drei Zeilentypen zeigte sich: **es gibt zwei Sorten Liste, und eine -Antwort reicht nicht.** - -| Sorte | Beispiele | Identität | -|---|---|---| -| Entitätsliste | Stationsliste, gearbeitete Stationen | eigener Schlüssel, `RowKeys.byValue` mit dem rohen Rufzeichen | -| Ereignisliste | DX-Cluster, QSO der anderen | `RowKeys.byReference` | - -`ClusterMessage` und `ChatMessage` haben **gar keinen Schlüssel**. Einer aus Zeit, -Absender und Text kollidiert, sobald jemand dasselbe Makro zweimal in derselben -Sekunde schickt — genau die Falle „Text lässt Dubletten verschmelzen" aus Etappe 2. -Zwei gleiche Nachrichten *sind* zwei Nachrichten. `byReference` vergibt Schlüssel beim -ersten Sehen und merkt sie sich an der Objektreferenz; das trägt, weil die Roster bei -jedem Schnappschuss dieselben Instanzen zurückgeben. - -**Es gibt bewusst keine Vorgabe.** In Etappe 2 kamen die sichtbaren Fehler aus der -Zeilenidentität; der Aufrufer zu zwingen, sie zu benennen, hält die Entscheidung dort, -wo das Wissen sitzt. - -Zwei weitere Regeln sind festgeschrieben: - -- **Die Auswahl folgt der Zeile, nicht der Position.** Kommt oben etwas dazu, bleibt - die gewählte Station dieselbe. Verschwindet sie, wird die Auswahl **gelöscht** statt - auf den Nachbarn zu rutschen — sonst zeigt der Operateur auf jemanden, den er nie - gewählt hat. -- **Die Sortierung ist stabil.** Gleiche Zeilen behalten ihre Ankunftsreihenfolge. Die - JavaFX-`SortedList` fügte per Binärsuche ein und legte Gleichstände irgendwohin, - weshalb Zeilen sprangen. - -Die ursprüngliche Beschreibung der Aufgabe: - - -Ohne Oberfläche prüfbar: Spaltendefinition, Sortierung, Auswahl, Zeilenidentität. - -**Die Zeilenidentität ist der kritische Punkt.** In Etappe 2 kamen die Fehler nicht -aus der Tabelle, sondern aus der Kopplung zwischen Roster und Anzeige: ein -Listenschlüssel darf nicht die Position sein (verschiebt sich beim Einfügen oben) und -nicht der Text (fällt bei Dubletten zusammen). Das ist hier erneut zu entscheiden und -festzuhalten. - -**Der Zustand hält alles, womit gemalt wird, in Compose-Zustand.** Aus Etappe 3b: -gewöhnliche Eigenschaften melden nichts, und bei Live-Daten fällt das nicht als -„reagiert nicht" auf, sondern als veraltete Anzeige. - -### 6. Die Tabelle als Compose-Komponente - -Spaltenbreiten werden über die bestehende `TableLayoutManager`-Kennung entprellt -gespeichert. Die Kennungen `dx-cluster-monitor` und `qso-other-monitor` bleiben -getrennt von denen des Hauptfensters, wie `PROJECT_CONTEXT.md` festlegt. - -### 7. Monitor-Fenster in Compose - -Titel „Cluster & QSO of the other", geteilte Fläche mit den zwei Tabellen, Größe aus -`GUIclusterAndQSOMonStage_SceneSizeHW`. Die Teilerposition wird gespeichert wie -bisher. - -### 8. Lasttest mit dem Simulator - -Wie in Etappe 2, denn dort hat genau das die Sortier- und Fokusfehler gefunden — kein -Test hat sie gesehen. Eine Stunde Dauerlast. Beobachtet wird: springt die Auswahl, -kippt die Sortierung, bleibt die Anzeige aktuell, wächst der Speicherbedarf. - -### 9. Den JavaFX-Monitorblock entfernen und nachmessen - -Erst wenn der Lasttest steht. - -## Was unverändert bleiben muss - -- Layout-Kennungen getrennt von denen des Hauptfensters. -- `GUIclusterAndQSOMonStage_SceneSizeHW` und `GUIstage_updateStage_SceneSizeHW` - behalten Namen und Bedeutung; `configVersion` bleibt 7. -- Die Update-Prüfung selbst wird nicht angefasst — nur ihre Anzeige. - -## Offene Punkte - -1. ~~**Die macOS-Systemmenüleiste.**~~ **Geklärt.** Compose kennt `MenuBar()` im - Geltungsbereich von `Window()`, und auf macOS erscheint sie in der Systemmenüleiste - am oberen Bildschirmrand, sobald das Fenster aktiv ist — genau das Verhalten, das - `setUseSystemMenuBar(true)` heute erzeugt. Eine *gemeinsame* Leiste über mehrere - Fenster gibt es nicht: jedes Fenster erklärt seine eigene. - - Das passt zum heutigen Vorgehen, nur anders herum. `installSharedSystemMenuBar` - spiegelt die Menüs des Hauptfensters über `Bindings.bindContent` in eine - unsichtbare Leiste jedes Nebenfensters. In Compose wird daraus eine gemeinsame - Composable-Funktion, die jedes Fenster aufruft — eine Quelle, fünf Aufrufstellen. - - **Ein Punkt bleibt zu beachten:** die Menüeinträge werden heute aus - `onConnectionStateChanged` aktiviert und deaktiviert (`menuItemFileDisconnect`, - `menuItemOptionsAwayBack` und weitere). Deren Zustand muss beobachtbar sein, sonst - wiederholt sich der Fehler aus Etappe 3b an einer Stelle, an der er besonders - schlecht auffällt: ein Menüeintrag, der falsch aktiviert aussieht. - - Quelle: [Menu bar, Kotlin Multiplatform Documentation](https://kotlinlang.org/docs/multiplatform/compose-desktop-menu-bar.html) -2. **Entprellte Spaltenbreiten.** `TableLayoutManager` hängt an JavaFX-`TableColumn`. - Ob die Compose-Tabelle ihn benutzt oder ein Gegenstück bekommt, entscheidet sich - mit Aufgabe 4. diff --git a/docs/superpowers/plans/2026-09-28-etappe5-hauptfenster.md b/docs/superpowers/plans/2026-09-28-etappe5-hauptfenster.md deleted file mode 100644 index 4c10a9ce..00000000 --- a/docs/superpowers/plans/2026-09-28-etappe5-hauptfenster.md +++ /dev/null @@ -1,91 +0,0 @@ -# Umsetzungsplan Etappe 5 — Hauptfenster - -Spezifikation: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 5. - -## Der Umfang, gemessen - -| Teil | Zeilen | Besonderheit | -|---|---|---| -| Stationsliste `initChatMemberTable` | 854 | 22 Spalten, 13 Zellfabriken, 1 Zeilenfabrik, 4 Vergleicher | -| Filterleiste | ~977 | 37 Bedienelemente, davon **25 Umschaltknöpfe** | -| Infobereich `generateFurtherInfoAbtSelectedCallsignBP` | 881 | eigener Teiler mit gespeicherter Position | -| Menüleiste (File, Options, Windows, Help) | ~343 | macOS-Systemmenüleiste | -| `TimelineView` | 376 | eigene Datei, eigene Zeichnung | -| Top-Prioritätenliste | 69 | | -| Statusanzeigen | ~40 | Sked-Warnung, Verbindung, Band-Upgrade | - -Über **3500 Zeilen**. Etappe 3 war kleiner und wurde geteilt. - -**Der Infobereich ist so groß wie die Stationsliste.** In der Spezifikation steht er -als einer von sieben Aufzählungspunkten; das untertreibt ihn um eine Größenordnung. - -## Aufteilung in 5a, 5b und 5c - -Nicht aus Ordnungsliebe, sondern weil eine Etappe lieferbar sein soll. Nach jedem -Abschnitt läuft die Anwendung, und du kannst abnehmen, bevor der nächste anfängt. - -### 5a — Stationsliste und Filterleiste - -Der Kern. Die mehrspaltige Tabelle aus Etappe 4 wurde **für diese Liste** zugeschnitten -und trägt hier ihre erste echte Last: 22 Spalten, Zellfärbung aus Zeile *und* Wert, -Zeilenfärbung nach `getCurrentPriorityScore()` über vier Schwellen, eigene Vergleicher, -23 gespeicherte Spaltenbreiten. - -Die 25 Umschaltknöpfe der Filterleiste sind kein Formular, sondern Zustand mit Regeln — -welche Filter einander ausschließen, welche sich stapeln. Die Regeln gehören in einen -testbaren Zustand, bevor ein Knopf gezeichnet wird. - -**Abnahme:** Die Liste zeigt dieselben Stationen in derselben Reihenfolge wie JavaFX, -die Filter wirken gleich, Spaltenbreiten überleben den Neustart. - -### 5b — Infobereich, Top-Prioritätenliste, Chat-Reiter - -Der Infobereich hängt an der Auswahl der Liste aus 5a — deshalb danach und nicht -davor. Sein Teiler wird hier einmal richtig gebaut; im Monitor-Fenster habe ich ihn -bewusst weggelassen, statt ihn zweimal halb zu bauen. - -**Abnahme:** Auswahl in der Liste füllt den Infobereich wie bisher, Teilerpositionen -werden entprellt gespeichert. - -### 5c — Menüleiste, Statusleiste, TimelineView, Profilwechsel - -Die Menüleiste wird eine gemeinsame Composable-Funktion, die jedes Fenster aufruft — -so verlangt es Compose, und so bekommen die Compose-Fenster auf macOS ihre -Systemmenüleiste zurück, die sie seit Etappe 3b nicht haben. - -**Der Profilwechsel ändert seine Natur.** Heute reißt er die Laufzeit ab und baut eine -neue `Kst4ContestApplication` auf, weil viele Bedienelemente inline erzeugte -Instanzfelder sind. Dieser Grund entfällt; der Wechsel wird zum Austausch des -Zustandsobjekts. `shutdownRuntime()` bleibt trotzdem verantwortlich für alles, was eine -Verbindung überdauert. - -**Abnahme:** Profilwechsel im laufenden Betrieb, ohne doppelt angemeldete Zuhörer und -ohne gehaltene Ressourcen. Auf macOS erscheint die Menüleiste bei jedem Fenster. - -## Was aus Etappe 4 mitgenommen wird - -Vier Fehler dieser Etappe kamen **ausschließlich** durch Hinsehen ans Licht, keiner -durch einen Test, und der Prüfer fand nur einen Teil davon: - -1. Ein Zwischenspeicher, der den Zustand nicht mehr las, und eine Tabelle, die deshalb - nie wieder zeichnete. -2. Eine Kopfzeile, die die ganze Tabellenhöhe nahm. -3. Eine Liste, die bei jeder neuen Zeile vom Kopf wegdriftete. -4. Ein Menüeintrag, der seinen Umschalter verloren hatte. - -Daraus drei Regeln für diese Etappe: - -- **Was gezeichnet wird, muss beim Lesen Zustand berühren.** Der `Snapshot.observe`-Test - aus Etappe 4 fängt genau das und wird hier für jede neue Ansicht angelegt. -- **Nach jedem sichtbaren Teil wird gestartet und hingesehen**, nicht erst am Ende des - Abschnitts. -- **Kein Verhalten wird weggelassen, um ein Darstellungsproblem zu umgehen.** Der - verlorene Umschalter war genau dieser Tausch. - -## Offene Punkte - -1. Ob `Platform.setImplicitExit(false)` und `ApplicationRuntimeLauncher.exitApplication()` - in der Compose-Fassung noch eine Entsprechung brauchen — laut Spezifikation nach - Etappe 4 zu klären, also jetzt. -2. Ob die Filterleiste ihre 25 Knöpfe behält oder ob mehrere davon derselbe Zustand - sind. Vor dem Zeichnen zu lesen, nicht danach. diff --git a/docs/superpowers/plans/2026-09-28-etappe5c-task13-hauptfenster-zusammenbau.md b/docs/superpowers/plans/2026-09-28-etappe5c-task13-hauptfenster-zusammenbau.md deleted file mode 100644 index 564df866..00000000 --- a/docs/superpowers/plans/2026-09-28-etappe5c-task13-hauptfenster-zusammenbau.md +++ /dev/null @@ -1,153 +0,0 @@ -# Umsetzungsplan Etappe 5c, Task 13 — das Hauptfenster zusammensetzen - -Task 13 stand im Etappenplan als **ein** Schritt: „das Hauptfenster zusammengesetzt, das -JavaFX-Fenster ersetzt und entfernt". Nach dem Vermessen der Vorlage ist das nicht ein -Schritt, sondern fünf. Dieser Plan sagt, welche, und warum die Reihenfolge so ist. - -## Der Baum des JavaFX-Fensters - -`start()` reicht von Zeile 7236 bis 9400 — **2164 Zeilen für ein Fenster**. Was darin -steht: - -``` -Stage (primaryStage) Größe aus GUIscn_ChatwindowMainSceneSizeHW, -└─ Scene auf den Schirm begrenzt - └─ BorderPane bPaneChatWindow - ├─ top: FlowPane flwpne_StatusBar ✅ Task 10 + 11 - └─ center: SplitPane links (waagerecht, 1 Trenner) - ├─ SplitPane Nachrichtenteil (senkrecht, 4 Trenner) - │ ├─ privateMessageTable ❌ fehlt - │ ├─ FlowPane flwPane_textSnippets ❌ fehlt - │ ├─ VBox pnl_inputAndSendButtons → TimelineView ✅ Task 12 - │ ├─ HBox textInputFlowPane ❌ fehlt - │ └─ TabPane bottomGlobalMessageTabPane ✅ Etappe 5b - └─ SplitPane rechts (senkrecht, 2 Trenner) - ├─ BorderPane chatMemberTableBorderPane ✅ Etappe 5a - ├─ topPriorityListPane ✅ Etappe 5b - └─ selectedCallSignFurtherInfoPane ✅ Etappe 5b -``` - -Sechs von zehn Bausteinen stehen. Die vier fehlenden sind nicht die kleinen. - -## Was fehlt, mit Zeilen - -| Fehlt | Vorlage | Bemerkung | -|---|---|---| -| Tabelle der gerichteten Nachrichten | `initChatprivateMSGTable` ab 3730 | eigener Bereich im Splitter, nicht in `MessageTabs` | -| Knopfreihe der Textschnipsel | `flwPane_textSnippets` 7890, `buttonFactory` 5233, `refreshShortcutButtons` 4541 | Der **Bestand** liegt schon in `ShortcutsTabState`; nur die Knöpfe fehlen | -| Sendezeile | `textInputFlowPane` 7886 | Eingabefeld, `TX`, `Clear`, Trenner, eigene QRG Haupt-/Zweitkategorie, eigener QTF | -| Kontextmenüs | `initChatMemberTableContextMenu` 3132, `insertTextSnippet` 7473 | Stationsliste **und** Nachrichtentabellen; Strg+1…Strg+0 für Schnipsel | -| Fensterrahmen | `getScreenAwareMainSceneSizeHW` 7178, `ensureStageFitsPrimaryScreen` 9396, `buildOperatorProfileTitleSuffix` 6595 | Größe merken und begrenzen, Titel mit Profilzusatz | -| **Splitter mit gemerkten Trennern** | 3 `SplitPane`, 7 Trenner | **Compose hat das nicht** — siehe unten | - -## Das eine Stück, das Compose nicht mitbringt - -`SplitPane` gibt es in Compose Multiplatform nicht. Sieben Trennerstellungen werden -heute in den Einstellungen gemerkt: - -```java -GUImainWindowLeftSplitPane_dividerposition = {0.51} -GUImainWindowRightSplitPane_dividerposition = {0.53, 0.78} -GUImessageSectionSplitpane_dividerposition = {0.62, 0.7, 0.75, 0.9} -``` - -Das ist kein Beiwerk: ein Operator richtet sein Fenster einmal für seinen Schirm ein und -erwartet es beim nächsten Contest genauso. Wir brauchen also eine eigene, gezogene -Trennleiste mit gemerkter Stellung — dasselbe Muster wie die Spaltengriffe aus Etappe 4 -(`Density.COLUMN_HANDLE_WIDTH`, Haarlinie in 5 dp Greiffläche), nur senkrecht wie -waagerecht und mit Anteilen statt Pixeln. - -`ChatPreferences` liefert die Felder als `double[]`; die Compose-Fassung braucht sie als -Zustand, sonst zeichnet das Ziehen nicht mit. Das ist derselbe Fehler wie in Etappe 3b -und wird gleich als solcher behandelt. - -## Aufteilung - -### 13a — `SplitterState` und `SplitterPane` - -Ein `n`-teiliger Splitter, waagerecht oder senkrecht, mit `n−1` Trennern als -Compose-Zustand, Mindestgrößen pro Bereich und Schreiben nach `ChatPreferences` beim -Loslassen (nicht bei jeder Mausbewegung — die JavaFX-Fassung hat bei jedem Pixel -`requestLayoutSave()` gerufen und dabei auf die Platte geschrieben). - -**Tests:** Anteile summieren sich zu 1; ein Trenner schiebt keinen anderen vorbei; eine -Mindestgröße wird eingehalten; ein abgelehntes Ziehen schreibt nichts (das ist der -aufgenommene Kleinmangel aus Etappe 4 bei `resizeColumn`); `Snapshot.observe` auf die -Stellungen; ein Bestand mit zu wenigen gemerkten Werten fällt auf die Vorgaben zurück, -statt zu stürzen. - -**Abnahme:** Prüfstand mit fünf Bereichen, Ziehen in beiden Richtungen. - -### 13b — Sendezeile und Schnipsel-Knöpfe - -Die Zeile, in der der Operator tatsächlich tippt. `ChatInputState` mit dem Text, dem -Ziel (gewählte Station oder Hauptkategorie), den beiden eigenen QRG-Feldern und dem QTF. -Darüber die Knopfreihe aus dem Schnipsel-Bestand, die sich neu baut, wenn die -Einstellungen sie ändern — `ShortcutsTabState` ruft dafür schon -`refreshShortcutButtons`, das heute noch ins Leere zeigt. - -**Tests:** Eingabetaste sendet und leert; `Clear` leert ohne zu senden; die Zielauflösung -in der Reihenfolge der Vorlage (gewählte Station → Tabellenauswahl → `ScoreService` → -Hauptkategorie); ein Schnipsel wird an der Einfügemarke eingesetzt, nicht angehängt; -`Snapshot.observe` auf Text und Ziel. - -**Risiko:** Das Feld hat in JavaFX den Fokus zurückgeholt (`requestFocus`, -`selectEnd`) — in Compose ist das ein `FocusRequester` und muss nach jedem Senden und -nach jedem Schnipsel wieder greifen. Genau hier hat Etappe 3b Fehler gehabt. - -### 13c — Gerichtete Nachrichten und die Kontextmenüs - -Die vierte Tabelle, mit `DataTableState` wie die anderen, und die Kontextmenüs, die es -in Compose noch gar nicht gibt: Rechtsklick auf eine Station (Einträge aus dem -gemerkten Bestand), auf eine Nachricht, und Strg+1…Strg+0 für die Schnipsel. - -**Tests:** die Spalten; welche Einträge bei welcher Auswahl erscheinen; dass ein -Menüeintrag ohne Auswahl nichts sendet. - -### 13d — Der Fensterrahmen - -Größe aus den Einstellungen, auf die Arbeitsfläche des Schirms begrenzt (die Vorlage tut -das, weil ein Fenster von einem größeren Monitor sonst unbedienbar aufgeht), Speichern -bei Änderung, Titel aus Chatzustand und Profilzusatz, Schließen über `ComposeWindowHost`. - -**Tests:** die Begrenzungsrechnung mit erfundenen Schirmgrößen (das ist reine Rechnung -und gehört nach `core`-Art getestet); der Titel bei gesetztem und fehlendem Profil. - -### 13e — Zusammensetzen, ersetzen, entfernen - -Erst hier entsteht `MainWindow.kt`, und erst hier fällt der JavaFX-Teil. Reihenfolge: - -1. `MainWindow` zusammensetzen, **neben** dem JavaFX-Fenster startbar (beide offen, - Vergleich am Bildschirm — so sind in Etappe 4 die Unterschiede aufgefallen). -2. Abnahme durch den Operator, mit laufender Verbindung. -3. Erst danach `start()` ausräumen und die Zeilenzahl festhalten. - -**Abnahme:** ein Contest-Ablauf von Hand — anmelden, Station wählen, tippen, senden, -Sked setzen, Profil wechseln — ohne dass ein Zuhörer doppelt hängt. - -## Reihenfolge und Begründung - -13a zuerst, weil ohne Splitter kein Fenster zusammengeht. 13b vor 13c, weil die -Sendezeile das ist, was der Operator die meiste Zeit benutzt; ein Fehler dort wiegt mehr -als eine fehlende Tabelle. 13d ist klein und unabhängig. 13e zuletzt und mit beiden -Fenstern offen. - -## Was aus Etappe 4 und 5 mitkommt - -- Nach **jedem** sichtbaren Teil starten und hinsehen. Elf Fehler dieser Umstellung sind - ausschließlich durch Hinsehen aufgefallen, keiner durch einen Test. -- `Snapshot.observe` für jede neue Ansicht. -- Kein Verhalten weglassen, um ein Darstellungsproblem zu umgehen. -- Vor dem Schreiben die Vorlage lesen, nicht nach dem Schreiben. Die falschen - Zuschreibungen dieser Etappe kamen alle daher, dass ich nach dem Namen geraten habe. - -## Offene Entscheidungen - -1. **Die vier Nachrichtenfilter** und **„Always on top"** sind aus Etappe 5b nach 5c - verschoben. Gehören sie in 13b (bei der Sendezeile) oder in 13c (bei den Tabellen)? - Vorschlag: 13c, weil sie Tabellenfilter sind. -2. Der Kleinmangel `GUIpnl_directedMSGWin_dividerposition` ist verwaist — die Vorlage - hat nur den `...Default`-Wert. In 13a wegwerfen oder stehen lassen? - Vorschlag: wegwerfen, mit einem Satz in der Einbuchung. -3. **Der dritte Sortierklick** (Sortierung löschen) fehlt seit Etappe 4. In 13c - mitnehmen oder eigene Kleinigkeit? diff --git a/docs/superpowers/plans/2026-10-01-etappe6-karte-compose-parity.md b/docs/superpowers/plans/2026-10-01-etappe6-karte-compose-parity.md deleted file mode 100644 index 919d3692..00000000 --- a/docs/superpowers/plans/2026-10-01-etappe6-karte-compose-parity.md +++ /dev/null @@ -1,200 +0,0 @@ -# Umsetzungsplan Etappe 6 — Karte: Angleichung an die Leaflet-Fassung - -Spezifikation: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 6. - -## Was dieser Plan ist — und was nicht - -Etappe 6 hat zwei Hälften: die Karte nach Compose bringen, und JavaFX aus dem Build -werfen. Die erste Hälfte wurde bereits angefangen (im Übergabeprotokoll „Task 18/19"), -und das Ergebnis trägt nicht. Dieser Plan ist die **Nachbesserung dieser ersten -Hälfte**. Das Entfernen von JavaFX und der dafür nötige Ersatz von -`JavaFxStylesheet.derive` bleiben ausdrücklich außen vor. - -Der Anlass ist nicht Geschmack. Die Compose-Karte bildet mehrere Mechanismen der -Leaflet-Fassung **gar nicht** ab, nicht bloß anders. Drei davon liegen fertig und -getestet in `core` und werden nicht aufgerufen. - -## Beweislage - -Verglichen wurde `github_docs/station_map_compact.png` und -`github_docs/station_map_path_analysis.png` (JavaFX) gegen den Stand vom 30.09. -(`scratch/screenshots/AAA011.png`, `AAA012.png`). - -| # | Befund | Stelle | -|---|---|---| -| 1 | `MaidenheadGridRenderPlanner` wird nicht aufgerufen — jede Zelle **und jedes Label** wird gezeichnet | `ComposeStationMap.kt:286` gegen `StationMapView.java:1301` | -| 2 | Gitterlabel in der Ecke statt in der Zellenmitte, weiß statt `#63067a` | `ComposeStationMap.kt:307` gegen `MapHtmlResources.java:302` | -| 3 | Gebrochener Zoom mit `scale()` über die ganze Zeichenfläche; alle Größen werden mit `/ scaleFactor` gegengerechnet | `ComposeStationMap.kt:243` | -| 4 | Clustering: 50 px fest, ab 2 Stationen, immer an — statt 55/70/95/125 px je Zoom, ab 3 Stationen, aus ab Zoom 8 | `ComposeStationMap.kt:68` gegen `MapHtmlResources.java:393-410`, `:758` | -| 5 | Kopfzeile: nur „Selected: X" statt der sechs Felder; rohe Material-3-Knöpfe statt `Fields.button` | `StationMapWindow.kt:159` gegen `StationMapView.java:571`, `:1010` | -| 6 | Zoomknöpfe und OSM-Attribution fehlen | — | -| 7 | Kein Kartenhintergrund; fehlende Kacheln zeigen Systemgrau | gegen `MapHtmlResources.java:57`, `:70` | -| 8 | `TileFetcher`: abgekündigte a/b/c-Subdomains, nur RAM-Cache, Fehler werden still verschluckt | `TileFetcher.java:44` | -| 9 | Absturz „maxWidth must be >= than minWidth", übertüncht durch eine Sperre, die das Fenster unter 500×400 dp leer lässt | `StationMapWindow.kt:151`, `:329` | - -**Der dritte Befund ist die Wurzel von mehreren anderen.** Weil die gesamte -Zeichenfläche skaliert wird, hängen Punktradius, Strichstärke und Schriftgröße am -Zoom-Bruchteil. In der Leaflet-Fassung war ein Marker **immer** 12 px und ein Label -**immer** 12 px, weil Leaflet Overlays in Bildschirmkoordinaten zeichnet. Solange der -`scale()`-Block steht, ist jede Farb- und Größenkorrektur nur an einer Zoomstufe -richtig — und genau das erklärt, warum mehrere Anläufe nacheinander „fast richtig" -aussahen. - -## Getroffene Entscheidungen - -| Frage | Entscheidung | -|---|---| -| Zielbild | Nah genug an Leaflet, eigene Handschrift in Details erlaubt — gleiche Informationsdichte und Bedienlogik sind Pflicht | -| Zoom | Weich zoomen, aber **Overlay in Bildschirmkoordinaten**. Nicht der ganzzahlige Zoom der JavaFX-Fassung | -| Absturz | Zuerst reproduzieren und beheben, dann umbauen | -| Kacheln | `TileFetcher` reparieren und um einen Plattencache erweitern | - -**Abweichung von der Spezifikation, bewusst.** Die Spezifikation schreibt „Kacheln -über den bestehenden `TileProxyServer`". Der Proxy existiert, weil die JavaFX-WebView -in AppImage- und Flatpak-Verpackung an TLS scheiterte — ein Compose-Canvas holt die -Kacheln selbst und hat dieses Problem nicht. Der Proxy bleibt für den JavaFX-Pfad -stehen, bis dieser entfällt. - -**Der ganzzahlige Zoom war ebenfalls ein WebView-Zugeständnis**, nicht Absicht: -`MapHtmlResources.java:884` begründet `zoomSnap: 1` mit unzuverlässiger -Markerpositionierung in der JavaFX-WebView. Der Grund entfällt mit der WebView. - -## Reihenfolge - -Erst der Absturz, dann die Zeichenebene, dann die Fachregeln, zuletzt die Hülle. -Umgekehrt müsste jeder Schritt gegen einen Fehler arbeiten, der ihn jederzeit -abbrechen kann. - -## Aufgaben - -### 1. Absturz beheben, Sperre entfernen - -`StationMapWindow.kt:328-330` legt in **eine** `Row` ein `Text` mit fester -`Modifier.width(130.dp)` und daneben eine `Box(Modifier.weight(1f))`. Wird das rechte -Pane schmaler als diese 130 dp samt Innenabstand, bleibt für das gewichtete Kind eine -**negative** Restbreite — und daraus baut Compose die `Constraints`, die mit -„maxWidth must be >= than minWidth, maxHeight must be >= than minHeight, minWidth and -minHeight must be >= 0" abbrechen. - -Dazu fehlt dem Detailbereich die Mindestbreite, die JavaFX hatte: -`detailScrollPane.setMinWidth(210)` und `setPrefWidth(350)`, `StationMapView.java:468`. - -- Reproduzieren: Teiler ganz nach rechts ziehen. -- Feste Breite durch `weight` ersetzen, wie es `DetailRow` bereits macht. -- Mindestbreite des Detailbereichs im `SplitterState` verankern, nicht im Fenster. -- **Erst danach** die Sperre `BoxWithConstraints { if (maxWidth < 500.dp …) return@… }` - in Zeile 151 entfernen. - -**Abnahme:** Der Teiler lässt sich über die gesamte Breite ziehen, ohne dass ein -Dialog erscheint, und das Fenster bleibt bei jeder Größe gezeichnet. - -### 2. Zeichenebene: Welt- gegen Bildschirmkoordinaten trennen - -Eine Projektionshilfe (Zentrum, Zoom als `Float`, Canvasgröße) rechnet den gebrochenen -Zoom über `2^zoom` direkt ein, statt mit `floor(zoom)` zu rechnen und die Zeichenfläche -hinterher zu skalieren. - -- Nur die Kachelschicht wird skaliert gezeichnet: Kacheln der Stufe `floor(zoom)`, - Zielgröße über den Skalierungsfaktor. -- Gitter, Marker, Labels, Antennenkeule, Pfadlinie und Kurzhinweis zeichnen - **unskaliert in Bildschirmpixeln**. -- `scale()` und sämtliche `/ scaleFactor` entfallen. -- Kartenhintergrund `#ede9df` hell, `#23282d` dunkel als erste Zeichenoperation. -- Zoombereich 3–18 wie Leaflet; Compose begrenzt heute auf 4–18. - -**Abnahme:** Ein Marker behält seine Größe über den gesamten Zoombereich. Bei -abgeschalteten Kacheln ist die Fläche in der Kartenfarbe, nicht in Systemgrau. - -### 3. Gitter über den vorhandenen Planer - -`MaidenheadGridRenderPlanner.createPlan(floor(zoom), bbox, canvasW, canvasH)` aufrufen, -Zellen über `buildVisibleCells(…, plan.precision())` holen. - -- Label nur bei `plan.shouldShowLabel(cell)`, Schriftgröße aus `plan.labelFontSizePx()`. -- Label **mittig** in der Zelle, in `#63067a` auf halbdurchsichtigem Kästchen. -- Linien `#e1e7ec` bei 48 % dunkel, `#46586c` bei 56 % hell, Strichstärke 1.4. - -Der Planer ist vorhanden und wird nicht geändert. - -**Abnahme:** Bei weitem Zoom Zweistellen-Felder, bei mittlerem die vierstelligen -Quadrate wie in `station_map_compact.png`; keine Labelteppiche. - -### 4. Clustering nach den Leaflet-Regeln - -`StationMapClusterer` in `core` vom Nächster-Nachbar-Verfahren auf das Bildschirmraster -der Leaflet-Fassung umstellen: - -- Zellgröße 55 px ab Zoom 7, 70 px ab 6, 95 px ab 5, darunter 125 px. -- Bucket über `floor(bildschirmX / zellgröße)`. -- Cluster **erst ab 3** Stationen; darunter werden die Stationen einzeln gezeichnet. -- Clustering **aus ab Zoom ≥ 8**. -- Ausgewählte Stationen und solche mit `warningToMyDirection` werden **nie** geclustert. -- Blasendurchmesser 32 / 36 / 42 px ab 1 / 8 / 20 Stationen. - -Das sind Fachregeln, keine Zeichenregeln — sie gehören mit Tests nach `core`. Die -Prüfung der Konstanten gegen `MapHtmlResources.java:393-410` und `:758` ist Teil der -Aufgabe. - -**Abnahme:** Tests in `core` decken die vier Zellgrößen, die Schwelle von drei -Stationen, die Abschaltung ab Zoom 8 und beide Ausnahmen ab. - -### 5. Kopfzeile und Bedienelemente - -- Statuszeile mit dem vollen Text aus `updateStatusLabel()` - (`StationMapView.java:1010`): `Showing N visible stations`, optional - `| filtered view active`, `| Selected: …`, Locator, `km / °`, Bänder, QRG. -- Statuszeile mit `Modifier.weight(1f)`, damit die Knöpfe rechtsbündig stehen. -- `Fields.button` (`FormControls.kt:367`) und `CompactCheckbox` (`:556`) statt roher - Material-3-Bedienelemente. Die grünen Pillen entstehen, weil `Theme.kt:97` - `primary` auf den Grünton des Abend-Stylesheets legt und Material `Button` genau - diese Rolle als Flächenfarbe nimmt. -- Zoomknöpfe oben links als Überlagerung. -- OSM-Attribution unten rechts. Die ist bei OSM-Kacheln lizenzrechtlich nicht - freigestellt. - -**Abnahme:** Die Kopfzeile zeigt dieselben Angaben wie `station_map_compact.png` und -sieht aus wie die übrigen Compose-Fenster. - -### 6. Kacheln - -- Host `tile.openstreetmap.org`; die Subdomains `a`/`b`/`c` sind abgekündigt. -- Plattencache im Profilverzeichnis, nicht im Arbeitsverzeichnis. -- Fehler nach außen melden, damit die Karte „Kacheln nicht erreichbar" anzeigen kann, - statt wie in `AAA012.png` kommentarlos grau zu bleiben. -- `User-Agent` bleibt wie bisher; die OSM-Nutzungsbedingungen verlangen einen - sprechenden Wert. - -**Abnahme:** Nach einem Neustart ohne Netz zeigt die Karte die zuletzt geholten -Kacheln aus dem Plattencache und einen Hinweis für die fehlenden. - -## Was unverändert bleibt - -- Der **gesamte JavaFX-Pfad**: `StationMapView.java`, `MapHtmlResources.java`, - `StationMapBridge.java`, `TileProxyServer.java`. Sie sind bis zum Entfernen von - JavaFX die Referenz. -- Die Fachlogik in `core` wird **benutzt, nicht geändert**: `PathAnalysis*`, - `Terrain*`, `PathGeometryUtils`, `MapCallsignRawSnapshotBuilder`, - `MaidenheadGridUtils`, `MaidenheadGridRenderPlanner`. -- `GUIstationMapClusteringEnabled` bleibt eine Layout-Einstellung unterhalb - `guiOptions` mit Vorgabewert `true`. -- Zurücksetzen der Ansicht lässt den Zoom in Ruhe und löscht nur das Ziel (AGENTS.md). -- Die `/cq`-Vorbelegung bei Stationsauswahl. -- Der Zoom auf die Bounding Box zwischen eigenem Standort und Zielstation. - -## Abnahmekriterium der Etappe - -Die Karte zeigt dieselben Informationen wie die Leaflet-Fassung. Offline-DEM, -Terrain-Pakete und der Profil-Cache funktionieren unverändert. - -Das zweite Kriterium der Spezifikation — keine JavaFX-Abhängigkeit mehr im Projekt — -gehört zur zweiten Hälfte der Etappe und ist hier **nicht** erfüllt. - -## Offene Punkte - -- Leistungsverhalten des Canvas bei mehreren tausend Markern. Die Spezifikation nennt - als Ausweg eine Vorabaggregation im Kern; Schritt 4 ist genau diese Vorstufe und - sollte vor einer Messung fertig sein. -- `JavaFxStylesheet.derive` ruft `com.sun.javafx.util.Utils.deriveColor` über - Reflexion auf. Das muss **vor** dem Entfernen von JavaFX ersetzt werden, sonst - verschieben sich alle abgeleiteten Farben still. Nicht Teil dieses Plans, aber Teil - dieser Etappe. diff --git a/docs/superpowers/plans/2026-10-01-javafx-entfernen-teil1.md b/docs/superpowers/plans/2026-10-01-javafx-entfernen-teil1.md deleted file mode 100644 index d9bfe690..00000000 --- a/docs/superpowers/plans/2026-10-01-javafx-entfernen-teil1.md +++ /dev/null @@ -1,506 +0,0 @@ -# JavaFX entfernen, Teil 1: Bestandsaufnahme und `derive`-Pin - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Den `derive`-Pin so einfrieren, dass er das Entfernen von JavaFX überlebt, und belegen, welcher Teil des nie angezeigten JavaFX-Aufbaus folgenlos gelöscht werden kann. - -**Architecture:** Zwei voneinander unabhängige Stränge. Der erste ersetzt einen Reflexionsaufruf im Test durch eingecheckte Daten — klein, testgetrieben, zeitkritisch. Der zweite ist Verifikationsarbeit an `Kst4ContestApplication`: für jede verdächtige Bindung wird durch Lesen **und** durch einen Lauf der Anwendung festgestellt, ob sie den Betrieb speist oder nur die tote Tabelle. Ergebnis ist ein Befunddokument, auf dem Teil 2 aufsetzt. - -**Tech Stack:** Java 21, Kotlin 2.2, Gradle, JUnit 5, Compose Multiplatform 1.8.2, JavaFX 21 (noch vorhanden). - -**Spec:** `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md` - -## Warum nur Teil 1 - -Die Spezifikation hat sechs Etappen. Etappe 2 (toten Aufbau löschen), 4 (Dispatcher), 5 (Lebenszyklus) und 6 (Reste und Build) lassen sich heute **nicht ohne Platzhalter** schreiben: welcher Block gelöscht wird und was vorher zu entflechten ist, ist das Ergebnis von Etappe 1. Ein Plan, der das vorwegnimmt, wäre geraten. - -Dieser Plan deckt Etappe 1 und Etappe 3 ab — die beiden, die heute vollständig spezifizierbar sind. Teil 2 wird geschrieben, wenn der Befund aus Aufgabe 5 vorliegt. - -## Global Constraints - -- Kommunikation mit dem Benutzer auf Deutsch; Quelltextkommentare und Javadoc ausschließlich auf Englisch (`AGENTS.md`). -- Keine neue Produktionsabhängigkeit ohne vorherige Freigabe (`AGENTS.md`). -- Kein Commit, Push, Merge, Tag oder Release ohne gesonderte ausdrückliche Freigabe (`AGENTS.md`). Die Commit-Schritte unten werden ausgeführt, weil dieser Plan sie benennt und der Benutzer ihn freigibt; alles darüber hinaus nicht. -- Der Build erlaubt historisch, dass Testfehler ignoriert werden. **Nicht** aus Exit-Code 0 auf „alle Tests grün" schließen — die XML-Ergebnisse unter `*/build/test-results/` lesen (`AGENTS.md`). -- Ausgangslage: `./gradlew clean build` ist grün, 766 Tests, 0 Fehler. Jede Rotfärbung ist ein Signal. -- Signierte Commits sind konfiguriert (`commit.gpgsign=true`, SSH). Schlägt das Signieren fehl, läuft der Bitwarden-Agent nicht — das ist kein Grund, unsigniert zu committen. -- In diesem Plan wird **keine** Produktionsdatei unter `src/main` gelöscht. Löschen ist Teil 2. - -## Review Focus - -Fünf Dinge, die die Spezifikation voraussetzt, die aber keine Aufgabe hier prüft, geordnet nach Wahrscheinlichkeit, dass sie jemanden treffen: - -1. **Der eingefrorene `derive`-Pin könnte die falschen Werte einfrieren.** Werden die Daten aus der eigenen Umsetzung statt aus JavaFX erzeugt, prüft der Test danach nur noch sich selbst. Aufgabe 1 erzeugt sie deshalb nachweislich über den Reflexionsaufruf und Aufgabe 2 prüft, dass mindestens ein Datensatz von einer absichtlich verfälschten Umsetzung abgelehnt wird. -2. **Die eingefrorenen Daten könnten den benutzten Wertebereich nicht abdecken.** `derive` wird mit 26.4 % und 35 % auf Stylesheet-Farben gerufen; Daten nur für Weiß und Schwarz hülfen nicht. Aufgabe 1 deckt die Prozentwerte und Farben ab, die der Test heute benutzt, und zusätzlich die Sprungstellen der Helligkeitsstaffel (0.2, 0.3, 0.4, 0.5, 0.6, 0.85). -3. **Der Befund aus Aufgabe 5 könnte eine Bindung übersehen, die nur bei bestimmten Einstellungen lebt.** Ein Profil mit anderen aktiven Bändern oder abgeschalteter Karte durchläuft andere Zweige. Aufgabe 4 prüft deshalb gegen ein zweites Profil. -4. **Ein Listener könnte nur beim Profilwechsel lebendig werden.** `closeOwnedStages` und der Wiederaufbau laufen nur dort. Aufgabe 4 schließt einen Profilwechsel ein. -5. **Die Systemmenüleiste könnte auf macOS anders leben als auf Linux.** Geprüft wird auf Linux; der Befund hält das ausdrücklich als ungeprüft fest, statt es offenzulassen. - ---- - -## File Structure - -| Datei | Verantwortung | Aufgabe | -|---|---|---| -| `app-desktop/src/test/resources/javafx-derive-reference.txt` | **neu** — die von JavaFX gelieferten Werte als eingecheckte Daten | 1 | -| `app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt` | **ändern** — vergleicht gegen die Daten statt gegen den Reflexionsaufruf | 1, 2 | -| `app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt` | **ändern** — nur der veraltete Kommentar | 2 | -| `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` | **neu** — der Befund, auf dem Teil 2 aufsetzt | 3, 4, 5 | - ---- - -## Task 1: Die JavaFX-Werte als Daten einfrieren - -**Files:** -- Create: `app-desktop/src/test/resources/javafx-derive-reference.txt` -- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt` -- Test: dieselbe Datei - -**Interfaces:** -- Consumes: `JavaFxStylesheet.derive(color: Color, percent: Double): Color` — unverändert. -- Produces: die Ressource `javafx-derive-reference.txt`, Zeilenformat - `;;`, z. B. `#ececec;26.4;#f4f4f4`. Drei - Kommentarzeilen am Anfang, jede mit `#`. Aufgabe 2 schreibt den Leser dafür. - Ausserdem bleibt `deriveWithJavaFx(rgb: String, percent: Double): String` im Test - erhalten — Aufgabe 2 entfernt nur seinen Aufruf aus dem laufenden Vergleich, nicht - die Methode. - -- [ ] **Step 1: Den bestehenden Test lesen, bevor etwas geändert wird** - -Run: `sed -n '1,60p;160,180p' app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt` - -Erwartet: `deriveWithJavaFx(rgb, percent)` ruft über `Class.forName("com.sun.javafx.util.Utils").getMethod("deriveColor", ...)` das echte JavaFX. Der Test `derive gives the values JavaFX gives` vergleicht drei Größen: die erwarteten Werte aus der Tabelle, `JavaFxStylesheet.derive` und `deriveWithJavaFx`. Notiere den genauen Namen der Hilfsmethoden `parse`, `hex` und `assertChannelsClose` — sie werden unten weiterbenutzt. - -- [ ] **Step 2: Einen Generator schreiben, der die Daten aus JavaFX erzeugt** - -Ein JUnit-Test, der die Datei schreibt statt etwas zu behaupten. Er wird einmal benutzt und bleibt danach als `@Disabled` stehen, damit nachvollziehbar ist, woher die Daten kommen. - -In `JavaFxStylesheetTest.kt` ergänzen: - -```kotlin - /** - * Writes the reference data the frozen test compares against. - * - * Disabled because it is a generator, not an assertion: it runs once, while JavaFX - * is still on the classpath, and its output is checked in. Kept rather than deleted - * so that the provenance of the data is readable — the values come from - * com.sun.javafx.util.Utils.deriveColor and from nowhere else, which is the whole - * point of pinning them. - * - * To regenerate: remove @Disabled, run, restore @Disabled. - */ - @org.junit.jupiter.api.Disabled("generator; run by hand while JavaFX is present") - @Test - fun `generate the JavaFX reference data`() { - val colours = listOf( - "#ffffff", "#000000", "#ececec", "#373e43", "#1d1d1d", "#4da6ff", - "#2b2b2b", "#808080", "#333333", "#4d4d4d", "#666666", "#999999", - "#d9d9d9", "#f4f4f4", "#63067a", "#ff9900", - ) - val percents = listOf(-80.0, -50.0, -35.0, -30.0, -10.0, 10.0, 26.4, 35.0, 50.0, 80.0) - - val lines = buildList { - for (rgb in colours) { - for (percent in percents) { - add("$rgb;$percent;${deriveWithJavaFx(rgb, percent)}") - } - } - } - - val target = java.io.File("src/test/resources/javafx-derive-reference.txt") - target.parentFile.mkdirs() - target.writeText( - "# Generated from com.sun.javafx.util.Utils.deriveColor by\n" + - "# JavaFxStylesheetTest.`generate the JavaFX reference data`.\n" + - "# Format: ;;\n" + - lines.joinToString("\n") + "\n" - ) - println("wrote ${lines.size} pairs to ${target.absolutePath}") - } -``` - -Die Farbliste deckt ab: Weiß und Schwarz (Ränder), die Modena-Basis `#ececec`, die Abendblech-Basis `#373e43`, Marker- und Gitterfarben aus der Karte, und Graustufen, die die Sprungstellen der Helligkeitsstaffel treffen (0.2, 0.3, 0.4, 0.5, 0.6, 0.85 — siehe Review Focus 2). Die Prozentwerte decken die im Produktionscode benutzten 26.4 und 35 ab, dazu negative und positive Extreme. - -- [ ] **Step 3: Den Generator einmal laufen lassen** - -`@Disabled` vorübergehend auskommentieren, dann: - -Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.JavaFxStylesheetTest' --console=plain -i 2>&1 | grep "wrote"` - -Erwartet: eine Zeile `wrote 160 pairs to /…/app-desktop/src/test/resources/javafx-derive-reference.txt`. - -Danach `@Disabled` wieder einkommentieren. - -- [ ] **Step 4: Prüfen, dass die Daten wirklich von JavaFX stammen** - -Run: `head -5 app-desktop/src/test/resources/javafx-derive-reference.txt && wc -l app-desktop/src/test/resources/javafx-derive-reference.txt` - -Erwartet: drei Kommentarzeilen, dann Zeilen der Form `#ffffff;-80.0;#333333`. Insgesamt 163 Zeilen. - -Stichprobe von Hand: `#ffffff;-30.0;…` muss ein mittleres Grau sein, nicht Weiß. Ist die Spalte identisch mit der ersten, hat der Generator nicht JavaFX gefragt — dann Schritt 2 prüfen, **nicht** weitermachen. - -- [ ] **Step 5: Commit** - -```bash -git add app-desktop/src/test/resources/javafx-derive-reference.txt \ - app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt -git commit -m "Freeze the JavaFX colour-derivation reference as test data - -JavaFxStylesheetTest compares derive() against com.sun.javafx.util.Utils -at runtime, through reflection. That comparison stops working the moment -JavaFX leaves the classpath, and the test would then quietly fall back to -checking derive() against itself. - -The pairs are generated from the real JavaFX implementation while it is -still here and checked in. The generator stays, disabled, so the -provenance of the numbers is readable." -``` - ---- - -## Task 2: Den Test gegen die Daten prüfen lassen - -**Files:** -- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt` -- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt` (nur Kommentar) -- Test: dieselbe Testdatei - -**Interfaces:** -- Consumes: `javafx-derive-reference.txt` aus Aufgabe 1. -- Produces: nichts, was spätere Aufgaben benutzen. - -- [ ] **Step 1: Den Test schreiben, der gegen die Daten prüft** - -In `JavaFxStylesheetTest.kt` ergänzen: - -```kotlin - /** The reference pairs, as `(rgbHex, percent, expectedRgbHex)`. */ - private fun referencePairs(): List> { - val resource = javaClass.getResourceAsStream("/javafx-derive-reference.txt") - ?: error("javafx-derive-reference.txt is missing from the test resources") - - return resource.bufferedReader().readLines() - .filterNot { it.isBlank() || it.startsWith("#") } - .map { line -> - val (rgb, percent, expected) = line.split(";") - Triple(rgb, percent.toDouble(), expected) - } - } - - /** - * derive() against the values JavaFX itself produced. - * - * Against checked-in data rather than a live reflection call, because the live call - * disappears with JavaFX and would take the only real check with it — leaving a - * test that compares derive() to derive(). - */ - @Test - fun `derive matches the frozen JavaFX reference`() { - val pairs = referencePairs() - - assertTrue(pairs.size >= 100) { "only ${pairs.size} reference pairs; the file looks truncated" } - - pairs.forEach { (rgb, percent, expected) -> - assertChannelsClose( - parse(expected), - JavaFxStylesheet.derive(parse(rgb), percent), - "derive($rgb, $percent%)", - ) - } - } -``` - -- [ ] **Step 2: Den Test laufen lassen und sehen, dass er grün ist** - -Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.JavaFxStylesheetTest' --console=plain > /tmp/t1.txt 2>&1; python3 -c " -import glob,xml.etree.ElementTree as ET -for f in glob.glob('app-desktop/build/test-results/test/TEST-*JavaFxStylesheetTest.xml'): - r=ET.parse(f).getroot(); print(f\"tests={r.get('tests')} failures={r.get('failures')} skipped={r.get('skipped')}\") - for tc in r.iter('testcase'): - for b in list(tc.iter('failure'))+list(tc.iter('error')): print(tc.get('name'), (b.get('message') or '')[:200])"` - -Erwartet: `failures=0`, `skipped=1` (der Generator). - -Ein grüner Test ist hier **noch kein Beweis** — er könnte grün sein, weil die Daten aus der eigenen Umsetzung stammen. Das prüft der nächste Schritt. - -- [ ] **Step 3: Beweisen, dass der Test den Fehler fängt, den er fangen soll** - -Review Focus 1. Die Produktionsumsetzung vorübergehend verfälschen: - -In `JavaFxStylesheet.kt`, in `derive`, `calcBrightness = calcBrightness.coerceIn(-1.0, 1.0)` ersetzen durch `calcBrightness = (calcBrightness * 1.05).coerceIn(-1.0, 1.0)`. - -Run: denselben Befehl wie Schritt 2. - -Erwartet: `failures=1`, und die Meldung nennt ein konkretes `derive(#…, …%)`. Fällt der Test **nicht**, sind die Daten wertlos — dann stammen sie nicht von JavaFX; zurück zu Aufgabe 1. - -Danach die Verfälschung zurücknehmen und erneut laufen lassen: `failures=0`. - -- [ ] **Step 4: Den Reflexionsvergleich aus dem laufenden Test nehmen** - -Die Zeile, die `deriveWithJavaFx` im Test `derive gives the values JavaFX gives` aufruft (um `:50`), entfernen, sodass dieser Test nur noch seine eigene Tabelle gegen `JavaFxStylesheet.derive` prüft. `deriveWithJavaFx` selbst **bleibt** — der Generator in Aufgabe 1 braucht es. - -Begründung als Kommentar über den entfernten Vergleich setzen: - -```kotlin - /* - * No live JavaFX comparison here. It lives in the frozen reference data - * instead (javafx-derive-reference.txt), because a reflection call into - * com.sun.javafx stops working when JavaFX is removed and would take the - * only real check with it. - */ -``` - -- [ ] **Step 5: Den veralteten Kommentar in der Produktionsdatei berichtigen** - -In `JavaFxStylesheet.kt` beschreibt der Kommentar über `derive` (um `:116`–`:129`) eine Reflexionsfassung, die es nicht mehr gibt. Ersetzen durch: - -```kotlin - /** - * JavaFX `derive(colour, percent)`, reimplemented. - * - * No JavaFX call and no reflection: this is the algorithm itself. The brightness it - * derives from is not the HSB value but `0.3R + 0.59G + 0.11B`, and the positive - * branch follows a staircase rather than a formula, which is why an earlier - * approximation here was measurably wrong. - * - * Correctness is not argued, it is pinned: `JavaFxStylesheetTest` checks this - * against values the real `com.sun.javafx.util.Utils.deriveColor` produced, frozen - * into `javafx-derive-reference.txt` so the check survives JavaFX being removed. - */ -``` - -- [ ] **Step 6: Die ganze Suite laufen lassen** - -Run: `./gradlew clean build --console=plain > /tmp/b.txt 2>&1; echo "exit=$?"; python3 -c " -import glob,xml.etree.ElementTree as ET -t=f=0 -for p in glob.glob('*/build/test-results/test/TEST-*.xml'): - r=ET.parse(p).getroot(); t+=int(r.get('tests')); f+=int(r.get('failures'))+int(r.get('errors')) -print(f'tests={t} failures={f}')"` - -Erwartet: `exit=0`, `failures=0`, und `tests` um eins höher als die 766 vom Ausgangsstand. - -- [ ] **Step 7: Commit** - -```bash -git add app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt \ - app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt -git commit -m "Check derive() against the frozen reference instead of live JavaFX - -The reflection call stays only in the disabled generator. The running -test now reads checked-in values, so it keeps its meaning once JavaFX is -gone rather than silently comparing derive() with itself. - -Verified by breaking derive() on purpose and watching the test fail. - -Also corrects the doc comment on derive(), which still described a -reflection delegation that the code has not done for some time." -``` - ---- - -## Task 3: Feststellen, ob `FxRosterBinding` den Betrieb speist - -**Files:** -- Create: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` -- Read only: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`, `app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java` - -**Interfaces:** -- Consumes: nichts. -- Produces: das Befunddokument mit dem Abschnitt „FxRosterBinding". Aufgabe 5 fasst zusammen, Teil 2 baut darauf auf. - -**Keine Produktionsdatei wird in dieser Aufgabe geändert.** - -- [ ] **Step 1: Alle Nutzungen des Spiegels auflisten** - -Run: `grep -n "chatMemberListBinding" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -Erwartet: Erzeugung um `:2513`, Nullsetzung um `:6877`, eine Prüfung um `:1766`. Jede weitere Fundstelle notieren. - -- [ ] **Step 2: Feststellen, wer den gespiegelten `ObservableList` liest** - -Run: `grep -n "chatMemberListBinding.get\|chatMemberListBinding\.\(mirror\|list\|items\)" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java; grep -n "public" app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java` - -Erwartet: die öffentliche Fläche von `FxRosterBinding` und jede Stelle, die den Spiegel abruft. Für jede Stelle festhalten: landet sie in einer `TableView` (tot) oder in etwas, das Compose liest (lebend)? - -- [ ] **Step 3: Feststellen, woher die Compose-Stationsliste ihre Zeilen bekommt** - -Run: `grep -n "DataTableState\|setRows\|rows =" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java | head -20; grep -n "fun setRows\|var rows\|val rows" app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt` - -Erwartet: eine durchgehende Kette von `ObservableRoster` bis `DataTableState.rows`. Notiere sie Glied für Glied. Führt die Kette durch `chatMemberListBinding`, ist der Spiegel **lebend**. - -- [ ] **Step 4: Den Befund schreiben** - -`docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` anlegen: - -```markdown -# Lebendbefund: was im JavaFX-Aufbau von KST4Contest tatsächlich benutzt wird - -Grundlage für Teil 2 von `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`. -Erhoben am 1. Oktober 2026 auf Linux, Branch `nextMajorRelease/version1_50`. - -Jeder Block bekommt genau einen von zwei Töpfen: -**folgenlos löschbar** oder **zu entflechten** (mit der Stelle, die davon abhängt). - -## FxRosterBinding (`chatMemberListBinding`) - -**Topf:** - -**Belege:** -- Erzeugt bei `Kst4ContestApplication.java:`, genullt bei `:`. -- Gelesen von: -- Die Compose-Stationsliste bezieht ihre Zeilen über: - -**Folgerung:** -``` - -Jede spitze Klammer wird durch das ersetzt, was die Schritte 1–3 ergeben haben. Bleibt eine stehen, ist die Aufgabe nicht fertig. - -- [ ] **Step 5: Commit** - -```bash -git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Record what FxRosterBinding actually feeds - -First section of the finding that part 2 of the JavaFX removal builds on. -No production code touched." -``` - ---- - -## Task 4: Die übrigen Verdachtsfälle am laufenden Programm prüfen - -**Files:** -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` -- Read only: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -**Interfaces:** -- Consumes: das Befunddokument aus Aufgabe 3. -- Produces: dasselbe Dokument mit den Abschnitten „registerThemedScene", „LayoutAutosave", „Systemmenüleiste" und „Kandidatenfenster". - -**Keine Produktionsdatei wird in dieser Aufgabe geändert.** Diese Aufgabe erfordert, die Anwendung zu starten und zu bedienen; Lesen allein beantwortet sie nicht. - -- [ ] **Step 1: Die Stellen auflisten** - -Run: `grep -n "registerThemedScene\|LayoutAutosave\|layoutAutosave\|installSharedSystemMenuBar\|initMenuBar\|showTopPriorityCandidatesWindow" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -Erwartet unter anderem: drei Aufrufe von `registerThemedScene`, `layoutAutosave` erzeugt um `:7420`, `installSharedSystemMenuBar(candidatesScene)` um `:4858`, `initMenuBar()` um `:5729`, `showTopPriorityCandidatesWindow` gerufen bei `:4706` (toter JavaFX-Knopf) und `:10707` (lebender Compose-Pfad). - -- [ ] **Step 2: Die Anwendung starten und das Kandidatenfenster öffnen** - -Run: `./gradlew :app-desktop:run` - -Dann im Compose-Hauptfenster den Knopf **more** neben den Prioritätsknöpfen drücken. - -Erwartet: ein Fenster mit der Kandidatenliste erscheint. Festhalten: Titelleiste, ob es eine eigene Menüleiste trägt, ob es modal ist, und ob ein Klick auf einen Eintrag im Hauptfenster eine Station auswählt. - -Erscheint **kein** Fenster, ist der Pfad trotz `:10707` tot — das ist ein genauso wertvoller Befund und gehört so ins Dokument. - -- [ ] **Step 3: Das Design umschalten und beobachten, ob die Compose-Fenster folgen** - -Bei laufender Anwendung das Design über die Menüleiste des Hauptfensters umschalten (Tag/Abend). - -Erwartet: Hauptfenster, Kartenfenster und Einstellungsfenster wechseln die Farben. Notieren, ob **alle** folgen. Folgt eines nicht, hängt es womöglich an `registerThemedScene` und gehört in den Topf „zu entflechten". - -- [ ] **Step 4: Einen Profilwechsel durchführen** - -Review Focus 4. Über die Menüleiste das Bedienerprofil wechseln. - -Erwartet: die Fenster werden geschlossen und neu aufgebaut; die Anwendung bleibt bedienbar; in der Konsole erscheint keine Ausnahme. Notieren, was passiert. - -- [ ] **Step 5: Mit einem zweiten Profil wiederholen** - -Review Focus 3. Ein Profil mit anderen aktiven Bändern wählen (mindestens ein Band an- oder abgeschaltet gegenüber dem ersten) und die Schritte 2 bis 4 wiederholen. - -Erwartet: dasselbe Verhalten. Weicht es ab, gehört die Abweichung mit der Einstellung ins Dokument, die sie auslöst. - -- [ ] **Step 6: Fenstergrößen auf Persistenz prüfen** - -Ein Fenster in der Größe ändern, die Anwendung beenden, neu starten. - -Erwartet: die Größe ist erhalten. Ist sie es nicht, schreibt `LayoutAutosave` womöglich Werte, die niemand mehr liest — oder umgekehrt. Notieren. - -- [ ] **Step 7: Die Abschnitte ins Befunddokument schreiben** - -Für jeden der vier Blöcke — `registerThemedScene`, `LayoutAutosave`, Systemmenüleiste, Kandidatenfenster — denselben Aufbau wie in Aufgabe 3 Schritt 4: Topf, Belege, Folgerung. - -Die Systemmenüleiste bekommt zusätzlich die Zeile: - -```markdown -**Nicht geprüft:** macOS. Erhoben wurde auf Linux. Ob die Systemmenüleiste dort -sichtbar an der JavaFX-`MenuBar` hängt, ist offen und muss vor dem Entfernen auf -einem Mac nachgeholt werden. -``` - -- [ ] **Step 8: Commit** - -```bash -git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Record what the themed scenes, autosave and menu bar actually do - -Established by running the application and operating it, not by reading: -a listener that is never fired reads the same as one that does not exist. -Includes a profile switch and a second profile, and marks macOS as -unverified. No production code touched." -``` - ---- - -## Task 5: Den Befund abschließen und Teil 2 umreißen - -**Files:** -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -**Interfaces:** -- Consumes: alle Abschnitte aus Aufgabe 3 und 4. -- Produces: die Löschreihenfolge, aus der Teil 2 geschrieben wird. - -- [ ] **Step 1: Den toten Aufbau in Scheiben zerlegen** - -Run: `grep -n "private.*void init[A-Z][a-zA-Z]*(\|private.*Pane init[A-Z][a-zA-Z]*(\|private.*Node init[A-Z][a-zA-Z]*(" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -Erwartet: die `init…`-Methoden, die zusammen das nie gezeigte Fenster bauen. Für jede notieren: Zeilenbereich, ungefähre Länge und ob der Befund aus Aufgabe 3 und 4 sie als „folgenlos löschbar" ausweist. - -- [ ] **Step 2: Eine Reihenfolge festlegen, die den Build nach jeder Scheibe grün lässt** - -Blätter zuerst, Wurzeln zuletzt: eine Methode, die niemand mehr ruft, lässt sich löschen; eine, die noch gerufen wird, nicht. Die Reihenfolge als nummerierte Liste ins Dokument schreiben, jede Scheibe mit den Methoden, die sie umfasst. - -- [ ] **Step 3: Die Zusammenfassung schreiben** - -Am Anfang des Dokuments, unter der Überschrift: - -```markdown -## Ergebnis in einem Satz - - - -## Löschreihenfolge für Teil 2 - -1. — — -2. … -``` - -- [ ] **Step 4: Prüfen, dass keine Lücke bleibt** - -Run: `grep -c "zu entflechten\|folgenlos löschbar" docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md; grep -n "<" docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -Erwartet: jeder Block hat genau einen Topf, und der zweite Befehl findet **keine** spitze Klammer mehr. Findet er eine, ist eine Vorlage nicht ausgefüllt. - -- [ ] **Step 5: Commit** - -```bash -git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Finish the live-code finding and derive the deletion order - -Part 2 of the JavaFX removal is written from this, not from assumption." -``` - ---- - -## Was dieser Plan nicht tut - -- Er löscht keine Produktionsdatei. -- Er fasst den `UiDispatcher`, den Lebenszyklus und die zehn Dialoge nicht an. -- Er entfernt `org.openjfx` nicht aus dem Build. - -Das ist Teil 2, und der wird geschrieben, wenn der Befund aus Aufgabe 5 vorliegt. diff --git a/docs/superpowers/plans/2026-10-01-javafx-entfernen-teil2.md b/docs/superpowers/plans/2026-10-01-javafx-entfernen-teil2.md deleted file mode 100644 index 6a4c8ff0..00000000 --- a/docs/superpowers/plans/2026-10-01-javafx-entfernen-teil2.md +++ /dev/null @@ -1,1400 +0,0 @@ -# JavaFX entfernen, Teil 2: Entflechten, bis der tote Aufbau wirklich tot ist - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Die vier Stellen, an denen der nie angezeigte JavaFX-Aufbau die Compose-Oberfläche speist, aus ihm herauslösen — und die eine Scheibe löschen, deren Unschädlichkeit beweisbar ist. - -**Architecture:** Jede Zuführung bekommt eine eigene, testbare Einheit, die den Compose-Zustand füllt und nichts von JavaFX weiß. Danach ruft der JavaFX-Aufbau sie nur noch, statt sie zu besitzen, und kann in Teil 3 fallen, ohne etwas mitzunehmen. Das Kandidatenfenster wird nicht entflochten, sondern portiert: es ist die einzige `Stage`, die dieser Quelltext selbst erzeugt. - -**Tech Stack:** Java 21, Kotlin 2.2, Gradle, JUnit 5, Compose Multiplatform 1.8.2, JavaFX 21 (noch vorhanden). - -**Spec:** `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md` - -**Befund, auf dem jede Aufgabe aufsetzt:** `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -## Warum nur das - -Die Spezifikation hat sechs Etappen. Dieser Plan ist der erste Teil von Etappe 2 und -deckt die fünf Entflechtungen plus die erste Löschscheibe ab. Die restlichen sieben -Löschscheiben hängen daran, dass diese fünf gelandet sind; `UiDispatcher`, -Lebenszyklus, die zehn Dialoge und der Build sind Etappe 4 bis 6. - -Nach diesem Plan ist der tote Aufbau zum ersten Mal wirklich tot — vorher ist jede -Löschung ein Risiko, das kein Test und kein Übersetzer abfängt. - -## Zwei Dinge, die beim Schreiben dieses Plans fehlten - -1. **Fünf Beobachtungen am laufenden Programm** aus Teil 1, Aufgabe 4, stehen aus - (Abschnitt „Was hier nicht geprüft werden konnte" im Befund). Betroffen ist vor - allem Aufgabe 2: das Kandidatenfenster wird aus dem Quelltext nachgebaut, nicht aus - der Anschauung. Der Quelltext ist vollständig gelesen und in Aufgabe 2 wiedergegeben, - aber niemand hat das alte Fenster dabei gesehen. -2. **macOS.** `installSharedSystemMenuBar` steigt auf Linux bei `:6075` sofort aus. Was - dort an der JavaFX-Menüleiste sichtbar hängt, entscheidet Aufgabe 6 nicht. - -## Global Constraints - -- Kommunikation mit dem Benutzer auf Deutsch; Quelltextkommentare und Javadoc - ausschließlich auf Englisch (`AGENTS.md`). -- Keine neue Produktionsabhängigkeit ohne vorherige Freigabe (`AGENTS.md`). -- Kein Push, Merge, Tag oder Release. Die Commit-Schritte unten sind durch die Freigabe - dieses Plans gedeckt, nichts darüber hinaus. -- **Nicht** aus Exit-Code 0 auf „alle Tests grün" schließen — die XML-Ergebnisse unter - `*/build/test-results/` lesen (`AGENTS.md`). -- Ausgangslage: `./gradlew clean build` → exit 0, 769 Tests, 0 Fehler. -- Signierte Commits sind konfiguriert. Schlägt das Signieren fehl, läuft der - Bitwarden-Agent nicht; das ist kein Grund, unsigniert zu committen. -- `ObservableRoster` ist der Speicher, die Listen sind Spiegel. Kein Worker-Faden fasst - UI-Sammlungen an. Zustellung nur über `UiDispatcher` (`AGENTS.md`). -- Zoom, Auswahl, Fokus, Sortierung und vorbelegter Text ändern sich nicht als - Nebeneffekt (`AGENTS.md`). -- **Vor jeder Löschung** den Durchlauf laufen lassen: - `python3 docs/superpowers/notes/javafx-compose-sweep.py`. - -## Review Focus - -Fünf Dinge, die die Spezifikation voraussetzt, die aber keine Aufgabe hier von sich aus -prüft, nach Wahrscheinlichkeit geordnet: - -1. **Eine Zuführung feuert nach dem Umbau nicht mehr, und nichts merkt es.** Genau das - ist die Fehlerklasse, die Teil 1 gefunden hat: kein Übersetzungsfehler, kein - Testfehler, nur eine leere Tabelle. Jede der Aufgaben 3, 4 und 5 bekommt deshalb - einen Test, der den Compose-Zustand **nach** dem Füllen prüft, nicht nur das Mapping. -2. **Eine Zuführung feuert doppelt.** Bleibt der alte Aufruf stehen und kommt der neue - dazu, schreiben zwei Wege denselben Zustand — unsichtbar, bis einer veraltete Daten - liefert. Die Aufgaben 3, 4 und 5 prüfen deshalb mit einem Zähler, dass genau einmal - geschrieben wird. -3. **Leere oder fehlende Eingabe.** `topCandidates()` kann leer sein, `getQrb()` kann - `null` sein, ein `ChatMember` kann ohne Rufzeichen kommen. Aufgabe 2 und 3 prüfen - den leeren und den unvollständigen Fall. -4. **Zustellung vom falschen Faden.** Die Controller-Listener feuern auf Netzfäden. - Aufgabe 4 prüft, dass die Zuführung über `UiDispatcher` geht und nicht direkt - schreibt. -5. **Die gelöschten Methoden waren doch erreichbar.** Aufgabe 1 prüft das vor dem - Löschen mit einem Grep, der auch auskommentierte Aufrufe sichtbar macht. - ---- - -## File Structure - -| Datei | Verantwortung | Aufgabe | -|---|---|---| -| `app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt` | **neu** — das portierte Kandidatenfenster | 2 | -| `app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt` | **neu** — Beschriftung und Rendern | 2 | -| `app-desktop/src/main/kotlin/kst4contest/view/feed/TimelineFeed.kt` | **neu** — füllt `TimelineState`, weiß nichts von JavaFX | 3 | -| `app-desktop/src/test/kotlin/kst4contest/view/feed/TimelineFeedTest.kt` | **neu** | 3 | -| `app-desktop/src/main/kotlin/kst4contest/view/feed/SelectedStationMessagesFeed.kt` | **neu** — füllt `selectedStationMessages` | 4 | -| `app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt` | **neu** | 4 | -| `app-desktop/src/main/kotlin/kst4contest/view/feed/ConnectionStateFeed.kt` | **neu** — füllt `MainWindowSurroundings` | 5 | -| `app-desktop/src/test/kotlin/kst4contest/view/feed/ConnectionStateFeedTest.kt` | **neu** | 5 | -| `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` | **ändern** — ruft die Zuführungen, besitzt sie nicht mehr | 1–6 | -| `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` | **ändern** — abgearbeitete Punkte abhaken | 1–6 | - -Ein eigenes Paket `kst4contest.view.feed`, weil diese drei Klassen dasselbe tun und -zusammen wandern werden: sie sind die Nahtstelle zwischen Controller und -Compose-Zustand und gehören nach Etappe 5 vermutlich näher an `core`. - -**In Kotlin, nicht in Java.** Die Compose-Zustände haben Kotlin-Standardparameter — -`MainWindowSurroundings(menu = MainMenuState())` und `DataTableState(columns, rowKey, -tableId = "", widths = null)` —, und aus Java gibt es dafür keinen kurzen Konstruktor. -Kotlin-Tests bauen diese Zustände bereits (`MainWindowStateTest`, `TimelineStateTest`). -`Kst4ContestApplication.java` kann die Klassen unverändert rufen. - ---- - -## Task 1: Die vier unreferenzierten Tabellen löschen - -Die einzige Scheibe, bei der „der Build merkt es nicht" beweisbar statt argumentiert -ist. Sie steht voran, weil sie die Prämisse des ganzen Vorhabens billig prüft. - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -**Interfaces:** -- Consumes: nichts. -- Produces: nichts. Vier Methoden verschwinden: `initShortcutTable` (`:4561`–`:4599`), - `initNotifyAtCallSignTable` (`:5146`–`:5246`), `initTextSnippetsTable` - (`:5248`–`:5294`), `initWkdStnTable` (`:5441`–`:5727`). - -- [ ] **Step 1: Prüfen, dass wirklich niemand sie ruft — auch nicht auskommentiert** - -Run: -```bash -cd /home/philipp/Projects/ham/kst4contest -for m in initShortcutTable initNotifyAtCallSignTable initTextSnippetsTable initWkdStnTable; do - echo "--- $m" - grep -rn "$m" --include='*.java' --include='*.kt' core/src app-desktop/src -done -``` - -Erwartet: für jede Methode **genau eine** Zeile, nämlich ihre eigene Deklaration in -`Kst4ContestApplication.java`. Findet der Befehl einen auskommentierten Aufruf, ist das -kein Hindernis — aber er gehört in die Commit-Nachricht, damit der nächste Leser weiß, -dass dort einmal etwas hing. - -- [ ] **Step 2: Prüfen, dass es keine reflektive Hintertür gibt** - -Run: -```bash -grep -cE "getDeclaredMethod|getMethod\(|Class\.forName|FXMLLoader" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Erwartet: `0`. Ein Wert über 0 bedeutet, dass eine Methode über ihren Namen als -Zeichenkette erreichbar sein könnte; dann **nicht löschen**, sondern die Fundstelle -prüfen. - -- [ ] **Step 3: Den Compose-Durchlauf laufen lassen** - -Run: `python3 docs/superpowers/notes/javafx-compose-sweep.py` - -Erwartet: die vier Methoden stehen als `clean to depth 3`. Stehen sie als -`REACHES COMPOSE`, ist der Befund veraltet — anhalten und ihn berichtigen. - -- [ ] **Step 4: Die vier Methoden löschen** - -Jede Methode vollständig entfernen, einschließlich ihres Javadoc-Blocks. Felder, die -nur in ihnen benutzt werden, mitnehmen — der Übersetzer zeigt sie als unbenutzt nicht -an, also nach jedem Löschen prüfen: - -```bash -grep -n "tbl_shortcutTable\|tbl_notifyAtCallSignTable\|tbl_textSnippetsTable\|tbl_wkdStnTable" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Bleibt eine Fundstelle außerhalb der gelöschten Bereiche, gehört das Feld jemand -anderem und bleibt. - -- [ ] **Step 5: Übersetzen und die ganze Suite laufen lassen** - -Run: -```bash -./gradlew clean build --console=plain > /tmp/t1.txt 2>&1; echo "exit=$?" -python3 -c " -import glob,xml.etree.ElementTree as ET -t=f=0 -for p in glob.glob('*/build/test-results/test/TEST-*.xml'): - r=ET.parse(p).getroot(); t+=int(r.get('tests')); f+=int(r.get('failures'))+int(r.get('errors')) -print(f'tests={t} failures={f}')" -``` - -Erwartet: `exit=0`, `tests=769 failures=0`. Die Testzahl ändert sich **nicht** — das -ist der Punkt: 470 Zeilen weg, und kein Test hatte sie je berührt. - -- [ ] **Step 6: Die Anwendung starten und bedienen** - -Run: `./gradlew :app-desktop:run` - -Erwartet: Sie startet, das Compose-Hauptfenster erscheint, die Stationsliste füllt sich, -und auf der Konsole steht keine `NullPointerException` und kein -`NoSuchMethodError`. Danach beenden. - -Kein Test deckt diesen Pfad ab; dieser Schritt ist die einzige Prüfung, die er bekommt. - -- [ ] **Step 7: Den Befund abhaken** - -In `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` unter „Reihenfolge für -Teil 2" die Scheibe 1 mit dem Datum und der Zeilenzahl als erledigt markieren: - -```markdown -1. ~~Unreferenziert: `initShortcutTable`, `initNotifyAtCallSignTable`, - `initTextSnippetsTable`, `initWkdStnTable`~~ — **erledigt am 2026-10-01**, 470 Zeilen, - Suite unverändert bei 769 Tests. -``` - -- [ ] **Step 8: Commit** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Delete four station tables that nothing has referenced - -initShortcutTable, initNotifyAtCallSignTable, initTextSnippetsTable and -initWkdStnTable each appeared exactly once in the tree - their own -declaration. No caller, commented out or otherwise, and no reflective or -FXML back door: getDeclaredMethod, getMethod(, Class.forName and -FXMLLoader appear zero times in the file. - -470 lines, and the suite neither grew nor shrank: 769 tests before and -after. That is the point of starting here - it is the one slice where -'the build will not notice' is provable rather than argued, and it proves -the premise the rest of the removal rests on." -``` - ---- - -## Task 2: Das Kandidatenfenster nach Compose - -Die einzige `Stage`, die dieser Quelltext selbst erzeugt (`new Stage()` bei `:4810`, -einziges Vorkommen in der Datei). Solange sie steht, kann keine JavaFX-Abhängigkeit -fallen. - -**Files:** -- Create: `app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt` -- Create: `app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt` -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java:4806-4862` und `:10707` - -**Interfaces:** -- Consumes: `kst4contest.controller.ScoreService.TopCandidate` mit - `getDisplayCallSign(): String`, `getScore(): double`, `getCallSignRaw(): String`, - `getPreferredChatCategory(): ChatCategory`. - `ScoreService.topCandidates(): ObservableRoster` mit `snapshot(): List`. -- Produces: - - `TopPriorityCandidatesWindow.show(candidates: () -> List, onPicked: (TopCandidate) -> Unit, darkMode: Boolean, baseFontSizeSp: Float)` - - `TopPriorityCandidatesWindow.hide()` - - `TopPriorityCandidatesWindow.applyDarkMode(darkMode: Boolean)` - - `candidateLabel(candidate: TopCandidate): String` — `internal`, für den Test - -So baut das alte Fenster (vollständig, `:4806`–`:4862`): eine `ListView` über -`topCandidates()`, Zelltext `displayCallSign + " | score " + "%.0f"`, Doppelklick wählt -aus und schließt, unten die Zeile „Double-click a candidate to select it.", Größe -360×500, Titel „Top priority candidates". - -- [ ] **Step 1: Den Test für die Beschriftung schreiben** - -```kotlin -package kst4contest.view.compose - -import kst4contest.controller.ScoreService -import kst4contest.model.ChatCategory -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -/** - * The window the "more" button opens. - * - * Its whole content is one line per candidate, so the line is the thing worth pinning: - * an operator reads the callsign and the score and nothing else, and the JavaFX version - * spelled it a particular way for two releases. - */ -class TopPriorityCandidatesWindowTest { - - private fun candidate(call: String, score: Double) = - ScoreService.TopCandidate(call, call, ChatCategory.values().first(), score) - - @Test - fun `a candidate reads as its callsign and its score`() { - assertEquals("PA6I | score 968", candidateLabel(candidate("PA6I", 968.0))) - } - - /** The JavaFX cell used %.0f; 967.6 showed as 968, not as 967.6 or 967. */ - @Test - fun `the score is rounded to whole points`() { - assertEquals("DK7SE | score 968", candidateLabel(candidate("DK7SE", 967.6))) - assertEquals("DK7SE | score 895", candidateLabel(candidate("DK7SE", 895.4))) - } - - @Test - fun `a score of zero still reads as a score`() { - assertEquals("DL1ABC | score 0", candidateLabel(candidate("DL1ABC", 0.0))) - } -} -``` - -- [ ] **Step 2: Den Test laufen lassen und scheitern sehen** - -Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.TopPriorityCandidatesWindowTest' --console=plain 2>&1 | grep -E "^e: " | head -3` - -Erwartet: `Unresolved reference 'candidateLabel'`. - -Scheitert er stattdessen am Konstruktor von `TopCandidate`, dessen Signatur im Quelltext -nachsehen (`core/src/main/java/kst4contest/controller/ScoreService.java`, um `:300`) und -den Testhelfer anpassen — die Beschriftung ist der Prüfgegenstand, nicht der -Konstruktor. - -- [ ] **Step 3: Das Fenster schreiben** - -```kotlin -package kst4contest.view.compose - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.onClick -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import kst4contest.controller.ScoreService -import java.util.Locale - -/** - * One line of the candidate list. - * - * Kept as a function rather than inlined into the row so the wording stays under a - * test: the JavaFX cell spelled it `callsign + " | score " + %.0f` for two releases, - * and an operator scanning the list reads the shape before the content. - */ -internal fun candidateLabel(candidate: ScoreService.TopCandidate): String = - candidate.displayCallSign + - " | score " + - String.format(Locale.US, "%.0f", candidate.score) - -/** - * The full priority list, opened by the "more" button beside the two priority buttons. - * - * It exists so the main window can stay compact while the whole ranking is still - * reachable. Its JavaFX predecessor was the only Stage this application constructed - * itself, which is why it is ported before anything is deleted: no JavaFX dependency - * can be dropped while it stands. - */ -object TopPriorityCandidatesWindow { - - private val host = ComposeWindowHost("TopPriorityCandidates") - - val isOpen: Boolean get() = host.isOpen - - fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode) - - fun hide() = host.close() - - /** - * @param candidates read afresh while the window is open; the ranking changes as - * scores do, and the JavaFX list was bound to the roster for that reason - * @param onPicked called with the chosen candidate, after which the window closes - */ - fun show( - candidates: () -> List, - onPicked: (ScoreService.TopCandidate) -> Unit, - darkMode: Boolean, - baseFontSizeSp: Float, - ) { - host.show( - title = "Top priority candidates", - darkMode = darkMode, - baseFontSizeSp = baseFontSizeSp, - widthDp = 360f, - heightDp = 500f, - ) { close -> - var ranking by remember { mutableStateOf(candidates()) } - - /* - * Polled rather than pushed. The ranking lives in the score service, which - * is not Compose state, and this window is open for seconds at a time while - * an operator reads it - a callback wired in and out for that is more - * machinery than the question deserves. - */ - LaunchedEffect(Unit) { - while (true) { - kotlinx.coroutines.delay(1_000) - ranking = candidates() - } - } - - Surface( - color = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.fillMaxSize(), - ) { - Column(Modifier.fillMaxSize().padding(5.dp)) { - CandidateList( - ranking = ranking, - onPicked = { picked -> - onPicked(picked) - close() - }, - modifier = Modifier.weight(1f).fillMaxWidth(), - ) - HorizontalDivider(Modifier.padding(vertical = 4.dp)) - Text( - "Double-click a candidate to select it.", - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - } - - @OptIn(ExperimentalFoundationApi::class) - @Composable - private fun CandidateList( - ranking: List, - onPicked: (ScoreService.TopCandidate) -> Unit, - modifier: Modifier = Modifier, - ) { - if (ranking.isEmpty()) { - Text("No candidates yet.", modifier = modifier.padding(4.dp)) - return - } - - LazyColumn(modifier) { - items(ranking) { candidate -> - Text( - text = candidateLabel(candidate), - modifier = Modifier - .fillMaxWidth() - .onClick(onDoubleClick = { onPicked(candidate) }, onClick = { }) - .padding(vertical = 3.dp, horizontal = 4.dp), - ) - } - } - } -} -``` - -`androidx.compose.runtime.Composable` muss importiert werden; falls der Übersetzer ihn -anmahnt, `import androidx.compose.runtime.Composable` ergänzen. - -- [ ] **Step 4: Den Test laufen lassen** - -Run: -```bash -./gradlew :app-desktop:test --tests 'kst4contest.view.compose.TopPriorityCandidatesWindowTest' --console=plain > /tmp/t2.txt 2>&1 -grep -E "^e: " /tmp/t2.txt | head -3 -python3 -c " -import glob,xml.etree.ElementTree as ET -for f in glob.glob('app-desktop/build/test-results/test/TEST-*TopPriorityCandidatesWindowTest.xml'): - r=ET.parse(f).getroot(); print(f\"tests={r.get('tests')} failures={r.get('failures')}\") - for tc in r.iter('testcase'): - for b in list(tc.iter('failure'))+list(tc.iter('error')): print(' ',tc.get('name'),(b.get('message') or '')[:200])" -``` - -Erwartet: `tests=3 failures=0`. - -- [ ] **Step 5: Einen Rendertest ergänzen, der den leeren Fall einschließt** - -Review Focus 3. Dasselbe Muster wie die Karten-Layouttests aus Etappe 6: - -```kotlin - @Test - fun `the list renders with candidates and without`() { - listOf( - emptyList(), - listOf(candidate("PA6I", 968.0), candidate("DK7SE", 895.0)), - ).forEach { ranking -> - val scene = androidx.compose.ui.ImageComposeScene( - width = 360, - height = 500, - density = androidx.compose.ui.unit.Density(1f), - ) { - TopPriorityCandidatesWindow.CandidateListForTest(ranking) { } - } - try { - scene.render() - } finally { - scene.close() - } - } - } -``` - -Dafür `CandidateList` als `internal` zugänglich machen, unter dem Namen, den der Test -benutzt: - -```kotlin - /** The list on its own, so a test can render it without opening a window. */ - @Composable - internal fun CandidateListForTest( - ranking: List, - onPicked: (ScoreService.TopCandidate) -> Unit, - ) = CandidateList(ranking, onPicked) -``` - -- [ ] **Step 6: Den Rendertest laufen lassen** - -Run: derselbe Befehl wie Schritt 4. - -Erwartet: `tests=4 failures=0`. Wirft das Rendern, ist das ein echter Fund — die -Karten-Layouttests fangen genau diese Klasse, und dieses Fenster hatte bisher keinen. - -- [ ] **Step 7: Den Aufrufer umstellen** - -In `Kst4ContestApplication.java` die Methode `showTopPriorityCandidatesWindow` -(`:4806`–`:4862`) vollständig durch diesen Rumpf ersetzen: - -```java - /** - * Opens the complete priority candidate list in a separate window. - * - * This keeps the main UI compact while still making the full list available when - * the operator wants to inspect more than the first two candidates. - * - * The window is Compose. Its JavaFX predecessor was the only Stage this class - * constructed, and it carried the themed-scene registration and the shared system - * menu bar with it; both are gone with it. - */ - private void showTopPriorityCandidatesWindow() { - TopPriorityCandidatesWindow.INSTANCE.show( - () -> chatcontroller.getScoreService().topCandidates().snapshot(), - candidate -> uiDispatcher.runOnUi(() -> selectTopCandidate(candidate)), - chatcontroller.getChatPreferences().isGUI_darkModeActive(), - 12f - ); - } -``` - -`selectTopCandidate` verliert seine beiden JavaFX-Parameter — es benutzt sie nicht, es -ruft nur `focusChatMemberAndPrepareCq(resolved)`. Signatur ändern auf -`private void selectTopCandidate(ScoreService.TopCandidate candidate)` und die Aufrufer -mitziehen: `:4761` in `selectTopCandidateAt` und der neue oben. - -Den toten Aufrufer bei `:4706` (`moreButton.setOnAction(...)`) mit der Zeile löschen. -Den lebenden bei `:10707` auf die parameterlose Fassung umstellen: - -```java - runOnUi(() -> showTopPriorityCandidatesWindow()); -``` - -- [ ] **Step 8: Das Fenster in den Design- und Profilwechsel einhängen** - -In `applyTheme` (`:6111`) neben die übrigen `applyDarkMode`-Aufrufe: - -```java - TopPriorityCandidatesWindow.INSTANCE.applyDarkMode(darkMode); -``` - -Und dort, wo die anderen Compose-Fenster beim Profilwechsel geschlossen werden — die -Stelle findet `grep -n "SettingsWindow.INSTANCE.hide\|MonitorWindow.INSTANCE.hide" -app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` — -`TopPriorityCandidatesWindow.INSTANCE.hide();` ergänzen. Findet der Grep nichts, ist das -ein Fund: dann schließt der Profilwechsel die Compose-Fenster nicht, und das gehört in -die Commit-Nachricht. - -- [ ] **Step 9: Übersetzen, Suite, Anwendung** - -Run: -```bash -./gradlew clean build --console=plain > /tmp/t2b.txt 2>&1; echo "exit=$?" -python3 -c " -import glob,xml.etree.ElementTree as ET -t=f=0 -for p in glob.glob('*/build/test-results/test/TEST-*.xml'): - r=ET.parse(p).getroot(); t+=int(r.get('tests')); f+=int(r.get('failures'))+int(r.get('errors')) -print(f'tests={t} failures={f}')" -``` - -Erwartet: `exit=0`, `failures=0`, `tests=773` (769 + 4). - -Dann `./gradlew :app-desktop:run`, den Knopf **more** drücken. - -Erwartet: Ein Fenster „Top priority candidates" erscheint mit der Liste; ein Doppelklick -wählt die Station im Hauptfenster aus und schließt das Fenster; das Design folgt dem -Hauptfenster. **Das ist die erste Gelegenheit, dieses Fenster überhaupt zu sehen** — -die Vorlage wurde aus dem Quelltext nachgebaut, nicht aus der Anschauung. - -- [ ] **Step 10: Commit** - -```bash -git add app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt \ - app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -git commit -m "Port the priority candidates window to Compose - -It was the only Stage this class constructed - new Stage() at :4810, the -single occurrence in the file - and the Compose main window's 'more' -button is what opened it. No JavaFX dependency could be dropped while it -stood, however much dead construction was deleted around it. - -The port is small because the window is: a list of 'callsign | score N', -a double-click that selects and closes, and a hint line. The label is -under a test because it is the whole content, and the list renders in a -test at both sizes that matter, empty and full. - -selectTopCandidate loses its two JavaFX parameters. It never used them - -it resolves the candidate and calls focusChatMemberAndPrepareCq. - -registerThemedScene and installSharedSystemMenuBar lose their only live -caller with this window." -``` - ---- - -## Task 3: Die Zeitleisten-Zuführung aus `start()` lösen - -Der zweite kritische Fund aus Teil 1. `timelineView` wird in `start()` erzeugt -(`:7379`), und `updateTimelineVisuals` steigt aus, wenn es `null` ist (`:4886`) — danach -schreibt es in den **Compose**-Zustand (`:4914`–`:4919`). Ein Löschen des „toten Zweigs" -nähme der Compose-Zeitleiste Skeds, Kandidaten und Antennenrichtung. - -**Files:** -- Create: `app-desktop/src/main/kotlin/kst4contest/view/feed/TimelineFeed.kt` -- Create: `app-desktop/src/test/kotlin/kst4contest/view/feed/TimelineFeedTest.kt` -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java:4885-4920` - -**Interfaces:** -- Consumes: `kst4contest.view.compose.TimelineState` — `replaceSkeds(List)`, - `replaceCandidates(List)`, `setBeamWidth(Double)`, und die - Eigenschaften `antennaAzimuth: Double`, `beamWidthDeg: Double`, `skeds`, `candidates`. - **`beamWidthDeg`, nicht `beamWidth`** — der Setter heißt `setBeamWidth`, die - Eigenschaft dahinter `beamWidthDeg`. -- Produces: `class TimelineFeed(target: TimelineState, dispatcher: UiDispatcher)` mit - `fun push(skeds: List, candidates: List, antennaAzimuthDeg: Double, beamWidthDeg: Double)` - -- [ ] **Step 1: Den Test schreiben** - -```kotlin -package kst4contest.view.feed - -import kst4contest.observe.UiDispatcher -import kst4contest.view.compose.TimelineState -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -/** - * What fills the timeline above the send field. - * - * It has its own class because of how it was found: the feed sat in a method guarded by - * a JavaFX field, inside window construction that is never shown, and deleting that - * construction would have emptied the Compose timeline with a green build and no - * compile error. A feed that can be constructed in a test cannot hide like that. - */ -class TimelineFeedTest { - - /** Runs inline and counts, so a test can tell "delivered" from "delivered twice". */ - private class CountingDispatcher(private val uiThread: Boolean = true) : UiDispatcher { - var deliveries = 0 - private set - - override fun runOnUi(task: Runnable) { - deliveries++ - task.run() - } - - override fun isUiThread(): Boolean = uiThread - } - - @Test - fun `it puts what it is given into the timeline`() { - val target = TimelineState() - - TimelineFeed(target, CountingDispatcher()).push(emptyList(), emptyList(), 229.0, 60.0) - - assertEquals(229.0, target.antennaAzimuth, 1e-9) - assertEquals(60.0, target.beamWidthDeg, 1e-9) - } - - /** Review Focus 2: two feeds writing one state is invisible until one goes stale. */ - @Test - fun `one push is one delivery`() { - val dispatcher = CountingDispatcher() - - TimelineFeed(TimelineState(), dispatcher).push(emptyList(), emptyList(), 0.0, 0.0) - - assertEquals(1, dispatcher.deliveries) - } - - /** Review Focus 4: the controller's listeners fire on network threads. */ - @Test - fun `it hands over rather than writing from the calling thread`() { - val dispatcher = CountingDispatcher(uiThread = false) - - TimelineFeed(TimelineState(), dispatcher).push(emptyList(), emptyList(), 10.0, 5.0) - - assertTrue(dispatcher.deliveries > 0) { "the feed wrote without going through the dispatcher" } - } - - /** Review Focus 3: empty is the normal state before the first score run. */ - @Test - fun `an empty push is not an error`() { - val target = TimelineState() - - TimelineFeed(target, CountingDispatcher()).push(emptyList(), emptyList(), 0.0, 0.0) - - assertEquals(0, target.skeds.size) - assertEquals(0, target.candidates.size) - } -} -``` - -- [ ] **Step 2: Den Test laufen lassen und scheitern sehen** - -Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.feed.TimelineFeedTest' --console=plain 2>&1 | grep -E "^e: " | head -3` - -Erwartet: `Unresolved reference 'TimelineFeed'`. - -Scheitert er stattdessen an `antennaAzimuth`, `beamWidthDeg`, `skeds` oder `candidates`, -die Namen in `TimelineState.kt` nachsehen und die Zusicherungen anpassen — geprüft wird -die Zuführung, nicht die Benennung. - -- [ ] **Step 3: Die Klasse schreiben** - -```kotlin -package kst4contest.view.feed - -import kst4contest.observe.UiDispatcher -import kst4contest.view.compose.ContestSked -import kst4contest.view.compose.TimelineCandidate -import kst4contest.view.compose.TimelineState - -/** - * Fills the timeline above the send field. - * - * A class of its own because of how it was found. The feed used to sit inside - * `updateTimelineVisuals()`, behind a guard on a JavaFX field that the never-shown - * window construction created; deleting that construction would have emptied the - * Compose timeline without a compile error and with a green suite. A feed that can be - * constructed on its own cannot hide inside something else's lifetime. - * - * Knows nothing about JavaFX. Delivery goes through the [UiDispatcher] because the - * controller's listeners fire on network threads. - */ -class TimelineFeed( - private val target: TimelineState, - private val dispatcher: UiDispatcher, -) { - - /** - * Replaces everything the timeline shows, in one delivery. - * - * One delivery and not four: four would let the operator see a frame with new skeds - * against an old antenna heading. - * - * The lists are copied before they are handed over. The caller's are snapshots of - * roster state that keeps changing, and the timeline must not follow them. - */ - fun push( - skeds: List, - candidates: List, - antennaAzimuthDeg: Double, - beamWidthDeg: Double, - ) { - val skedsCopy = skeds.toList() - val candidatesCopy = candidates.toList() - - dispatcher.runOnUi { - target.replaceSkeds(skedsCopy) - target.replaceCandidates(candidatesCopy) - target.antennaAzimuth = antennaAzimuthDeg - target.setBeamWidth(beamWidthDeg) - } - } -} -``` - -- [ ] **Step 4: Den Test laufen lassen** - -Run: -```bash -./gradlew :app-desktop:test --tests 'kst4contest.view.feed.TimelineFeedTest' --console=plain > /tmp/t3.txt 2>&1 -grep -E "^e: " /tmp/t3.txt | head -3 -python3 -c " -import glob,xml.etree.ElementTree as ET -for f in glob.glob('app-desktop/build/test-results/test/TEST-*TimelineFeedTest.xml'): - r=ET.parse(f).getroot(); print(f\"tests={r.get('tests')} failures={r.get('failures')}\") - for tc in r.iter('testcase'): - for b in list(tc.iter('failure'))+list(tc.iter('error')): print(' ',tc.get('name'),(b.get('message') or '')[:200])" -``` - -Erwartet: `tests=4 failures=0`. - -- [ ] **Step 5: `updateTimelineVisuals` auf die Zuführung umstellen** - -Ein Feld neben den übrigen Compose-Feldern anlegen: - -```java - private TimelineFeed timelineFeed; -``` - -In `updateTimelineVisuals` die Wache bei `:4886` ändern — `timelineView` darf die -Compose-Zuführung nicht mehr aufhalten: - -```java - private void updateTimelineVisuals() { - if (chatcontroller == null) { - return; - } -``` - -Den Block, der direkt in den Compose-Zustand schreibt (`:4914`–`:4919`), ersetzen: - -```java - if (timelineFeed != null) { - timelineFeed.push( - skedsSnapshot, - composeCandidates, - chatcontroller.getChatPreferences().getActualQTF().get(), - chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg() - ); - } -``` - -Jeder verbleibende Zugriff auf `timelineView` in dieser Methode bekommt seine eigene -Null-Prüfung, damit der JavaFX-Teil fehlen darf, ohne den Compose-Teil mitzunehmen: - -```bash -sed -n '4885,4925p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java | grep -n "timelineView" -``` - -- [ ] **Step 6: Die Zuführung erzeugen, wo der Compose-Zustand entsteht** - -Die Stelle findet -`grep -n "composeMainWindowState = " app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`: - -```java - timelineFeed = new TimelineFeed(composeMainWindowState.getTimeline(), uiDispatcher); -``` - -Den Listener bei `:7428` **nicht** löschen — er bleibt der Auslöser und wandert in -Teil 3 mit dem Rest von `start()`. - -- [ ] **Step 7: Übersetzen und die ganze Suite** - -Run: wie Aufgabe 1, Schritt 5. Erwartet: `exit=0`, `failures=0`, `tests=777`. - -- [ ] **Step 8: Am laufenden Programm prüfen** - -Run: `./gradlew :app-desktop:run` - -Erwartet: Die Zeitleiste über dem Eingabefeld zeigt weiter Skeds und -Prioritätskandidaten, und der Antennenbalken folgt einer Änderung der QTF. Der Test -deckt die Zuführung ab, nicht ihren Auslöser — diese Prüfung deckt den Auslöser ab. - -- [ ] **Step 9: Commit** - -```bash -git add app-desktop/src/main/kotlin/kst4contest/view/feed/TimelineFeed.kt \ - app-desktop/src/test/kotlin/kst4contest/view/feed/TimelineFeedTest.kt \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -git commit -m "Lift the timeline feed out of the window that is never shown - -updateTimelineVisuals() returned at its first line unless timelineView was -non-null, and timelineView is created in the start() body that part 3 will -delete. Everything after that guard writes the Compose timeline: skeds, -candidates, antenna azimuth, beam width. Deleting the 'dead branch' would -have emptied the timeline above the send field, with no compile error and -a green suite. - -TimelineFeed takes the Compose state and a UiDispatcher and knows nothing -about JavaFX. One push is one delivery, which is pinned: four deliveries -would let the operator see new skeds against an old antenna heading." -``` - ---- - -## Task 4: Die Nachrichten-Zuführung aus der Tabellenkonstruktion lösen - -Der erste kritische Fund aus Teil 1. `initFurtherInfoAbtCallsignMSGTable` erzeugt bei -`:3400` die Bindung, deren Listener bei `:3404`–`:3409` in -`composeMainWindowState.getSelectedStationMessages()` schreibt — eine Compose-Tabelle. - -**Files:** -- Create: `app-desktop/src/main/kotlin/kst4contest/view/feed/SelectedStationMessagesFeed.kt` -- Create: `app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt` -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java:3400-3410` - -**Interfaces:** -- Consumes: `kst4contest.view.compose.DataTableState` mit - `replaceRows(List)` und der Eigenschaft `rows: List`. - Konstruktor: `DataTableState(columns, rowKey, tableId = "", widths = null)`. -- Produces: `class SelectedStationMessagesFeed(target: DataTableState, dispatcher: UiDispatcher)` - mit `fun push(messages: List)` - -- [ ] **Step 1: Den Test schreiben** - -```kotlin -package kst4contest.view.feed - -import kst4contest.model.ChatMessage -import kst4contest.observe.UiDispatcher -import kst4contest.view.compose.DataTableState -import kst4contest.view.compose.RowKeys -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -/** - * What fills the "messages of the selected station" table. - * - * Found the hard way: this feed was buried two hundred lines inside a JavaFX table - * builder, so "who calls that builder" answered a different question than "is it safe - * to delete". A feed with its own name and its own test cannot be lost that way. - */ -class SelectedStationMessagesFeedTest { - - private class CountingDispatcher : UiDispatcher { - var deliveries = 0 - private set - - override fun runOnUi(task: Runnable) { - deliveries++ - task.run() - } - - override fun isUiThread(): Boolean = true - } - - private fun emptyTable() = - DataTableState(emptyList(), RowKeys.byReference(), "test") - - @Test - fun `the messages it is given reach the table`() { - val target = emptyTable() - - SelectedStationMessagesFeed(target, CountingDispatcher()).push(listOf(ChatMessage())) - - assertEquals(1, target.rows.size) - } - - /** Review Focus 2. */ - @Test - fun `one push is one delivery`() { - val dispatcher = CountingDispatcher() - - SelectedStationMessagesFeed(emptyTable(), dispatcher).push(listOf(ChatMessage())) - - assertEquals(1, dispatcher.deliveries) - } - - /** Review Focus 3: no station selected is the state the window opens in. */ - @Test - fun `an empty push clears the table rather than failing`() { - val target = emptyTable() - val feed = SelectedStationMessagesFeed(target, CountingDispatcher()) - - feed.push(listOf(ChatMessage())) - feed.push(emptyList()) - - assertEquals(0, target.rows.size) - } - - /** The caller hands over a live mirror; the table must not follow it. */ - @Test - fun `the table keeps its own copy`() { - val target = emptyTable() - val source = mutableListOf(ChatMessage()) - - SelectedStationMessagesFeed(target, CountingDispatcher()).push(source) - source.clear() - - assertEquals(1, target.rows.size, "the table followed the caller's list") - } -} -``` - -- [ ] **Step 2: Den Test laufen lassen und scheitern sehen** - -Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.feed.SelectedStationMessagesFeedTest' --console=plain 2>&1 | grep -E "^e: " | head -3` - -Erwartet: `Unresolved reference 'SelectedStationMessagesFeed'`. - -Scheitert er an `RowKeys.byReference()`, die Fassung nachsehen -(`grep -n "byReference" app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt`) -— aus Kotlin heißt sie je nach Deklaration `RowKeys.byReference()` oder -`RowKeys.INSTANCE.byReference()`. - -- [ ] **Step 3: Die Klasse schreiben** - -```kotlin -package kst4contest.view.feed - -import kst4contest.model.ChatMessage -import kst4contest.observe.UiDispatcher -import kst4contest.view.compose.DataTableState - -/** - * Fills the table of messages belonging to the selected station. - * - * Its own class for the reason it was nearly lost: the feed used to be a listener - * registered two hundred lines inside a JavaFX table builder, so a reader asking "who - * calls that builder" never discovered that deleting it would leave this table - * permanently empty — with a green build and no compile error. - * - * Copies what it is handed. The caller's list is the live mirror of a roster and keeps - * changing; a table holding a view of it would show rows that are no longer there. - */ -class SelectedStationMessagesFeed( - private val target: DataTableState, - private val dispatcher: UiDispatcher, -) { - - fun push(messages: List) { - val copy = messages.toList() - dispatcher.runOnUi { target.replaceRows(copy) } - } -} -``` - -- [ ] **Step 4: Den Test laufen lassen** - -Run: wie Aufgabe 3, Schritt 4, mit `SelectedStationMessagesFeedTest`. - -Erwartet: `tests=4 failures=0`. - -- [ ] **Step 5: Die Zuführung an die Stelle der Listener-Schreibung setzen** - -Feld anlegen und neben `composeMainWindowState` erzeugen: - -```java - private SelectedStationMessagesFeed selectedStationMessagesFeed; -``` -```java - selectedStationMessagesFeed = new SelectedStationMessagesFeed( - composeMainWindowState.getSelectedStationMessages(), uiDispatcher); -``` - -In `initFurtherInfoAbtCallsignMSGTable` den Listener bei `:3403`–`:3409` ersetzen: - -```java - selectedCallSignInfoMessageBinding.list().addListener( - (javafx.collections.ListChangeListener) change -> { - if (selectedStationMessagesFeed != null) { - selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list()); - } - }); -``` - -**Die Bindung selbst bleibt vorerst, wo sie ist.** Sie herauszuziehen ist Teil 3; hier -geht es darum, dass das Ziel der Zuführung einen Namen und einen Test hat. - -- [ ] **Step 6: Die weiteren Schreiber auf die Zuführung umstellen** - -Der Befund nennt vier Schreiber. Die Zeilen haben sich durch Aufgabe 1 verschoben: - -```bash -grep -n "getSelectedStationMessages()" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Jede Fundstelle, die `replaceRows(...)` ruft, auf `selectedStationMessagesFeed.push(...)` -umstellen. **Review Focus 2:** danach darf keine Fundstelle mehr direkt `replaceRows` -auf dieser Tabelle rufen. - -Run zur Kontrolle: -```bash -grep -n "getSelectedStationMessages().replaceRows" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Erwartet: keine Ausgabe. - -- [ ] **Step 7: Übersetzen, Suite, Anwendung** - -Run: wie Aufgabe 1, Schritt 5. Erwartet: `exit=0`, `failures=0`, `tests=781`. - -Dann `./gradlew :app-desktop:run`, eine Station auswählen. - -Erwartet: Die Tabelle „Nachrichten der ausgewählten Station" füllt sich, und ein Wechsel -auf eine andere Station tauscht ihren Inhalt aus. - -- [ ] **Step 8: Commit** - -```bash -git add app-desktop/src/main/kotlin/kst4contest/view/feed/SelectedStationMessagesFeed.kt \ - app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -git commit -m "Give the selected-station message feed a name and a test - -The listener that fills this Compose table was registered two hundred -lines inside a JavaFX table builder, which is why a review of 'who calls -that builder' concluded the builder was safe to delete. It was not: the -table would have gone permanently empty, with a green build. - -SelectedStationMessagesFeed copies what it is handed, because the caller's -list is a live roster mirror and a table holding a view of it would show -rows that are no longer there. All writers now go through it, so there is -one path instead of four." -``` - ---- - -## Task 5: Den Verbindungszustand aus dem JavaFX-Indikator lösen - -Der vierte Fund, den erst der transitive Durchlauf fand. -`updateConnectionStateIndicator` schreibt bei `:6217`–`:6219` in die Compose-Statusleiste -und fasst danach bei `:6224` ein JavaFX-Tooltip an. Sie hat lebende Aufrufer außerhalb -des toten Aufbaus (`:9844`, `:9871`). - -**Files:** -- Create: `app-desktop/src/main/kotlin/kst4contest/view/feed/ConnectionStateFeed.kt` -- Create: `app-desktop/src/test/kotlin/kst4contest/view/feed/ConnectionStateFeedTest.kt` -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java:6200-6230` - -**Interfaces:** -- Consumes: `kst4contest.view.compose.MainWindowSurroundings` mit den Eigenschaften - `connectionState: On4KstConnectionState` und `connectionDetail: String`. - Konstruktor `MainWindowSurroundings(menu: MainMenuState = MainMenuState())` — - aus Kotlin reicht `MainWindowSurroundings()`. -- Produces: `class ConnectionStateFeed(target: MainWindowSurroundings, dispatcher: UiDispatcher)` - mit `fun push(state: On4KstConnectionState?, detail: String?)` - -- [ ] **Step 1: Den Test schreiben** - -```kotlin -package kst4contest.view.feed - -import kst4contest.controller.On4KstConnectionState -import kst4contest.observe.UiDispatcher -import kst4contest.view.compose.MainWindowSurroundings -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -/** - * What fills the connection indicator in the Compose status bar. - * - * The JavaFX method this came out of did two things in one breath: it wrote the Compose - * state and then touched a JavaFX tooltip. Deleting the construction that creates the - * tooltip would have left live callers running into the second half. - */ -class ConnectionStateFeedTest { - - private class CountingDispatcher : UiDispatcher { - var deliveries = 0 - private set - - override fun runOnUi(task: Runnable) { - deliveries++ - task.run() - } - - override fun isUiThread(): Boolean = true - } - - @Test - fun `the state and its detail reach the status bar`() { - val target = MainWindowSurroundings() - - ConnectionStateFeed(target, CountingDispatcher()) - .push(On4KstConnectionState.CONNECTED, "logged in as DN9APW") - - assertEquals(On4KstConnectionState.CONNECTED, target.connectionState) - assertEquals("logged in as DN9APW", target.connectionDetail) - } - - /** - * Review Focus 3. The JavaFX method substituted DISCONNECTED for an absent state - * and the state's own name for a blank detail; an operator reading "null" in a - * status bar learns nothing. - */ - @Test - fun `a missing state reads as disconnected`() { - val target = MainWindowSurroundings() - - ConnectionStateFeed(target, CountingDispatcher()).push(null, null) - - assertEquals(On4KstConnectionState.DISCONNECTED, target.connectionState) - assertEquals("DISCONNECTED", target.connectionDetail) - } - - @Test - fun `a blank detail reads as the state itself`() { - val target = MainWindowSurroundings() - - ConnectionStateFeed(target, CountingDispatcher()) - .push(On4KstConnectionState.CONNECTING, " ") - - assertEquals("CONNECTING", target.connectionDetail) - } - - /** Review Focus 2. */ - @Test - fun `one push is one delivery`() { - val dispatcher = CountingDispatcher() - - ConnectionStateFeed(MainWindowSurroundings(), dispatcher) - .push(On4KstConnectionState.CONNECTED, "x") - - assertEquals(1, dispatcher.deliveries) - } -} -``` - -Scheitert der Test daran, dass `On4KstConnectionState` kein `CONNECTING` kennt, die -vorhandenen Werte nachsehen -(`grep -n " [A-Z_]*," core/src/main/java/kst4contest/controller/On4KstConnectionState.java`) -und einen davon einsetzen — geprüft wird die Ersetzungsregel, nicht der Name des Werts. - -- [ ] **Step 2: Den Test laufen lassen und scheitern sehen** - -Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.feed.ConnectionStateFeedTest' --console=plain 2>&1 | grep -E "^e: " | head -3` - -Erwartet: `Unresolved reference 'ConnectionStateFeed'`. - -- [ ] **Step 3: Die Klasse schreiben** - -```kotlin -package kst4contest.view.feed - -import kst4contest.controller.On4KstConnectionState -import kst4contest.observe.UiDispatcher -import kst4contest.view.compose.MainWindowSurroundings - -/** - * Fills the connection indicator in the Compose status bar. - * - * Separated from `updateConnectionStateIndicator()`, which wrote this state and then - * touched a JavaFX tooltip in the same method. The tooltip belongs to the window that - * is never shown; its live callers do not, and they must not be taken down with it. - * - * The substitutions are the JavaFX method's own: an absent state reads as DISCONNECTED - * and an absent detail as the state's name, because "null" in a status bar tells an - * operator nothing. - */ -class ConnectionStateFeed( - private val target: MainWindowSurroundings, - private val dispatcher: UiDispatcher, -) { - - fun push(state: On4KstConnectionState?, detail: String?) { - val effectiveState = state ?: On4KstConnectionState.DISCONNECTED - val effectiveDetail = - if (detail.isNullOrBlank()) effectiveState.name else detail - - dispatcher.runOnUi { - target.connectionState = effectiveState - target.connectionDetail = effectiveDetail - } - } -} -``` - -- [ ] **Step 4: Den Test laufen lassen** - -Run: wie Aufgabe 3, Schritt 4, mit `ConnectionStateFeedTest`. - -Erwartet: `tests=4 failures=0`. - -- [ ] **Step 5: `updateConnectionStateIndicator` umstellen** - -Feld anlegen und neben `composeMainWindowState` erzeugen: - -```java - private ConnectionStateFeed connectionStateFeed; -``` -```java - connectionStateFeed = new ConnectionStateFeed( - composeMainWindowState.getSurroundings(), uiDispatcher); -``` - -Den Block bei `:6217`–`:6220` ersetzen: - -```java - if (connectionStateFeed != null) { - connectionStateFeed.push(state, detail); - } -``` - -Die Normalisierung bei `:6212`–`:6215` (`effectiveState`, `stateDetail`) bleibt stehen — -der JavaFX-Teil darunter braucht sie. Dass sie damit an zwei Stellen steht, ist Absicht -und endet, wenn der JavaFX-Teil in Teil 3 fällt; `ConnectionStateFeedTest` hält die -Fassung fest, die bleibt. - -Jeden verbleibenden Zugriff auf `tipConnectionStateIndicator` und die übrigen -JavaFX-Felder in dieser Methode einzeln null-prüfen, damit sie fehlen dürfen. - -- [ ] **Step 6: Übersetzen, Suite, Anwendung** - -Run: wie Aufgabe 1, Schritt 5. Erwartet: `exit=0`, `failures=0`, `tests=785`. - -Dann `./gradlew :app-desktop:run`, die Verbindung zu ON4KST herstellen und trennen. - -Erwartet: Die Verbindungsanzeige in der Compose-Statusleiste wechselt mit, und der -Hinweistext daneben ändert sich. - -- [ ] **Step 7: Commit** - -```bash -git add app-desktop/src/main/kotlin/kst4contest/view/feed/ConnectionStateFeed.kt \ - app-desktop/src/test/kotlin/kst4contest/view/feed/ConnectionStateFeedTest.kt \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -git commit -m "Separate the connection-state feed from the JavaFX indicator - -updateConnectionStateIndicator wrote the Compose status bar and then -touched a JavaFX tooltip in the same method. The tooltip belongs to the -window that is never shown; the live callers at the bottom of the file do -not, and deleting the construction would have run them into the second -half. - -Only the transitive sweep found this one: the init method's own body is -clean, and it is the method it calls that writes. The substitutions are -the originals - an absent state reads as DISCONNECTED and an absent detail -as the state's name, because 'null' in a status bar tells an operator -nothing." -``` - ---- - -## Task 6: Die Menüeinträge gegen die Compose-Leiste abgleichen - -`initMenuBar` (337 Zeilen) öffnet über seine Einträge die Compose-Fenster. Es wird -portiert, nicht gelöscht — aber erst muss feststehen, **was** zu portieren ist. - -**Files:** -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` -- Read only: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java:5729-6066`, - `app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt`, - `app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt` - -Diese Aufgabe ändert keinen Produktionscode. Ihr Ergebnis ist eine Liste, aus der Teil 3 -die Portierung schreibt. - -- [ ] **Step 1: Die JavaFX-Einträge auflisten** - -Run: -```bash -sed -n '5729,6066p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - | grep -nE "new Menu\(|new MenuItem\(|new CheckMenuItem\(|setOnAction" -``` - -Erwartet: die Menüs und ihre Einträge mit den Aktionen, die daran hängen. Für jeden -Eintrag Beschriftung und Aktion notieren. - -- [ ] **Step 2: Die Compose-Einträge auflisten** - -Run: -```bash -grep -nE "Text\(|onClick|MenuItem|fun " app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt -grep -n "class MainMenuState" -A 30 app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt -``` - -Erwartet: die Einträge, die das Compose-Hauptfenster bereits hat. - -- [ ] **Step 3: Die Differenz in den Befund schreiben** - -Unter „initMenuBar" im Befunddokument einen Abschnitt ergänzen: - -```markdown -### Abgleich mit `MainMenuBar.kt` (Stand 2026-10-01) - -| JavaFX-Eintrag | Aktion | In Compose vorhanden? | -|---|---|---| -| … | … | ja / **fehlt** | - -**Zu portieren:** -**Nur macOS:** -``` - -Jede Zeile ausfüllen. Bleibt eine spitze Klammer stehen, ist die Aufgabe nicht fertig. - -- [ ] **Step 4: Prüfen, dass keine Vorlage offen blieb** - -Run: -```bash -grep -nE " **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Die drei gemischten Methoden, die Teil 2 mit einer Schnittlinie markiert hat, entlang dieser Linie trennen — der lebende Compose-Schreibweg bleibt, der nie gezeigte JavaFX-Teil fällt — und danach den gesamten durchsetzten Aufbau in `Kst4ContestApplication` in bauenden, geprüften Scheiben löschen, bis die Klasse auf ihre lebende Fläche zusammengeschrumpft ist. - -**Architecture:** Teil 2 hat die harte Arbeit vorweggenommen: die drei Zuführungen (`TimelineFeed`, `SelectedStationMessagesFeed`, `ConnectionStateFeed`) existieren als benannte, getestete Einheiten, werden neben der Deklaration von `composeMainWindowState` erzeugt (nicht mehr verstreut in `start()`), und drei Methoden tragen den Kommentar „part 3 cuts at this line". Teil 3 ist deshalb kein Entwurf, sondern eine Ausführung: entlang gesetzter Linien schneiden, dann Blätter vor Wurzeln löschen. Jede Löschscheibe wird einzeln gebaut, mit dem Durchlauf-Skript vorgeprüft und mit einem Anwendungsstart abgenommen, weil kein Test beantwortet, ob ein entfernter Listener gefehlt hat. - -**Tech Stack:** Java 21, Kotlin 2.2, Gradle, JUnit 5, Compose Multiplatform 1.8.2, JavaFX 21 (noch vorhanden). - -**Spec:** `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md` - -**Befund, auf dem jede Aufgabe aufsetzt:** `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -## Was Teil 2 geliefert hat — und was dieser Plan am echten Code vorfand - -Dieser Plan wurde gegen den Code vom 2. Oktober 2026 geschrieben, nicht gegen die -Zeilennummern des Befunds. Die Datei ist seit dem 1. Oktober von ~11.655 auf **11.179 -Zeilen** geschrumpft, und mehrere Stellen des Befunds sind durch Teil 2 überholt. Die -wichtigsten Abweichungen, damit der ausführende Agent nicht nach veralteten Zeilen -sucht: - -- **Baseline ist 800 Tests, 0 Fehler** (nicht 769/773 wie im Befund und im Teil-2-Plan). - `./gradlew clean build` ist grün, Stand 2026-10-02. Verifiziert über die XML unter - `*/build/test-results/test/TEST-*.xml`: 106 Dateien, `tests=800 failures=0 errors=0 - skipped=3`. Die drei übersprungenen sind Generatoren (u. a. der `derive`-Generator aus - Teil 1). -- **Scheibe B ist strukturell erledigt.** Die drei Feeds werden bei `:10133`–`:10138` - erzeugt, unmittelbar nach `composeMainWindowState = new MainWindowState(...)` bei - `:10078` — also genau in der „empfohlenen Endform" des Befunds. - `FeedLifetimeIsTiedToTheWindowTest` hält das fest. **`openComposeMainWindowIfRequested` existiert unter - diesem Namen nicht mehr**; der erzeugende Rumpf ist die Methode um `:10078`. -- **Die Zuführungs-Ziele sind bereits entkoppelt.** `updateTimelineVisuals` (`:4802`) - hat keine Wache mehr auf `timelineView` am Compose-Datenpfad; es schiebt bei - `:4839`–`:4846` in `timelineFeed`, wenn der nicht null ist, und berührt `timelineView` - nur noch im eigenen, toten Zweig (`:4819`–`:4821`). -- **Die drei Schnittlinien sind im Code markiert.** `updateTimelineVisuals`, - `updateConnectionStateIndicator` (`:5693`, Kommentar bei `:5736`: „part 3 cuts at this - line") und `initFurtherInfoAbtCallsignMSGTable` (`:3220`, Kommentar bei `:3406`: „the - binding itself still lives in this JavaFX builder - prising it out is part 3") sagen - selbst, wo Teil 3 schneidet. -- **Scheibe D ist im Compose-Code fertig.** `MainMenuBar.kt` trägt alle Aktionen, das - schreibgeschützte macOS-Verbindungsmenü und den auskommentierten Changelog-Eintrag - (`openChangelog`, als „kept so the bar can say so" dokumentiert). Für Teil 3 bleibt - nur, `initMenuBar` in der JavaFX-Datei zu löschen, nachdem der Abgleich bestätigt, - dass nichts Portierbares verloren geht. -- **Scheibe E ist halb erledigt.** Die Doppel-Zustellung in - `updateConnectionStateIndicator` ist schon auf „one write" reduziert - (`:5712`–`:5717`), der Feed-Push sitzt oberhalb der Schnittlinie. Offen ist nur das - Löschen des JavaFX-Indikators unterhalb der Linie, zusammen mit - `initConnectionStateIndicatorButton`. - -**Daraus folgt der Zuschnitt dieses Plans.** Scheibe B und D brauchen keine eigene -Entflechtungsaufgabe mehr. Übrig bleiben: eine echte Entflechtung (Scheibe C, die -Bindung aus dem Builder lösen), die Löschscheiben der `init…`-Methoden, das Schneiden -der drei gemischten Methoden an ihrer Linie und zuletzt die zeilenweise Zerlegung von -`start()`. - -## Warum nur Teil 3 - -Die Spezifikation hat sechs Etappen. Teil 1 deckte Etappe 1 und 3 ab, Teil 2 den ersten -Abschnitt von Etappe 2. Dieser Plan ist der **Rest von Etappe 2**: der durchsetzte -Aufbau in `Kst4ContestApplication` wird gelöscht, bis die Klasse auf ihre lebende Fläche -zusammengeschrumpft ist. Was danach kommt — der `UiDispatcher`-Tausch (Etappe 4), der -Lebenszyklus samt der zehn `Alert`-Dialoge (Etappe 5), die Restdateien und die Befreiung -des Builds von `org.openjfx` (Etappe 6) — ist **nicht** Teil dieses Plans. Diese Etappen -sind erst schreibbar, wenn `start()` zerlegt ist; sie vorwegzunehmen hieße raten. - -## Global Constraints - -- Kommunikation mit dem Benutzer auf Deutsch; Quelltextkommentare und Javadoc - ausschließlich auf Englisch (`AGENTS.md`). -- Keine neue Produktionsabhängigkeit ohne vorherige Freigabe (`AGENTS.md`). -- Kein Push, Merge, Tag oder Release. Die Commit-Schritte unten sind durch die Freigabe - dieses Plans gedeckt, nichts darüber hinaus. -- **Nicht** aus Exit-Code 0 auf „alle Tests grün" schließen — die XML-Ergebnisse unter - `*/build/test-results/test/TEST-*.xml` lesen (`AGENTS.md`). Das Zählskript steht in - Aufgabe 0. -- Ausgangslage: `./gradlew clean build` → exit 0, **800 Tests, 0 Fehler**. Jede - Rotfärbung ist ein Signal. -- Signierte Commits sind konfiguriert (`commit.gpgsign=true`, SSH). Schlägt das Signieren - fehl, läuft der Bitwarden-Agent nicht — das ist kein Grund, unsigniert zu committen. -- `ObservableRoster` ist der Speicher, die Listen sind Spiegel. Kein Worker-Faden fasst - UI-Sammlungen an. Zustellung nur über `UiDispatcher` (`AGENTS.md`). -- Zoom, Auswahl, Fokus, Sortierung und vorbelegter Text ändern sich nicht als - Nebeneffekt (`AGENTS.md`). -- **Vor jeder Löschung** den Durchlauf laufen lassen: - `python3 docs/superpowers/notes/javafx-compose-sweep.py`. -- Die 91 MP3-Dateien in `core/src/main/resources` **bleiben** — sie gehören der lebenden - CW-Ausgabe in `PlayAudioUtils`, nicht dem toten `playCWLauncher`. - -## Review Focus - -Sechs Dinge, die die Spezifikation voraussetzt, die aber keine Aufgabe hier von sich aus -prüft, nach Wahrscheinlichkeit geordnet, dass sie jemanden treffen: - -1. **Eine Löschung nimmt der Compose-Oberfläche still einen Anstoß.** Das ist die - Fehlerklasse, die Teil 1 gefunden hat: kein Übersetzungsfehler, kein Testfehler, nur - eine leere Tabelle oder eine eingefrorene Zeitleiste. Jede Löschscheibe bekommt - deshalb einen Anwendungsstart als Abnahme, nicht nur einen grünen Build — und bei den - drei Zuführungen zusätzlich eine konkrete Bedienhandlung (Station wählen, Verbindung - wechseln, Sked eintreffen lassen). -2. **Ein Feld überlebt die Methode, die es allein benutzte.** Der Übersetzer meldet ein - unbenutztes privates Feld nicht als Fehler. Jede Löschscheibe prüft deshalb mit einem - Grep auf die Feldnamen, ob außerhalb des gelöschten Bereichs noch eine Fundstelle - steht. -3. **Eine „tote" Methode war über einen Compose-Rückruf doch erreichbar.** Die - Compose-Fenster rufen in `Kst4ContestApplication` zurück. Das Durchlauf-Skript prüft - das transitiv bis Tiefe 3; jede Löschscheibe lässt es **vorher** laufen und bricht - ab, wenn eine zu löschende Methode als `REACHES COMPOSE` gemeldet wird. -4. **Scheibe C löst die falsche Hälfte der Doppel-Zustellung.** `refresh()` **und** ein - `Platform.runLater` schieben heute beide (`:320`/`:10381`); der Listener bei `:3410` - schiebt zusätzlich. Aufgabe 1 benennt ausdrücklich, welcher Weg bleibt, und prüft mit - einem Zähler, dass die ausgewählte Station nach dem Umbau genau einmal zugestellt - wird. -5. **Die zeilenweise Zerlegung von `start()` lässt einen lebenden Listener fallen.** In - `start()` liegen tote `timelineView`-Zeilen und lebende Auslöser dicht beieinander - (`:6927`, `:6930`, `:6945`, `:9069`). Aufgabe 7 trennt sie einzeln und hält jeden - lebenden Auslöser mit einer Begründung fest, warum er bleibt. -6. **Das Durchlauf-Skript meldet eine Methode fälschlich sauber, weil `COMPOSE_TOKENS` - unvollständig ist.** Kommt in Teil 3 ein neues Compose-Fenster oder eine neue - `replace…`-Senke dazu, ist die Liste im Skript zu pflegen. Aufgabe 0 prüft die Liste - einmal gegen die heute existierenden Compose-Senken. - ---- - -## File Structure - -| Datei | Verantwortung | Aufgabe | -|---|---|---| -| `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` | **ändern** — Bindung entflechten, dann in Scheiben löschen | 1–7 | -| `app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt` | **ändern** — ein Zähler-Test für die eine Zustellung (Review Focus 4) | 1 | -| `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` | **ändern** — abgearbeitete Scheiben abhaken | 1–7 | - -Keine neue Produktionsdatei. Teil 3 ist Entflechten und Löschen in **einer** Datei; die -Zuführungsklassen stehen schon. - ---- - -## Task 0: Baseline festnageln und das Durchlauf-Skript prüfen - -Keine Produktionsänderung. Diese Aufgabe stellt sicher, dass die folgenden Löschungen -auf einem belastbaren Netz stehen, und dass das Durchlauf-Skript die heutigen -Compose-Senken kennt. - -**Files:** -- Read only: `docs/superpowers/notes/javafx-compose-sweep.py`, - `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -- [ ] **Step 1: Den grünen Ausgangsstand bestätigen** - -Run: -```bash -cd /home/philipp/Projects/ham/kst4contest -./gradlew clean build --console=plain > /tmp/t3-base.txt 2>&1; echo "exit=$?" -python3 - <<'PY' -import glob, xml.etree.ElementTree as ET -t=f=e=s=0 -for p in glob.glob("*/build/test-results/test/TEST-*.xml"): - r=ET.parse(p).getroot() - t+=int(r.get("tests")); f+=int(r.get("failures")); e+=int(r.get("errors")); s+=int(r.get("skipped")) -print(f"tests={t} failures={f} errors={e} skipped={s}") -PY -``` - -Erwartet: `exit=0`, `tests=800 failures=0 errors=0 skipped=3`. Weicht die Zahl ab, ist -der Ausgangsstand ein anderer als beim Schreiben dieses Plans — anhalten und die -Abweichung klären, bevor etwas gelöscht wird. - -- [ ] **Step 2: Den Durchlauf laufen lassen und die drei lebenden Methoden sehen** - -Run: `python3 docs/superpowers/notes/javafx-compose-sweep.py` - -Erwartet: Genau drei `init…`-Methoden als `REACHES COMPOSE`: -`initConnectionStateIndicatorButton`, `initFurtherInfoAbtCallsignMSGTable`, `initMenuBar`. -Die Zeitleiste ist keine `init…`-Methode und erscheint nicht; sie wird in Aufgabe 7 -behandelt. Meldet das Skript **mehr** als drei, ist eine neue Compose-Senke dazugekommen -und der Plan ist vor dem Löschen an ihr zu messen. - -- [ ] **Step 3: `COMPOSE_TOKENS` gegen die heutigen Senken prüfen** - -Run: `grep -nE "replace[A-Z]\w+|Window\.INSTANCE|composeMainWindowState" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java | grep -oE "replace[A-Z]\w+|[A-Z]\w+Window" | sort -u` - -Erwartet: Jeder gefundene `replace…`-Name und jedes `…Window` steht in `COMPOSE_TOKENS` -im Skript. Fehlt einer (etwa `replaceRows`, `replaceSkeds`, `replaceCandidates`, -`StationMapWindow`), ist das Skript zu ergänzen, **bevor** es als Prüfung benutzt wird — -sonst meldet es eine Methode fälschlich sauber (Review Focus 6). - -Keine Datei wird in dieser Aufgabe geändert und kein Commit erzeugt. - ---- - -## Task 1: `selectedCallSignInfoMessageBinding` aus dem JavaFX-Builder lösen (Scheibe C) - -Der Code sagt selbst, dass dies Teil 3 ist: der Kommentar bei `:3406` lautet „The -binding itself still lives in this JavaFX builder - prising it out is part 3". Danach -kann `initFurtherInfoAbtCallsignMSGTable` in Aufgabe 4 fallen, ohne die Nachrichtentabelle -der ausgewählten Station mitzunehmen. - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt` - -**Interfaces:** -- Consumes: `derivedBinding(...)`, `selectedStationMessagesFeed.push(List)`, - `rosterBindings`. -- Produces: nichts Neues. `selectedCallSignInfoMessageBinding` entsteht danach außerhalb - der Tabellenkonstruktion. - -**Vorgefundene Doppel-Zustellung (Review Focus 4):** Heute schieben drei Wege in die -Compose-Nachrichtentabelle — der Listener auf `selectedCallSignInfoMessageBinding.list()` -(`:3410`–`:3414`), und zusätzlich `applySelectedCallSignInfoFilter` (`:319`–`:324`) und -die Stationsauswahl (`:10380`–`:10385`), die beide `refresh()` rufen **und** danach selbst -`push(...)`. `refresh()` ändert die Spiegelliste und löst den Listener aus; der folgende -`push` stellt denselben Inhalt ein zweites Mal zu. **Welcher Weg bleibt:** der Listener. -Er ist der eine Schreibweg, der an die Datenänderung gekoppelt ist; die beiden -nachgezogenen `push`-Aufrufe sind die Doppelung. Diese Aufgabe entfernt die beiden -nachgezogenen `push`-Aufrufe **nicht** — das ist eine Verhaltensänderung über die -Entflechtung hinaus und gehört, falls erwünscht, in eine eigene, benannte Aufgabe. Sie -hält den Zustand nur mit einem Zähler-Test fest, damit eine spätere Auflösung messbar -ist. - -- [ ] **Step 1: Die heutige Erzeugung und ihre drei Zuführungswege lesen** - -Run: -```bash -sed -n '3395,3416p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -sed -n '315,326p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -sed -n '10374,10388p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -grep -n "selectedCallSignInfoMessageBinding" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Erwartet: Erzeugung über `derivedBinding(...)` innerhalb von `initFurtherInfoAbtCallsignMSGTable` -(um `:3402`), der Listener, der `selectedStationMessagesFeed.push(...)` ruft, und die -Nullsetzung bei `:6376`. Jede Fundstelle notieren — die Zeilen verschieben sich mit jeder -Löschung, also hier festhalten, nicht später raten. - -- [ ] **Step 2: Eine eigene Erzeugungsmethode schreiben, die nichts von der Tabelle weiß** - -Eine neue private Methode anlegen, die genau das tut, was heute mitten in -`initFurtherInfoAbtCallsignMSGTable` steht — die alte Bindung freigeben, die neue über -`derivedBinding` erzeugen, den Listener anmelden —, aber ohne jeden `TableView`-Bezug: - -```java - /** - * Creates the mirror that feeds the Compose "messages of the selected station" table. - * - * Lifted out of initFurtherInfoAbtCallsignMSGTable, which built the never-shown - * JavaFX table around it. The feed it drives does not depend on that table existing, - * so the binding must not either - otherwise deleting the table would silently empty - * the Compose table with a green build. - */ - private void rebuildSelectedCallSignInfoMessageBinding() { - if (selectedCallSignInfoMessageBinding != null) { - selectedCallSignInfoMessageBinding.dispose(); - rosterBindings.remove(selectedCallSignInfoMessageBinding); - } - selectedCallSignInfoMessageBinding = derivedBinding( - chatcontroller::selectedCallSignInfoMessages, - chatcontroller.getLst_globalChatMessageList()); - selectedCallSignInfoMessageBinding.list().addListener((javafx.collections.ListChangeListener) c -> { - if (selectedStationMessagesFeed != null) { - selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list()); - } - }); - } -``` - -Den herausgelösten Block in `initFurtherInfoAbtCallsignMSGTable` durch einen Aufruf von -`rebuildSelectedCallSignInfoMessageBinding()` ersetzen, **bevor** -`tbl_furtherInfoAbtCallsignMSGTable.setItems(selectedCallSignInfoMessageBinding.list())` -steht — die tote Tabelle darf die Liste weiter beziehen, solange sie existiert; das -ändert sich erst mit ihrer Löschung in Aufgabe 4. - -- [ ] **Step 3: Den Aufruf dorthin hängen, wo die anderen Zuführungen erzeugt werden** - -In der Methode, die `composeMainWindowState` und die Feeds erzeugt (um `:10078`–`:10138`), -nach der Feed-Erzeugung `rebuildSelectedCallSignInfoMessageBinding()` aufrufen, falls die -Bindung dort noch nicht steht. **Vorsicht:** Die Bindung wird heute auch beim Öffnen des -Info-Fensters neu erzeugt (der Builder läuft je Auswahl). Prüfen, ob der neue Aufruf eine -Doppelerzeugung verursacht; ist das der Fall, nur den Builder-Aufruf behalten und diesen -Schritt überspringen — der Zähler-Test in Schritt 5 fängt eine Doppelerzeugung. - -Run: `grep -n "rebuildSelectedCallSignInfoMessageBinding\|initFurtherInfoAbtCallsignMSGTable()" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -Erwartet: der neue Methodenname und seine Aufrufer sind sichtbar; die Erzeugung läuft -genau einmal pro Info-Fenster-Aufbau. - -- [ ] **Step 4: Übersetzen** - -Run: `./gradlew :app-desktop:compileJava --console=plain 2>&1 | tail -5` - -Erwartet: `BUILD SUCCESSFUL`. Ein Übersetzungsfehler hier ist am billigsten zu beheben, -bevor Tests laufen. - -- [ ] **Step 5: Einen Zähler-Test ergänzen, der die eine Zustellung festhält** - -In `SelectedStationMessagesFeedTest.kt` einen Test ergänzen, der mit einem zählenden -`UiDispatcher` prüft, dass ein `push` genau eine Zustellung ist — dasselbe Muster, das -`TimelineFeedTest` schon benutzt (Review Focus 4). Der bestehende Test bleibt unberührt; -der neue hält fest, dass die Zuführungsklasse selbst nicht doppelt schreibt (die -Systemdoppelung über `refresh()` + nachgezogenem `push` ist davon unberührt und bleibt -bewusst bestehen — siehe Interfaces oben). - -Run: -```bash -./gradlew :app-desktop:test --tests 'kst4contest.view.feed.SelectedStationMessagesFeedTest' --console=plain > /tmp/t3-1.txt 2>&1 -python3 - <<'PY' -import glob, xml.etree.ElementTree as ET -for f in glob.glob("app-desktop/build/test-results/test/TEST-*SelectedStationMessagesFeedTest.xml"): - r=ET.parse(f).getroot(); print(f"tests={r.get('tests')} failures={r.get('failures')} errors={r.get('errors')}") - for tc in r.iter("testcase"): - for b in list(tc.iter("failure"))+list(tc.iter("error")): print(" ", tc.get("name"), (b.get("message") or "")[:200]) -PY -``` - -Erwartet: `failures=0 errors=0`, Testzahl um eins höher als zuvor. - -- [ ] **Step 6: Die ganze Suite und ein Anwendungsstart** - -Run: -```bash -./gradlew clean build --console=plain > /tmp/t3-1b.txt 2>&1; echo "exit=$?" -python3 - <<'PY' -import glob, xml.etree.ElementTree as ET -t=f=0 -for p in glob.glob("*/build/test-results/test/TEST-*.xml"): - r=ET.parse(p).getroot(); t+=int(r.get("tests")); f+=int(r.get("failures"))+int(r.get("errors")) -print(f"tests={t} failures={f}") -PY -``` - -Erwartet: `exit=0`, `failures=0`, `tests=801` (800 + 1). - -Dann `./gradlew :app-desktop:run`: Anwendung starten, eine Station wählen, prüfen dass -sich die Nachrichtentabelle der ausgewählten Station füllt, einen Filter umschalten, -prüfen dass sie folgt. Beenden. **Das ist die Prüfung, die kein Test leistet** (Review -Focus 1). - -- [ ] **Step 7: Den Befund abhaken und committen** - -In `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` die Scheibe C als erledigt -markieren. - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Prise the selected-station message binding out of the JavaFX builder - -initFurtherInfoAbtCallsignMSGTable built the never-shown JavaFX table and -created selectedCallSignInfoMessageBinding in the middle of doing so. The -Compose feed the binding drives does not depend on that table, so the -binding must not either - otherwise deleting the table in a later slice -would silently empty the Compose 'messages of the selected station' table -with a green build and no compile error, the exact failure class part 1 -was written to prevent. - -The binding now has its own builder, rebuildSelectedCallSignInfoMessage- -Binding, with no TableView reference. A counting-dispatcher test pins that -one push is one delivery inside the feed; the system-level double delivery -(refresh() plus a trailing push) is left as found and noted, because -resolving it changes behaviour beyond this disentanglement." -``` - ---- - -## Befund nach Task 1: der Rest-Zuschnitt ist anders als zuerst geplant - -Beim Vorbereiten von Task 2 am echten Code (Stand 2026-10-02, nach den Commits bis -`03b9c1bd`) sind zwei Annahmen der ersten Planfassung widerlegt worden. Die Tasks 2–7 -unten sind deshalb durch die Scheiben **S1–S6** ersetzt. - -1. **Es gibt keine freistehenden Blatt-Methoden mehr.** Die vier unreferenzierten - `init…`-Methoden, die die erste Fassung in Task 2 löschen wollte - (`initShortcutTable`, `initNotifyAtCallSignTable`, `initTextSnippetsTable`, - `initWkdStnTable`), sind bereits in Teil 2 gelöscht — ein Grep über den ganzen Baum - findet sie **nullmal**. Task 2 der ersten Fassung ist gegenstandslos. -2. **Jeder verbliebene Tabellen-Builder hat genau einen Aufrufer, und der sitzt im toten - `try`-Block von `start()`** (`:6961`–`:9028`; `primaryStage.setScene(...)` und - `primaryStage.show()` sind beide auskommentiert, `:9019`/`:9028`). Methode und - Aufrufer fallen also zusammen; „Blätter zuerst, Wurzeln zuletzt" greift nicht, weil es - keine Blätter ohne Aufrufer gibt. -3. **Der lebende Compose-Pfad liegt außerhalb des toten Blocks.** - `openComposeMainWindowIfRequested()` (erzeugt `MainWindowState`, die drei Feeds und - die `composeMemberListener`/`composeChatListener`) wird **nach** dem `catch` gerufen - (um `:9055`), ebenso `openMonitorWindow`, `openUpdateWindowIfAvailable`, - `openSettingsWindow` und die Zeitleisten-Erstbefüllung - `Platform.runLater(this::updateTimelineVisuals)` (um `:9079`). Der tote Szenenaufbau - ist damit ein abgrenzbarer `try`-Block, dessen lebende Zuführungen daneben, nicht - darin, liegen. - -Die reglose Reihenfolge bleibt: risikoarm zuerst, die Mitgliedertabelle mit der -Filterleiste als heikelste Scheibe mit ausdrücklicher Listener-Prüfung, der -`start()`-Rest zuletzt. Jede Scheibe wird einzeln gebaut (`clean build` + XML), lässt -vorher den Durchlauf laufen, erhält jeden lebenden Listener belegt und wird committet. -Die GUI-Abnahme (Anwendungsstart) kann der ausführende Agent nicht selbst leisten; sie -wird gesammelt und am Ende von Marc gebündelt durchgeführt. - ---- - -## S1: Die drei Statusknöpfe schneiden und löschen (Scheibe E) - -`updateConnectionStateIndicator` ist gemischt: oben der lebende Feed-Push, unten der -JavaFX-Indikator. Der Code markiert die Linie selbst („part 3 cuts at this line", heute -um `:5745`). Die risikoärmste der Rest-Scheiben, deshalb zuerst. - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -- [ ] **Step 1: Durchlauf und lebende Aufrufer erheben** - -Run: -```bash -python3 docs/superpowers/notes/javafx-compose-sweep.py | grep -iE "connection|sked|bandupgrade" -grep -n "updateConnectionStateIndicator\|initConnectionStateIndicatorButton\|initSkedWarnIndicatorButton\|initBandUpgradeIndicatorButton\|btnConnectionStateIndicator\|tipConnectionStateIndicator\|btnSkedWarnIndicator\|btnBandUpgradeIndicator" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Erwartet: `updateConnectionStateIndicator` hat lebende Aufrufer außerhalb des toten -Aufbaus (die den Feed speisen) — diese bleiben. Die drei `init…`-Knopf-Methoden werden -nur aus `start()` gerufen (`:7116`/`:7122`/`:7145`). - -- [ ] **Step 2: `updateConnectionStateIndicator` an der Linie trennen** - -Alles unterhalb von „part 3 cuts at this line" (Tooltip, Button-Stil, macOS-Menütexte) -entfernen. Oben bleiben: Fadenprüfung, Normalisierung (`effectiveState`/`stateDetail`), -der eine Feed-Push, `logConnectionIndicatorTransition`. Die Normalisierung bleibt — sie -ist der Vertrag, den `ConnectionStateFeedTest` festhält. Die macOS-Menüfelder -(`menuConnectionStateMacOs`/`menuItemConnectionStateDetailMacOs`) werden nur in -`initMenuBar` eingehängt und fallen mit S2. - -- [ ] **Step 3: Die drei Knopf-Builder und ihre `start()`-Aufrufer löschen** - -`initConnectionStateIndicatorButton`, `initSkedWarnIndicatorButton`, -`initBandUpgradeIndicatorButton` samt Javadoc entfernen, die Aufrufzeilen in `start()` -mitnehmen, und die nur von ihnen genutzten Felder. Prüfen: - -Run: `grep -n "btnConnectionStateIndicator\|tipConnectionStateIndicator\|btnSkedWarnIndicator\|btnBandUpgradeIndicator" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -Erwartet: nach dem Löschen keine Fundstelle außerhalb der gelöschten Bereiche. **Vorsicht -beim Verbindungsknopf:** er wird außerhalb des toten Blocks in die Statusleiste gehängt -(`!isMacOs()`), diese Zeile fällt mit. - -- [ ] **Step 4: Übersetzen, Suite, Durchlauf** - -Run: -```bash -./gradlew clean build --console=plain > /tmp/s1.txt 2>&1; echo "exit=$?" -python3 /tmp/count_tests.py -python3 docs/superpowers/notes/javafx-compose-sweep.py | grep -iE "connection|sked|bandupgrade" -``` - -Erwartet: `exit=0`, `tests=800 failures=0`, die drei Methoden erscheinen nicht mehr im -Durchlauf. GUI-Abnahme (Marc, gesammelt): Verbindung herstellen/trennen → die -Compose-Statusleiste wechselt weiter. - -- [ ] **Step 5: Abhaken und committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Cut updateConnectionStateIndicator at its seam and delete the status buttons - -Above the line stays - thread check, normalisation, the single feed push, -the log. Below it goes, with initConnectionStateIndicatorButton and its two -neighbours and the fields only they touched. The Compose status bar is fed -from above the line, so its badge keeps changing. The macOS menu fields -fall with initMenuBar in S2." -``` - ---- - -## S2: `initMenuBar` löschen (Scheibe D ist im Compose-Code bereits erledigt) - -`MainMenuBar.kt` trägt alle Aktionen schon — beim Schreiben dieses Plans am aktuellen -`MainMenuBar.kt` bestätigt: jede aktionstragende JavaFX-Position hat eine -Compose-Entsprechung; die drei Lücken sind ein Trenner, ein zusammengelegter -Doppeleintrag und der auskommentierte `openChangelog`. Nichts zu portieren, nur zu -löschen. - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -- [ ] **Step 1: Den Abgleich am echten Code bestätigen** - -Run: -```bash -grep -n "setOnAction\|menubar.getMenus()\|installSharedSystemMenuBar\|Macros" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java | sed -n '1,80p' -grep -nE "Item\(|Menu\(" app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt -``` - -Erwartet: Jede `setOnAction`-tragende Position hat ein `Item(...)` in `MainMenuBar.kt`. -Eine Aktion ohne Compose-Entsprechung ist ein Fund — anhalten und in den Befund -schreiben, nicht löschen. Das tote `Macros`-Menü (gebaut, nie der Leiste hinzugefügt, -Handler auskommentiert) fällt mit, es wird nicht portiert. - -- [ ] **Step 2: Durchlauf, dann löschen** - -`initMenuBar`, `installSharedSystemMenuBar`, die macOS-Menüfelder und das tote -`Macros`-Menü samt dem `start()`-Aufruf `mainScreenMenuBar = initMenuBar()` entfernen. -Verwaiste Hilfsmethoden/Felder (`mainScreenMenuBar`, `flwpne_StatusBar`, soweit nur hier -benutzt, `macOsConnectionStateMenuTitle` falls `MainMenuBar.kt` eine eigene Entsprechung -hat) mitnehmen; im Zweifel bleibt die Hilfsmethode und der Fund kommt in die -Commit-Nachricht. - -- [ ] **Step 3: Übersetzen, Suite, Durchlauf** - -Run: -```bash -./gradlew clean build --console=plain > /tmp/s2.txt 2>&1; echo "exit=$?" -python3 /tmp/count_tests.py -python3 docs/superpowers/notes/javafx-compose-sweep.py | tail -3 -``` - -Erwartet: `exit=0`, `tests=800 failures=0`, und `0 of N init methods reach Compose -state.` — zum ersten Mal erreicht keine `init…`-Methode mehr Compose-Zustand. GUI-Abnahme -(Marc, gesammelt): die Compose-Fenster-Öffner im Menü (Einstellungen, Monitor, Karte) -laufen weiter. - -- [ ] **Step 4: Abhaken und committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Delete initMenuBar; its port already lives in MainMenuBar.kt - -Nothing to port: MainMenuBar.kt carries every action, the read-only macOS -connection menu and the commented-out changelog item. The three gaps are a -separator, a double entry Compose merged, and a long-commented action. With -this the sweep reports zero init methods reaching Compose - the interleaved -construction is fully separated. installSharedSystemMenuBar, the macOS menu -fields and the dead Macros menu go with it." -``` - ---- - -## S3: Die Nachrichtentabellen-Scheibe löschen - -Die vier Nachrichten-/Cluster-Tabellen und ihr Tab-Container, alle nur aus dem toten -`try`-Block gerufen. Die lebenden `composeChatListener` liegen außerhalb (nach dem -`catch`) und bleiben. - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -- [ ] **Step 1: Durchlauf und Aufrufer prüfen** - -Run: -```bash -python3 docs/superpowers/notes/javafx-compose-sweep.py | tail -3 -grep -n "initChatprivateMSGTable\|initChatGeneralMSGTable\|initChatToOtherMSGTable\|initDXClusterTable\|initBottomGlobalMessageTabPane" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Erwartet: keine dieser Methoden erreicht Compose; jede hat genau einen Aufrufer im toten -`try`-Block (`:7526`/`:7673`/`:7768`/`:3607`/`:3611`). Die zugehörigen toten -Selektionslistener (`privateChatselectionModel…`, `generalChatselectionModel…`) stehen -daneben und fallen mit. - -- [ ] **Step 2: Löschen, verwaiste Felder mitnehmen** - -Die fünf Methoden samt Javadoc und ihre `start()`-Aufrufzeilen entfernen, dazu die toten -Selektionslistener und die nur hier benutzten Felder (`tbl_generalMessageTable`, -`tbl_chatToOther…` usw.). Prüfen: - -Run: `grep -n "tbl_generalMessageTable\|tbl_dxCluster\|tbl_chatToOther\|tbl_chatprivate\|bottomGlobalMessageTabPane" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -Erwartet: nach dem Löschen keine Fundstelle außerhalb der gelöschten Bereiche. - -- [ ] **Step 3: Übersetzen, Suite** - -Run: -```bash -./gradlew clean build --console=plain > /tmp/s3.txt 2>&1; echo "exit=$?" -python3 /tmp/count_tests.py -``` - -Erwartet: `exit=0`, `tests=800 failures=0`. GUI-Abnahme (Marc, gesammelt): die -Chat-/Cluster-Panes im Compose-Fenster füllen sich weiter (über `composeChatListener`). - -- [ ] **Step 4: Abhaken und committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Delete the never-shown message and cluster tables - -The private, general, to-other and DX-cluster tables and their tab pane, -each called once from the dead try block in start(). The Compose chat and -cluster panes are fed by composeChatListener, which lives after the catch -and stays. Their dead selection listeners go with them." -``` - ---- - -## S4: `initFurtherInfoAbtCallsignMSGTable` und die Kompaktsteuerung löschen - -Sicher erst nach Task 1 (Scheibe C): die lebende Bindung lebt seither in -`rebuildSelectedCallSignInfoMessageBinding`, nicht im Tabellen-Builder. - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -- [ ] **Step 1: Durchlauf und Aufrufer prüfen** - -Run: -```bash -python3 docs/superpowers/notes/javafx-compose-sweep.py | grep -iE "furtherinfo|compact" -grep -n "initFurtherInfoAbtCallsignMSGTable\|initSelectedCallSignCompactControlsPane\|rebuildSelectedCallSignInfoMessageBinding" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Erwartet: `initFurtherInfoAbtCallsignMSGTable` erreicht Compose jetzt **über** -`rebuildSelectedCallSignInfoMessageBinding`. Diese Hilfsmethode bleibt; der Tabellen- -Builder und seine Aufrufer (`:895`, `:1547`) sowie -`initSelectedCallSignCompactControlsPane` (`:1523`/`:3468`) fallen. - -- [ ] **Step 2: Löschen, Bindungserzeugung sichern** - -Beide Methoden samt Javadoc und ihre Aufrufer entfernen. **Kritisch:** Der Aufruf von -`rebuildSelectedCallSignInfoMessageBinding()` muss erhalten bleiben — er stand bisher im -Tabellen-Builder. Wird der Builder gelöscht, muss der Aufruf an eine lebende Stelle -wandern, die bei jeder Stationsauswahl läuft (`generateFurtherInfoAbtSelectedCallsignBP` -oder deren Aufrufer). Vor dem Löschen prüfen, wo `rebuild…` dann gerufen wird: - -Run: `grep -n "rebuildSelectedCallSignInfoMessageBinding\|generateFurtherInfoAbtSelectedCallsignBP" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -Erwartet: `rebuild…` wird weiterhin bei jeder Auswahl gerufen; -`selectedCallSignInfoMessageBinding` bleibt lebend. - -- [ ] **Step 3: Übersetzen, Suite** - -Run: -```bash -./gradlew clean build --console=plain > /tmp/s4.txt 2>&1; echo "exit=$?" -python3 /tmp/count_tests.py -``` - -Erwartet: `exit=0`, `tests=800 failures=0`. GUI-Abnahme (Marc, gesammelt): Station -wählen → die Nachrichtentabelle der ausgewählten Station füllt sich weiter. - -- [ ] **Step 4: Abhaken und committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Delete the selected-callsign info table and its compact controls - -Safe because S/Scheibe C moved the binding into rebuildSelectedCallSign- -InfoMessageBinding first. The Compose messages-of-the-selected-station -table is fed by that binding, not by this JavaFX table, so the table goes -while the feed stays. Verified by selecting a station after the deletion." -``` - ---- - -## S5: Die Mitgliedertabelle, das Kontextmenü und die Filterleiste löschen - -Die größte und heikelste Scheibe: `initChatMemberTable` (die größte Methode), -`initChatMemberTableContextMenu` und die Filter-UI im toten `try`-Block. Hier liegen -lebende und tote Listener dicht beieinander. - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -- [ ] **Step 1: Jeden Listener im Filterbereich tot/lebend einordnen** - -Dies ist der eigentliche Prüfschritt der Scheibe und läuft **vor** jeder Löschung. - -Run: -```bash -python3 docs/superpowers/notes/javafx-compose-sweep.py | grep -iE "chatMember" -grep -n "getLst_chatMemberListFilterPredicates\|getStationFilter\|chatMemberTableFilter\|tglBtnQRBEnable\|tglGrpQTF\|selectionModelChatMember\|tbl_chatMember\b\|chatMemberContextMenu\|chatMessageContextMenu" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Für jeden Treffer entscheiden: speist er die **Compose**-Stationsliste (lebend, muss -bleiben oder umgehängt werden) oder nur die tote `TableView` (fällt)? Der Lebendbefund -legt nahe, dass die Compose-Filterung über `composeMainWindowState.getStationFilter()` -(`:9440` ff.) läuft und **nicht** über diese JavaFX-Filter-Listener — das ist hier zu -**bestätigen**, nicht anzunehmen. Jeder lebende Listener wird mit Fundstelle im Befund -notiert, bevor etwas fällt. - -- [ ] **Step 2: Löschen, ggf. in zwei Commits (Kontextmenü, dann Tabelle+Filter)** - -`initChatMemberTable`, `initChatMemberTableContextMenu`, die Filter-UI und die toten -Selektions-/Filter-Listener samt `start()`-Aufrufern entfernen. Das Feld -`chatMemberListBinding` und seine Erzeugung mitnehmen, sobald sein letzter Leser -(`setItems` der toten Tabelle) weg ist: - -Run: `grep -n "chatMemberListBinding" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -Erwartet: nach dem Löschen keine Fundstelle außerhalb der gelöschten Bereiche. Steht ein -anderer Leser da, bleibt das Feld. - -- [ ] **Step 3: Übersetzen, Suite** - -Run: -```bash -./gradlew clean build --console=plain > /tmp/s5.txt 2>&1; echo "exit=$?" -python3 /tmp/count_tests.py -``` - -Erwartet: `exit=0`, `tests=800 failures=0`. GUI-Abnahme (Marc, gesammelt, **die -wichtigste**): die Compose-Stationsliste füllt sich, Filtern (QRB/QTF/Text) wirkt -weiter, Auswahl/Sortierung/`/cq`-Vorbelegung unverändert. - -- [ ] **Step 4: Abhaken und committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Delete the never-shown member table, its context menu and the filter bar - -The roots of the window that is built every start and shown never. The -Compose station list is fed by composeMemberListener and filtered through -composeMainWindowState.getStationFilter(), both outside the dead block, so -they stay; the JavaFX table, its context menu, the filter listeners and -chatMemberListBinding - whose only reader was the dead table's setItems - -go together. Each live listener was classified before deletion; the finding -records them." -``` - ---- - -## S6: Den Rest von `start()` zerlegen - -Der letzte Schnitt: der tote `timelineView`, die `scn_ChatwindowMainScene` samt Listenern -und `registerThemedScene`/`themedScenes`, der leer gewordene `try/catch`-Block, und die -tote CW-Ausgabe. Die drei lebenden Zeitleisten-Listener bleiben, von ihren -`timelineView`-Berührungen befreit. - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -**Vorgefundene Verzahnung** (vor der Arbeit neu greppen): - -| Stelle | Was | tot/lebend | -|---|---|---| -| `~:6878`–`:6885` | `timelineView = new TimelineView()`, Styling, Einhängen | **tot** | -| `~:6890`, `:6899` | `timelineView` Width/Height-Listener → `updateTimelineVisuals()` | **tot** | -| `~:6927` | `getActiveSkeds().addListener(... updateTimelineVisuals())` | **lebend** | -| `~:6930` | `getScoreService().uiPulse().addListener(... updateTimelineVisuals())` | **lebend** | -| `~:6941`, `:6942` | `timelineView.setCurrentAntennaAzimuth(...)`, `setBeamWidthDeg(...)` | **tot** | -| `~:6945`–`:6948` | `getActualQTF().addListener(...)`: `timelineView.setCurrentAntennaAzimuth` **und** `updateTimelineVisuals()` | **lebend, gemischt** | -| `~:9079` | `Platform.runLater(this::updateTimelineVisuals)` | **lebend** (Erstbefüllung) | -| `~:9849` | `return timelineView;` (Getter) | mit `timelineView` zu prüfen | - -- [ ] **Step 1: Die Verzahnung am echten Code neu erheben** - -Run: -```bash -grep -n "timelineView\|updateTimelineVisuals\|getActiveSkeds().addListener\|uiPulse().addListener\|getActualQTF().addListener\|scn_ChatwindowMainScene\|registerThemedScene\|themedScenes\|playCWLauncher\|javafx.scene.media" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Jede Zeile einem Topf zuordnen — tot (fällt) oder lebend (bleibt, oder der lebende Teil -einer gemischten Zeile bleibt). - -- [ ] **Step 2: Die lebenden Auslöser vom toten Knoten trennen** - -- `getActualQTF().addListener(...)` zu einem reinen `updateTimelineVisuals()`-Auslöser - machen, den `timelineView.setCurrentAntennaAzimuth`-Aufruf entfernen. - `updateTimelineVisuals` liest den Azimut ohnehin frisch aus den Preferences. -- `getActiveSkeds()`- und `uiPulse()`-Listener bleiben unverändert. -- Die Erstbefüllung `Platform.runLater(this::updateTimelineVisuals)` bleibt. - -- [ ] **Step 3: Den toten `timelineView`, die tote Szene und den leeren Block löschen** - -- `timelineView`-Erzeugung, Größen-Listener, Setter, Getter, Feld (`:213`) und den toten - Zweig in `updateTimelineVisuals` entfernen; der Feed-Push bleibt. -- `scn_ChatwindowMainScene` samt Größen-/Key-Listenern, `registerThemedScene` und - `themedScenes` entfernen, soweit der Grep keinen lebenden Nutzer zeigt. -- Den dann leeren/toten `try { … } catch`-Block in `start()` auflösen, ohne die lebenden - Aufrufe danach (`openMonitorWindow`, `openComposeMainWindowIfRequested`, - `openUpdateWindowIfAvailable`, `openSettingsWindow`, Erstbefüllung) zu berühren. -- Die tote CW-Ausgabe (`playCWLauncher`, `musicList`, `mediaPlayer`) und den Import - `javafx.scene.media` nur löschen, wenn der Grep sie als unerreichbar belegt. -- **`TimelineView.java` selbst bleibt** (Etappe 6). Diese Scheibe entfernt nur seine - Nutzung. - -- [ ] **Step 4: Übersetzen, Suite** - -Run: -```bash -./gradlew clean build --console=plain > /tmp/s6.txt 2>&1; echo "exit=$?" -python3 /tmp/count_tests.py -``` - -Erwartet: `exit=0`, `tests=800 failures=0`. GUI-Abnahme (Marc, gesammelt): die -Compose-Zeitleiste über dem Eingabefeld reagiert weiter auf eintreffende Skeds und -Rotordrehung. - -- [ ] **Step 5: Abhaken und committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Separate start(): drop the dead timeline node and scene, keep the triggers - -The dead TimelineView node, the never-attached scn_ChatwindowMainScene with -its size and key listeners, registerThemedScene/themedScenes and the now- -empty try block are gone. The three live timeline triggers stay - the sked -listener, the uiPulse listener and the QTF listener, the last cut down to -the updateTimelineVisuals call it shares with a removed timelineView setter. -The dead CW launcher and the javafx.scene.media import go where the grep -proves nothing live reaches them. TimelineView.java itself stays for stage 6. - -Verified by watching the Compose timeline update on an incoming sked and a -rotor turn - the signal a deleted live trigger would have silenced." -``` - ---- - -## Prüfung - -Das Netz dieser Etappe: - -- **800 Tests, 0 Fehler** als Ausgangsstand, nach jeder Scheibe neu gezählt über die XML - — nicht aus Exit-Code 0 geschlossen. -- Das **Durchlauf-Skript** vor jeder Löschung; nach S2 meldet es `0 of N`. -- Eine **GUI-Abnahme** je Scheibe, von Marc gebündelt durchgeführt (der ausführende Agent - kann die GUI nicht bedienen): Stationsliste, Nachrichtentabelle bei Auswahl, - Verbindungsbadge, Zeitleiste auf Sked/Rotor, Menü-Fensteröffner, Design-/Profilwechsel. -- Die bestehenden Feed-Tests (`TimelineFeedTest`, `SelectedStationMessagesFeedTest`, - `ConnectionStateFeedTest`, `FeedIsTheOnlyWriterTest`, - `FeedLifetimeIsTiedToTheWindowTest`). - -## Was unverändert bleibt - -- Die Fachlogik in `core` und sämtliche Protokollformate. -- Die **Schnittstelle** `UiDispatcher` und ihre Umsetzung `JavaFxUiDispatcher` — der - Tausch ist Etappe 4. -- Die Invarianten aus `AGENTS.md`: `ObservableRoster` ist der Speicher, die Listen sind - Spiegel; kein Worker-Faden fasst UI-Sammlungen an; Zoom, Auswahl, Fokus, Sortierung und - vorbelegter Text ändern sich nicht als Nebeneffekt. -- Die 91 MP3-Dateien und die lebende CW-Ausgabe in `PlayAudioUtils`. -- `TimelineView.java`, `PathProfileChart.java`, `StationMapView.java` und die übrigen - JavaFX-Dateien als Dateien — nur ihre Nutzung in `Kst4ContestApplication` fällt. -- Die vorgefundene System-Doppelzustellung der Nachrichtentabelle — in Task 1 - festgehalten, nicht aufgelöst. - -## Risiken - -**Eine Löschung nimmt der Compose-Oberfläche still einen Anstoß.** Die Fehlerklasse, der -dieser Plan gilt. Jede Scheibe hat deshalb eine GUI-Abnahme, nicht nur einen grünen -Build. S5 (Mitgliedertabelle + Filterleiste) ist die gefährlichste, weil dort lebende und -tote Listener am dichtesten liegen; ihr Step 1 ordnet jeden Listener vor dem Schnitt ein. - -**Die Zeilennummern altern mit jeder Scheibe.** Jede Scheibe erhebt ihre Stellen vor der -Arbeit neu per Grep; keine Löschung stützt sich auf eine notierte Zeile, nur auf den -Namen. - -**Die GUI-Abnahme ist ausgelagert.** Der ausführende Agent kann die Anwendung nicht -bedienen; die Abnahme je Scheibe liegt bei Marc. Bis dahin ist ein grüner Build ein -notwendiger, aber kein hinreichender Nachweis. - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -- [ ] **Step 1: Den Durchlauf laufen lassen** - -Run: `python3 docs/superpowers/notes/javafx-compose-sweep.py` - -Erwartet: unverändert drei `REACHES COMPOSE`. Keine der hier zu löschenden Methoden darf -darunter sein. - -- [ ] **Step 2: Für jede verdächtige `init…`-Methode die Aufrufer am echten Code zählen** - -Run: -```bash -for m in initDXClusterTable initChatToOtherMSGTable initBottomGlobalMessageTabPane \ - initChatGeneralMSGTable initChatprivateMSGTable initChatMemberTable \ - initChatMemberTableContextMenu initSelectedCallSignCompactControlsPane \ - initTopPriorityListPane initSkedWarnIndicatorButton initBandUpgradeIndicatorButton; do - echo "--- $m" - grep -rn "\b$m\b" --include='*.java' --include='*.kt' app-desktop/src core/src -done -grep -cE "getDeclaredMethod|getMethod\(|Class\.forName|FXMLLoader" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Erwartet: Für jede Methode genau die Deklaration und ihre echten Aufrufer; die letzte -Zeile ist `0` (keine reflektive oder FXML-Hintertür). **Diese Scheibe löscht nur -Methoden, deren einziger Aufrufer selbst in derselben Scheibe gelöscht wird oder die gar -keinen Aufrufer haben.** Welche das sind, entscheidet die Ausgabe — nicht der Befund, -dessen Zeilen veraltet sind. Methoden, die noch von lebendem Compose-Code erreicht -werden, bleiben für spätere Scheiben. - -- [ ] **Step 3: Die Blatt-Scheibe löschen** - -Die in Schritt 2 als blattartig bestätigten Methoden vollständig entfernen, je mit -Javadoc-Block. Die Konstruktionskette von `scn_ChatwindowMainScene` baut diese Tabellen -ineinander; eine Methode, die eine noch lebende Methode aufruft, bleibt. Blätter zuerst: -die Tabellen, die niemand mehr baut, bevor der Rahmen fällt, der sie baute. - -- [ ] **Step 4: Verwaiste Felder mitnehmen** - -Run: -```bash -grep -nE "tbl_dxClusterTable|tbl_chatToOtherMSGTable|tbl_chatGeneralMSGTable|tbl_chatprivateMSGTable|tbl_chatMemberTable" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Erwartet: Jedes Feld, dessen einzige Nutzer gelöscht wurden, erscheint nur noch in seiner -Deklaration — dann mit entfernen. Bleibt eine Fundstelle außerhalb der gelöschten -Bereiche, gehört das Feld jemandem und bleibt (Review Focus 2). - -- [ ] **Step 5: Übersetzen, Suite, Anwendungsstart** - -Run: -```bash -python3 docs/superpowers/notes/javafx-compose-sweep.py | tail -3 -./gradlew clean build --console=plain > /tmp/t3-2.txt 2>&1; echo "exit=$?" -python3 - <<'PY' -import glob, xml.etree.ElementTree as ET -t=f=0 -for p in glob.glob("*/build/test-results/test/TEST-*.xml"): - r=ET.parse(p).getroot(); t+=int(r.get("tests")); f+=int(r.get("failures"))+int(r.get("errors")) -print(f"tests={t} failures={f}") -PY -``` - -Erwartet: `exit=0`, `failures=0`, Testzahl **unverändert** (die gelöschten Methoden hatte -kein Test berührt). Dann `./gradlew :app-desktop:run`: Anwendung startet, Stationsliste -füllt sich, keine `NullPointerException`/`NoSuchMethodError` auf der Konsole. Beenden. - -- [ ] **Step 6: Abhaken und committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Delete the never-shown station and message table builders - -The slices from the live-code finding, confirmed against today's tree: -each deleted builder's only reachable caller was itself deleted in the -same slice, and the sweep script reports none of them reaching Compose -state. getDeclaredMethod, getMethod(, Class.forName and FXMLLoader are -still zero in the file, so there is no reflective back door either. - -The test count did not move: nothing ever exercised this construction. -That is the premise the whole removal rests on, re-proven on a tree that -is now several slices smaller than when it was first stated." -``` - ---- - -## Task 3: Die Nachrichten- und Kompaktsteuerungs-Scheibe löschen - -Nach Aufgabe 1 ist `initFurtherInfoAbtCallsignMSGTable` von seiner lebenden Zuführung -getrennt und kann mit seiner Nachbarschaft fallen. - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -- [ ] **Step 1: Durchlauf und Aufrufer prüfen** - -Run: -```bash -python3 docs/superpowers/notes/javafx-compose-sweep.py | grep -E "FurtherInfo|SelectedCallSignCompact" -grep -n "initFurtherInfoAbtCallsignMSGTable\|initSelectedCallSignCompactControlsPane\|tbl_furtherInfoAbtCallsignMSGTable" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Erwartet: `initFurtherInfoAbtCallsignMSGTable` wird vom Durchlauf **nicht mehr** als -`REACHES COMPOSE` gemeldet (Aufgabe 1 hat die Compose-Senke entfernt). Steht es noch dort, -ist Aufgabe 1 unvollständig — anhalten. Die verbliebenen Aufrufer müssen selbst in dieser -oder einer früheren Scheibe gelöscht sein. - -- [ ] **Step 2: Löschen, Felder mitnehmen, Binding-Erzeugung sichern** - -Die beiden Methoden samt Javadoc entfernen. **Kritisch:** `selectedCallSignInfoMessageBinding` -darf durch das Löschen von `initFurtherInfoAbtCallsignMSGTable` **nicht** verschwinden — -seine Erzeugung lebt seit Aufgabe 1 in `rebuildSelectedCallSignInfoMessageBinding()`. -Prüfen, dass dieser Aufruf erhalten bleibt: - -Run: `grep -n "rebuildSelectedCallSignInfoMessageBinding\|selectedCallSignInfoMessageBinding" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -Erwartet: die Bindung wird weiter in `rebuildSelectedCallSignInfoMessageBinding` erzeugt -und vom Feed-Listener benutzt; der `setItems`-Aufruf auf die gelöschte Tabelle ist weg. - -- [ ] **Step 3: Übersetzen, Suite, Anwendungsstart** - -Run: wie Aufgabe 2, Schritt 5. - -Erwartet: `exit=0`, `failures=0`, Testzahl unverändert gegenüber Aufgabe 1 (801). Beim -Anwendungsstart **erneut** eine Station wählen und prüfen, dass die Nachrichtentabelle -sich füllt — das ist die Scheibe, bei der ein Fehler in Aufgabe 1 sichtbar würde. - -- [ ] **Step 4: Abhaken und committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Delete the selected-callsign info table and its compact controls - -Safe only because task 1 moved selectedCallSignInfoMessageBinding into its -own builder first. The Compose 'messages of the selected station' table is -fed by that binding, not by this JavaFX table, so the table goes while the -feed stays. Verified by selecting a station after the deletion and watching -the Compose table fill." -``` - ---- - -## Task 4: Die großen Tabellen-Scheiben löschen (Mitglieder-, Chat-, Kontextmenü) - -Die Wurzeln des nie gezeigten Fensters: `initChatMemberTable` (die größte Methode) und -ihr Kontextmenü, die privaten und allgemeinen Chat-Tabellen. Sie fallen, nachdem die -Blätter weg sind. - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -- [ ] **Step 1: Durchlauf und Aufrufer prüfen** - -Run: -```bash -python3 docs/superpowers/notes/javafx-compose-sweep.py | tail -3 -grep -n "initChatMemberTable\b\|initChatMemberTableContextMenu\|initChatprivateMSGTable\|initChatGeneralMSGTable" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -grep -n "chatMemberListBinding" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Erwartet: keine dieser Methoden erreicht Compose. `chatMemberListBinding` (das Feld, das -der Befund als „mit der toten Tabelle löschbar" ausweist) hat als einzigen Leser den -`setItems`-Aufruf der toten Mitgliedertabelle — der mit ihr fällt. Steht ein anderer -Leser da, bleibt das Feld. - -- [ ] **Step 2: Löschen, in sinnvoller Teilung** - -`initChatMemberTable` ist groß genug, um die Scheibe bei Bedarf in zwei Commits zu teilen -(Kontextmenü zuerst, dann die Tabelle). Jede Teillöschung einzeln bauen. Das Feld -`chatMemberListBinding` und seine Erzeugung mitnehmen, sobald sein letzter Leser weg ist. - -- [ ] **Step 3: Übersetzen, Suite, Anwendungsstart** - -Run: wie Aufgabe 2, Schritt 5. - -Erwartet: `exit=0`, `failures=0`, Testzahl unverändert. Beim Anwendungsstart die -Stationsliste und die Chat-Tabellen im Compose-Fenster prüfen — sie werden von den -Compose-Listenern (`composeMemberListener`, `composeChatListener`) gespeist, nicht von -diesen gelöschten JavaFX-Tabellen; sie müssen sich weiter füllen. - -- [ ] **Step 4: Abhaken und committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Delete the never-shown member and chat tables - -The roots of the window that is built every start and shown never. The -Compose station list and chat panes are fed by composeMemberListener and -composeChatListener, which stay; these JavaFX tables and chatMemberList- -Binding, whose only reader was the dead table's setItems, go together. -Verified by watching the Compose lists fill after the deletion." -``` - ---- - -## Task 5: Die drei Statusknöpfe schneiden und löschen (Scheibe E inbegriffen) - -`initConnectionStateIndicatorButton` ist gemischt: oben der lebende Feed-Push, unten der -JavaFX-Indikator. Der Code markiert die Linie bei `:5736` („part 3 cuts at this line"). -`initSkedWarnIndicatorButton` und `initBandUpgradeIndicatorButton` sind die beiden -Nachbarn. - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -- [ ] **Step 1: Die Schnittlinie und die lebenden Aufrufer lesen** - -Run: -```bash -sed -n '5693,5760p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -grep -n "updateConnectionStateIndicator\|initConnectionStateIndicatorButton\|btnConnectionStateIndicator\|tipConnectionStateIndicator\|menuConnectionStateMacOs\|menuItemConnectionStateDetailMacOs" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Erwartet: `updateConnectionStateIndicator` wird außerhalb des toten Aufbaus gerufen (die -lebenden Aufrufer, die den Feed speisen). Diese Aufrufer **bleiben**; nur der -JavaFX-Rumpf unterhalb der Schnittlinie fällt. Die macOS-Felder `menuConnectionStateMacOs` -/ `menuItemConnectionStateDetailMacOs` werden nur in `initMenuBar` eingehängt und fallen -mit Aufgabe 6. - -- [ ] **Step 2: `updateConnectionStateIndicator` an der Linie trennen** - -Alles unterhalb von „part 3 cuts at this line" (`:5736` ff.: Tooltip, Button-Stil, -macOS-Menütexte) entfernen. Oben bleiben: die Fadenprüfung, die Normalisierung -(`effectiveState`/`stateDetail`), der eine Feed-Push und `logConnectionIndicatorTransition`. -**Die Normalisierung bleibt**, auch wenn sie damit nur noch den Feed-Push und das Log -speist — sie ist der Vertrag, den `ConnectionStateFeedTest` festhält. - -Prüfen, ob die Methode nach dem Schnitt noch `tipConnectionStateIndicator` oder -`btnConnectionStateIndicator` berührt; wenn nicht, können diese Felder mit -`initConnectionStateIndicatorButton` fallen. - -- [ ] **Step 3: Die drei Knopf-Builder löschen** - -`initConnectionStateIndicatorButton`, `initSkedWarnIndicatorButton`, -`initBandUpgradeIndicatorButton` samt Javadoc entfernen, und die nur von ihnen benutzten -Felder (`btnConnectionStateIndicator`, `tipConnectionStateIndicator` und die beiden -Nachbarknöpfe) mitnehmen. Prüfen: - -Run: `grep -n "btnConnectionStateIndicator\|tipConnectionStateIndicator\|btnSkedWarn\|btnBandUpgrade" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -Erwartet: nach dem Löschen keine Fundstelle mehr außerhalb der gelöschten Bereiche. - -- [ ] **Step 4: Übersetzen, Suite, Anwendungsstart** - -Run: wie Aufgabe 2, Schritt 5. - -Erwartet: `exit=0`, `failures=0`, Testzahl unverändert. Beim Anwendungsstart die -Verbindung herstellen/trennen und prüfen, dass die Verbindungsanzeige in der -**Compose**-Statusleiste weiter wechselt — der Feed-Push oberhalb der Linie speist sie, -der gelöschte JavaFX-Indikator war nie sichtbar (Review Focus 1). - -- [ ] **Step 5: Abhaken und committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Cut updateConnectionStateIndicator at its seam and delete the buttons - -The method mixed a live feed push with the JavaFX indicator of the window -that is never shown; the code already marked where part 3 cuts. Above the -line stays - thread check, normalisation, the single feed push, the log. -Below it goes, with initConnectionStateIndicatorButton and its two -neighbours and the fields only they touched. - -The Compose status bar's connection badge is fed from above the line, so -it keeps changing; verified by connecting and disconnecting after the cut. -The macOS menu fields fall with initMenuBar in the next task." -``` - ---- - -## Task 6: `initMenuBar` löschen (Scheibe D ist im Compose-Code bereits erledigt) - -`MainMenuBar.kt` trägt alle Aktionen schon — der Abgleich ist im Befund gemacht und beim -Schreiben dieses Plans am aktuellen `MainMenuBar.kt` erneut bestätigt: jede -aktionstragende JavaFX-Position hat eine Compose-Entsprechung; die drei Lücken sind ein -Trenner, ein in Compose zusammengelegter Doppeleintrag und ein seit Längerem -auskommentierter Eintrag (`openChangelog`, in `MainMenuBar.kt` ausdrücklich „kept so the -bar can say so"). Es ist also nichts zu portieren, nur zu löschen. - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -- [ ] **Step 1: Den Abgleich am echten Code bestätigen** - -Run: -```bash -grep -n "initMenuBar\|installSharedSystemMenuBar\|menubar.getMenus()\|setOnAction" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java | sed -n '1,60p' -grep -nE "Item\(|Menu\(" app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt -``` - -Erwartet: Jede `setOnAction`-tragende JavaFX-Position hat ein `Item(...)` in -`MainMenuBar.kt`. Taucht eine Aktion in JavaFX auf, die in Compose fehlt, ist das ein -Fund — anhalten und ihn in den Befund schreiben, **nicht** löschen. Das tote `Macros`-Menü -(gebaut, nie der Leiste hinzugefügt, Handler auskommentiert) fällt mit, es wird nicht -portiert. - -- [ ] **Step 2: Den Durchlauf prüfen und löschen** - -Run: `python3 docs/superpowers/notes/javafx-compose-sweep.py | grep -i menubar` - -Erwartet: `initMenuBar` ist zu diesem Zeitpunkt die einzige verbliebene `REACHES -COMPOSE`-Meldung — und das zu Recht, weil seine Einträge Compose-Fenster öffnen. Das -Löschen entfernt diese Aufrufe mit; die Compose-Fenster werden weiter über -`ComposeMenuActions` / `MainMenuBar.kt` geöffnet, nicht über diese JavaFX-Leiste. -`initMenuBar`, `installSharedSystemMenuBar` und die macOS-Menüfelder samt dem toten -`Macros`-Menü entfernen. - -- [ ] **Step 3: Verwaiste Felder und Hilfsmethoden mitnehmen** - -Run: `grep -nE "menubar\b|macOsConnectionStateMenuTitle|menuConnectionStateMacOs|menuItemConnectionStateDetailMacOs|fileMenu|optionsMenu|windowMenu|helpMenu" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -Erwartet: Felder und Hilfsmethoden, deren einzige Nutzer in `initMenuBar` standen, -erscheinen nur noch in ihrer Deklaration — mit entfernen. **`macOsConnectionStateMenuTitle` -prüfen:** falls `MainMenuBar.kt` eine eigene Entsprechung hat -(`ConnectionIndicator.macOsMenuTitle`), ist die Java-Fassung verwaist und fällt; falls nicht, bleibt sie, bis Etappe 5 -den macOS-Pfad klärt. Im Zweifel bleibt sie und der Fund kommt in die Commit-Nachricht. - -- [ ] **Step 4: Übersetzen, Suite, Anwendungsstart** - -Run: wie Aufgabe 2, Schritt 5. - -Erwartet: `exit=0`, `failures=0`, Testzahl unverändert. Beim Anwendungsstart jeden -Compose-Fenster-Öffner aus dem Menü prüfen (Einstellungen, Monitor, Karte) — sie laufen -über `MainMenuBar.kt`, nicht über die gelöschte Leiste. - -- [ ] **Step 5: Durchlauf-Erfolg festhalten** - -Run: `python3 docs/superpowers/notes/javafx-compose-sweep.py | tail -3` - -Erwartet: `0 of N init methods reach Compose state.` — zum ersten Mal erreicht **keine** -`init…`-Methode mehr Compose-Zustand. Das ist die messbare Abnahme, dass der durchsetzte -Aufbau getrennt ist. - -- [ ] **Step 6: Abhaken und committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Delete initMenuBar; its port already lives in MainMenuBar.kt - -Nothing to port: MainMenuBar.kt carries every action the JavaFX bar did, -the read-only macOS connection menu, and even the commented-out changelog -item, kept there so the bar can say so. The three gaps are a separator, a -double entry Compose merged, and an action that has been commented out for -some time. - -With this the sweep reports zero of the init methods reaching Compose -state - the first time the interleaved construction is fully separated. -installSharedSystemMenuBar, the macOS menu fields and the dead Macros menu -(built, never added, handlers commented out) go with it." -``` - ---- - -## Task 7: `start()` Zeile für Zeile zerlegen - -Der letzte und heikelste Schnitt dieser Etappe. In `start()` (`:6855` ff.) liegen tote -`timelineView`-Zeilen und **lebende** Auslöser dicht beieinander. `start()` wird nicht am -Stück beschnitten, sondern Zeile für Zeile getrennt (Review Focus 5). - -**Files:** -- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` - -**Die vorgefundene Verzahnung** (Stand 2026-10-02, Zeilen verschieben sich mit den -früheren Scheiben — vor der Arbeit neu greppen): - -| Stelle | Was | tot/lebend | -|---|---|---| -| `:6878`–`:6885` | `timelineView = new TimelineView()`, Styling, Einhängen in `pnl_inputAndSendButtons` | **tot** — der Knoten wird nie gezeigt | -| `:6890`, `:6899` | `timelineView` Width/Height-Listener → `updateTimelineVisuals()` | **tot** — feuern nur, wenn der tote Knoten Größe bekommt | -| `:6927` | `chatcontroller.getActiveSkeds().addListener(... updateTimelineVisuals())` | **lebend** — speist die Compose-Zeitleiste | -| `:6930` | `chatcontroller.getScoreService().uiPulse().addListener(... updateTimelineVisuals())` | **lebend** | -| `:6941`, `:6942` | `timelineView.setCurrentAntennaAzimuth(...)`, `setBeamWidthDeg(...)` | **tot** — füttern nur den toten Knoten | -| `:6945`–`:6948` | `getActualQTF().addListener(...)`: ruft `timelineView.setCurrentAntennaAzimuth` **und** `updateTimelineVisuals()` | **lebend, gemischt** — der `updateTimelineVisuals`-Teil bleibt, der `timelineView`-Teil fällt | -| `:7823` | `timelineView.setOnCandidateClicked(...)` | **tot** | -| `:9069` | `Platform.runLater(this::updateTimelineVisuals)` | **lebend** — die Erstbefüllung | -| `:9849` | `return timelineView;` (Getter) | mit `timelineView` zu prüfen | - -- [ ] **Step 1: Die Verzahnung am echten Code neu erheben** - -Run: -```bash -grep -n "timelineView\|updateTimelineVisuals\|getActiveSkeds().addListener\|uiPulse().addListener\|getActualQTF().addListener" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Erwartet: dieselbe Verzahnung wie in der Tabelle oben, mit aktuellen Zeilen. Jede Zeile -genau einem Topf zuordnen — tot (fällt) oder lebend (bleibt, oder der lebende Teil einer -gemischten Zeile bleibt). - -- [ ] **Step 2: Die lebenden Auslöser vom toten Knoten trennen** - -- `:6945`–`:6948` zu einem reinen `updateTimelineVisuals()`-Auslöser machen, den - `timelineView.setCurrentAntennaAzimuth`-Aufruf entfernen. `updateTimelineVisuals` liest - den Azimut ohnehin frisch aus den Preferences (`:4843`), der tote Knoten brauchte ihn - nicht. -- `:6927` und `:6930` bleiben unverändert — sie rufen schon nur `updateTimelineVisuals()`. -- `:9069` bleibt — die Erstbefüllung. - -- [ ] **Step 3: Den toten `timelineView` und seinen eigenen Zweig löschen** - -- Die Erzeugung (`:6878`–`:6885`), die beiden Größen-Listener, `setCurrentAntennaAzimuth` - /`setBeamWidthDeg`, `setOnCandidateClicked` und den Getter entfernen. -- In `updateTimelineVisuals` den toten Zweig `if (timelineView != null) { timelineView.updateVisuals(...) }` - (`:4819`–`:4821`) entfernen; der Feed-Push darunter bleibt. -- Das Feld `private TimelineView timelineView;` (`:213`) fällt zuletzt. -- **`TimelineView.java` selbst bleibt** — es ist eine eigene Datei und gehört in Etappe 6 - (Restdateien), nicht hierher. Diese Aufgabe entfernt nur seine Nutzung in - `Kst4ContestApplication`. - -- [ ] **Step 4: Die tote CW-Ausgabe und die tote Szene mitnehmen, soweit erreichbar** - -Der Befund nennt für den Rest von `start()` auch die tote CW-Ausgabe (`playCWLauncher`, -`musicList`, `mediaPlayer`, Import `javafx.scene.media`) und die nie gehängte -`scn_ChatwindowMainScene` samt Listenern und `themedScenes`. Am echten Code prüfen, was -davon nach den Scheiben 2–6 noch steht und ohne lebende Nebenwirkung fällt: - -Run: -```bash -grep -n "playCWLauncher\|musicList\|mediaPlayer\|javafx.scene.media\|scn_ChatwindowMainScene\|themedScenes\|registerThemedScene" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Erwartet: `playCWLauncher` ohne Aufrufer (der Befund nennt ihn tot), `registerThemedScene` -/`themedScenes` nach dem Wegfall des Kandidatenfensters (Teil 2) und der toten Hauptszene -ohne lebenden Nutzer. Jede dieser Gruppen nur löschen, wenn der Grep bestätigt, dass kein -lebender Pfad sie erreicht; sonst in den Befund schreiben und stehen lassen. Der Import -`javafx.scene.media` fällt mit der toten CW-Ausgabe. - -- [ ] **Step 5: Übersetzen, Suite, Anwendungsstart — mit Sked-Prüfung** - -Run: wie Aufgabe 2, Schritt 5. - -Erwartet: `exit=0`, `failures=0`, Testzahl unverändert. Beim Anwendungsstart die -**Compose-Zeitleiste** über dem Eingabefeld prüfen: ein eintreffender Sked und eine -Rotordrehung müssen sie weiter aktualisieren (Marker, Antennenrichtung). Bleibt sie leer -oder eingefroren, ist ein lebender Auslöser mitgelöscht worden — die Fehlerklasse, die -dieser Plan verhindern soll (Review Focus 5). - -- [ ] **Step 6: Abhaken und committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Separate start() line by line: drop the dead timeline node, keep its triggers - -In start() the dead TimelineView node and the live triggers that drive the -Compose timeline sat next to each other. The node, its size listeners, its -azimuth setters, its click handler and its getter are gone; the three live -triggers - the sked listener, the uiPulse listener and the QTF listener - -stay, with the QTF one cut down to the updateTimelineVisuals call it shares -with a now-removed timelineView setter. updateTimelineVisuals reads the -azimuth fresh from preferences, so the setter was never load-bearing. - -The dead CW launcher and the never-attached main scene go where the grep -proves no live path reaches them, taking the javafx.scene.media import with -them. TimelineView.java itself stays for stage 6. - -Verified by watching the Compose timeline still update on an incoming sked -and a rotor turn - the exact signal a deleted live trigger would have -silenced with a green build." -``` - ---- - -## Prüfung - -Das Netz dieser Etappe: - -- **800 Tests, 0 Fehler** als Ausgangsstand (Aufgabe 0), nach jeder Scheibe neu gezählt - über die XML — nicht aus Exit-Code 0 geschlossen. -- Das **Durchlauf-Skript** vor jeder Löschung; es muss die zu löschende Methode als - `clean` führen, und nach Aufgabe 6 `0 of N` melden. -- Ein **Anwendungsstart nach jeder Löschscheibe**, weil kein Test beantwortet, ob ein - entfernter Listener gefehlt hat — bei den Zuführungen mit konkreter Bedienhandlung - (Station wählen, Verbindung wechseln, Sked eintreffen lassen). -- Die bestehenden Feed-Tests (`TimelineFeedTest`, `SelectedStationMessagesFeedTest`, - `ConnectionStateFeedTest`, `FeedIsTheOnlyWriterTest`, - `FeedLifetimeIsTiedToTheWindowTest`) und der neue Zähler-Test aus Aufgabe 1. - -## Was unverändert bleibt - -- Die Fachlogik in `core` und sämtliche Protokollformate. -- Die **Schnittstelle** `UiDispatcher` und ihre Umsetzung `JavaFxUiDispatcher` — der - Tausch ist Etappe 4. -- Die Invarianten aus `AGENTS.md`: `ObservableRoster` ist der Speicher, die Listen sind - Spiegel; kein Worker-Faden fasst UI-Sammlungen an; Zoom, Auswahl, Fokus, Sortierung und - vorbelegter Text ändern sich nicht als Nebeneffekt. -- Die 91 MP3-Dateien und die lebende CW-Ausgabe in `PlayAudioUtils`. -- `TimelineView.java`, `PathProfileChart.java`, `StationMapView.java` und die übrigen - JavaFX-Dateien als Dateien — nur ihre Nutzung in `Kst4ContestApplication` fällt, soweit - diese Scheiben sie berühren. -- Die vorgefundene System-Doppelzustellung der Nachrichtentabelle (`refresh()` + - nachgezogener `push`) — sie wird in Aufgabe 1 festgehalten, nicht aufgelöst. - -## Risiken - -**Eine Löschung nimmt der Compose-Oberfläche still einen Anstoß.** Das ist die -Fehlerklasse, der dieser ganze Plan gilt. Jede Scheibe hat deshalb einen Anwendungsstart -als Abnahme, nicht nur einen grünen Build. - -**Die Zeilennummern in diesem Plan altern mit jeder Scheibe.** Jede Aufgabe erhebt ihre -Stellen vor der Arbeit neu per Grep; keine Löschung stützt sich auf eine hier notierte -Zeile, nur auf den Namen. - -**`start()` ist 2.000+ Zeilen durchsetzter Aufbau.** Aufgabe 7 ist zeilenweise und -einzeln zu prüfen; ein am Stück beschnittenes `start()` ist der sichere Weg, einen -lebenden Auslöser zu verlieren. - -## Was dieser Plan nicht tut - -Alles Folgende ist **nicht** Teil 3, sondern ein späterer Plan (Spec-Etappen 4–6), weil -es erst schreibbar ist, wenn `start()` zerlegt und der durchsetzte Aufbau gelöscht ist: - -- **Etappe 4:** `JavaFxUiDispatcher` → eine Umsetzung auf dem AWT-Ereignisfaden. Die - Schnittstelle `UiDispatcher` bleibt, nur ihre Umsetzung wird getauscht. -- **Etappe 5:** der Lebenszyklus (`extends Application`, `launch(args)`, `start(Stage)` → - einfaches `main`), die zehn `Alert`-Dialoge als eigene Compose-Fenster, Fensterrahmen, - Startgröße (`Screen`) und Anwendungssymbol, und der macOS-Systemmenüleisten-Pfad. -- **Etappe 6:** die Restdateien (`StationMapView`, `StationMapBridge`, `MapHtmlResources`, - Leaflet-Ressourcen, `TileProxyServer`, `PathProfileChart`, `TimelineView`, - `MessageTextTableCell`, `TruncatedTextTableCell`, `JavaFxUiDispatcher`), das Verschieben - von `MessageVariableResolverTest` nach `src/test`, das Entfernen der `javafx`-Importe - aus `TableLayoutManager.java` (die Klasse **bleibt**, sie wird von sechs Compose-Dateien - benutzt), und das Befreien des Builds — der `org.openjfx`-Block aus - `app-desktop/build.gradle.kts` und `gradle/libs.versions.toml`. - -Erst wenn `grep -rl "javafx\." core/src/main app-desktop/src/main` nichts mehr liefert und -der `org.openjfx`-Block aus dem Build fällt, ist das Ziel der Spezifikation erreicht — das -ist das Ende von Etappe 6, nicht von Teil 3. diff --git a/docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md b/docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md deleted file mode 100644 index c596631b..00000000 --- a/docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md +++ /dev/null @@ -1,1772 +0,0 @@ -# Umsetzungsplan — JavaFX entfernen, Teil 4: Dispatcher, Dialoge, Lebenszyklus - -> **Für ausführende Agenten:** Dieser Plan setzt die Etappen 4 und 5 der Spezifikation in -> einem Zug um. Schritte sind als Kästchen (`- [ ]`) geführt. Nach jeder Aufgabe ein -> Commit; nach den mit **GUI-ABNAHME** markierten Aufgaben die Anwendung starten und von -> Marc bedienen lassen, bevor es weitergeht. - -**Ziel:** `Kst4ContestApplication` startet ohne `javafx.application.Application`, stellt UI-Arbeit -über den AWT-Ereignisfaden zu und zeigt alle Dialoge als Compose-Fenster. - -**Architektur:** Der `UiDispatcher`-Vertrag bleibt unverändert; nur die Umsetzung wechselt von -`Platform.runLater` auf `EventQueue.invokeLater` — den Faden, auf dem Compose ohnehin komponiert. -Weil `ComposeMenuActions` jede Menüaktion über diesen Dispatcher schickt, müssen die zehn -JavaFX-`Alert`-Aufrufe und der `new Stage()` des Profilwechsels **vor** dem Tausch fallen; sonst -laufen sie auf dem falschen Faden und werfen. Die Dialoge werden modale `ComposeDialog`-Fenster -(ein `JDialog` aus der Compose-AWT-Brücke), weil ein modaler `JDialog` auf dem Ereignisfaden eine -geschachtelte Ereignisschleife startet und damit genau die `showAndWait`-Semantik liefert, die die -Ablaufsteuerung dieser Dialoge braucht. - -**Werkzeuge:** Java 21, Gradle Wrapper, Kotlin 2.2.0, Compose Multiplatform 1.8.2, JUnit 5. - -**Spezifikation:** `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`, Etappen 4 und 5. -**Lebendbefund:** `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`. -**Vorgänger:** `docs/superpowers/plans/2026-10-02-javafx-entfernen-teil3.md`. - -## Projektweite Vorgaben - -- Kommunikation mit Marc auf **Deutsch**; Quelltextkommentare und Javadoc **ausschließlich - Englisch**. Commit-Nachrichten knappes Englisch. -- `./gradlew clean build`, Java 21. Zwei Module: `core` (toolkit-frei) und `app-desktop`. -- **Nicht aus Exit-Code 0 auf grün schließen.** Nach jeder Aufgabe die XML unter - `*/build/test-results/test/TEST-*.xml` auswerten. Grundlinie: **800 Tests, 0 Fehler**. -- **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, kein Versions-Bump. -- Die Schnittstelle `kst4contest.observe.UiDispatcher` bleibt unverändert — nur ihre Umsetzung - wird getauscht. -- `core` bekommt in diesem Plan **keine** Änderung. Kein `java.awt`-Import in `core`. -- Keine neue Produktionsabhängigkeit. `ComposeDialog` liegt in `ui-desktop-1.8.2.jar`, das - bereits über `compose.desktop.currentOs` auf dem Klassenpfad ist. -- **Nicht anfassen:** `TableLayoutManager.java` (wird von sechs Compose-Dateien benutzt, verliert - seine JavaFX-Importe erst in Etappe 6), die 91 MP3-Dateien, `PlayAudioUtils`, die Kartendateien - `StationMapView`/`StationMapBridge`/`MapHtmlResources`, `MessageTextTableCell`, - `TruncatedTextTableCell`, `PathProfileChart`, `TimelineView`, `FxRosterBinding`, der - `org.openjfx`-Block in `app-desktop/build.gradle.kts` und `gradle/libs.versions.toml`. Das ist - alles Etappe 6. - -## Prüfschwerpunkte - -Fünf Eingaben beziehungsweise Zustände, die die Spezifikation voraussetzt, aber nicht als Test -benennt. Jede Zeile bekommt ihren Test in der Aufgabe, der der Code gehört. - -1. **Blockierender Dialog vom Ereignisfaden aus.** Ein modaler Dialog, der vom AWT-Faden geöffnet - wird, muss den Aufrufer anhalten **und** sich trotzdem zeichnen. Täte er das nicht, stünde die - Anwendung beim ersten Profilwechsel. → Aufgabe 1 (Sonde) und Aufgabe 3. -2. **Zustellung vom Fremdfaden, wenn noch kein Fenster offen ist.** `MessageBusManagementThread` - stellt ab der ersten Verbindung zu, und das kann vor dem ersten gezeichneten Fenster passieren. - Der Ereignisfaden muss dann trotzdem anlaufen. → Aufgabe 2. -3. **Eine geworfene Ausnahme in einer UI-Aufgabe.** Reißt sie den Ereignisfaden, steht die ganze - Oberfläche — nicht nur die eine Aktualisierung. → Aufgabe 2. -4. **`mailto:` gegen `http:`.** `getHostServices().showDocument` behandelte beides gleich, - `java.awt.Desktop` nicht: Mail will `Desktop.mail(URI)`, Web will `Desktop.browse(URI)`. Ein - `browse("mailto:…")` öffnet auf Linux nichts. → Aufgabe 5. -5. **Gespeicherte Fenstergröße von einem größeren Bildschirm.** Die Korrektur auf den aktuell - verfügbaren Bereich muss den Wechsel von `javafx.stage.Screen` auf - `GraphicsEnvironment.getMaximumWindowBounds()` überleben, sonst startet die Anwendung größer - als der Schirm. → Aufgabe 6. - ---- - -## Dateiübersicht - -**Neu:** - -| Datei | Verantwortung | -|---|---| -| `app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java` | `UiDispatcher` auf dem AWT-Ereignisfaden | -| `app-desktop/src/test/java/kst4contest/view/AwtUiDispatcherTest.java` | Vertrag des Dispatchers | -| `app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt` | Der Dialogbaustein: Beschreibung, Knöpfe, modales Fenster | -| `app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeAlertTest.kt` | Die toolkit-freien Regeln des Bausteins | -| `app-desktop/src/main/java/kst4contest/view/ExternalDocuments.java` | Ersatz für `HostServices.showDocument` | -| `app-desktop/src/test/java/kst4contest/view/ExternalDocumentsTest.java` | Schema-Entscheidung Mail gegen Web | -| `app-desktop/src/main/java/kst4contest/view/ScreenBounds.java` | Verfügbarer Bildschirmbereich über AWT | -| `app-desktop/src/test/java/kst4contest/view/ScreenBoundsTest.java` | Begrenzung der gespeicherten Größe | - -**Geändert:** `Kst4ContestApplication.java` (Hauptlast), `ApplicationRuntimeLauncher.java`, -`LayoutAutosave.java`, `Main.java`, `GuiUtils.java`, `app-desktop/build.gradle.kts` (nur -`mainClass`, falls nötig). - -**Gelöscht:** `app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java` sowie die in -Aufgabe 11 benannten toten Methoden. - ---- - -## Aufgabe 1: Sonde — modaler `ComposeDialog` vom Ereignisfaden - -**Wegwerfcode.** Das Ergebnis ist eine Antwort, kein Baustein. Die Datei wird am Ende der Aufgabe -wieder gelöscht; nur die Antwort wandert in diesen Plan und in die Übergabe. - -**Die Frage:** Hält ein modaler `androidx.compose.ui.awt.ComposeDialog` den Aufrufer an und -zeichnet sich trotzdem — sowohl wenn er vom AWT-Ereignisfaden geöffnet wird als auch von einem -Fremdfaden? Davon hängt ab, ob die vier blockierenden Dialoge (Aufgabe 3) ihre Ablaufsteuerung -behalten können. - -**Dateien:** -- Anlegen (und am Ende löschen): `app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeDialogProbeTest.kt` - -- [ ] **Schritt 1: Die Sonde schreiben** - -```kotlin -package kst4contest.view.compose - -import androidx.compose.material3.Text -import androidx.compose.ui.awt.ComposeDialog -import org.junit.jupiter.api.Assumptions.assumeFalse -import org.junit.jupiter.api.Test -import java.awt.Dialog -import java.awt.Dimension -import java.awt.EventQueue -import java.awt.GraphicsEnvironment -import java.util.concurrent.atomic.AtomicBoolean -import javax.swing.Timer -import kotlin.test.assertTrue - -/** THROWAWAY probe for the Etappe 4/5 plan. Delete once the question is answered. */ -class ComposeDialogProbeTest { - - private fun probe(label: String): Pair { - val composed = AtomicBoolean(false) - val dialog = ComposeDialog(null, Dialog.ModalityType.APPLICATION_MODAL) - dialog.size = Dimension(300, 150) - dialog.setContent { - composed.set(true) - Text("probe $label") - } - /* Closes the dialog from the event thread, which is the only thread that may. */ - Timer(1500) { dialog.dispose() }.apply { isRepeats = false }.start() - - val startedAt = System.nanoTime() - dialog.isVisible = true - val blockedMs = (System.nanoTime() - startedAt) / 1_000_000 - - return blockedMs to composed.get() - } - - @Test - fun blocksAndRendersFromTheEventThread() { - assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display") - - var result: Pair? = null - EventQueue.invokeAndWait { result = probe("edt") } - - println("[probe] from EDT: blocked=${result!!.first}ms composed=${result!!.second}") - assertTrue(result!!.first >= 1_000, "a modal dialog must block its caller") - assertTrue(result!!.second, "the dialog must have composed while blocking") - } - - @Test - fun blocksAndRendersFromAForeignThread() { - assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display") - - val result = probe("worker") - - println("[probe] from worker: blocked=${result.first}ms composed=${result.second}") - assertTrue(result.first >= 1_000, "a modal dialog must block its caller") - assertTrue(result.second, "the dialog must have composed while blocking") - } -} -``` - -Der Konstruktor wird als `ComposeDialog(owner: Window?, modalityType: Dialog.ModalityType)` -angenommen. Weicht er in 1.8.2 ab, die tatsächliche Signatur nehmen: - -```bash -javap -cp ~/.gradle/caches/modules-2/files-2.1/org.jetbrains.compose.ui/ui-desktop/1.8.2/*/ui-desktop-1.8.2.jar \ - androidx.compose.ui.awt.ComposeDialog | head -30 -``` - -- [ ] **Schritt 2: Sonde laufen lassen** - -```bash -./gradlew :app-desktop:test --tests '*ComposeDialogProbeTest*' --info -``` - -Erwartung: beide Tests grün, in der Ausgabe `blocked=~1500ms composed=true` für beide Fäden. - -- [ ] **Schritt 3: Das Ergebnis festhalten** - -Ergebnis in `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` oben als kurze Notiz -eintragen: ob der modale `ComposeDialog` trägt, mit den gemessenen Zahlen. - -**Scheitert die Sonde** — der Aufruf kehrt sofort zurück, oder `composed` bleibt falsch, oder es -hängt —, ist der Plan ab Aufgabe 3 hinfällig. Dann **anhalten und Marc fragen**, nicht ausweichen. -Der Ausweichweg wäre, die vier blockierenden Dialoge auf Rückrufe umzubauen; das ändert die -Ablaufsteuerung und ist eine eigene Entscheidung, die die Spezifikation ausdrücklich Marc überlässt -(„Die Dialoge sind Ablaufsteuerung, keine Dekoration"). - -- [ ] **Schritt 4: Sonde löschen und committen** - -```bash -rm app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeDialogProbeTest.kt -git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md -git commit -m "Record the modal ComposeDialog probe result for the dispatcher stage" -``` - ---- - -## Aufgabe 2: `AwtUiDispatcher` - -Die neue Umsetzung entsteht, wird aber noch nicht verdrahtet. Keine Verhaltensänderung. - -**Dateien:** -- Anlegen: `app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java` -- Anlegen: `app-desktop/src/test/java/kst4contest/view/AwtUiDispatcherTest.java` - -**Schnittstellen:** -- Nutzt: `kst4contest.observe.UiDispatcher` mit `void runOnUi(Runnable)` und `boolean isUiThread()`. -- Liefert: `public class AwtUiDispatcher implements UiDispatcher` mit öffentlichem - Standardkonstruktor. Aufgabe 9 ersetzt damit `new JavaFxUiDispatcher()`. - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -```java -package kst4contest.view; - -import org.junit.jupiter.api.Test; - -import java.awt.EventQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class AwtUiDispatcherTest { - - private final AwtUiDispatcher dispatcher = new AwtUiDispatcher(); - - @Test - void deliversWorkFromAForeignThreadToTheEventThread() throws Exception { - AtomicBoolean ranOnEventThread = new AtomicBoolean(); - CountDownLatch done = new CountDownLatch(1); - - dispatcher.runOnUi(() -> { - ranOnEventThread.set(EventQueue.isDispatchThread()); - done.countDown(); - }); - - assertTrue(done.await(5, TimeUnit.SECONDS), "the task was never delivered"); - assertTrue(ranOnEventThread.get(), "the task must run on the AWT event thread"); - } - - @Test - void runsInlineWhenAlreadyOnTheEventThread() throws Exception { - AtomicInteger runs = new AtomicInteger(); - - EventQueue.invokeAndWait(() -> { - dispatcher.runOnUi(runs::incrementAndGet); - // Inline means: already done when runOnUi returns, not queued behind us. - assertEquals(1, runs.get(), "a task handed over on the UI thread must run at once"); - }); - - assertEquals(1, runs.get()); - } - - @Test - void reportsTheEventThreadAndOnlyThat() throws Exception { - assertFalse(dispatcher.isUiThread(), "a test thread is not the UI thread"); - - AtomicBoolean onEventThread = new AtomicBoolean(); - EventQueue.invokeAndWait(() -> onEventThread.set(dispatcher.isUiThread())); - - assertTrue(onEventThread.get(), "the AWT event thread must report itself as the UI thread"); - } - - @Test - void aFailingTaskNeitherReachesTheCallerNorKillsTheEventThread() throws Exception { - dispatcher.runOnUi(() -> { throw new IllegalStateException("boom"); }); - - CountDownLatch stillAlive = new CountDownLatch(1); - dispatcher.runOnUi(stillAlive::countDown); - - assertTrue(stillAlive.await(5, TimeUnit.SECONDS), - "the event thread must survive a failing UI task"); - } - - @Test - void aFailingTaskHandedOverOnTheEventThreadDoesNotReachTheCaller() throws Exception { - AtomicBoolean reachedTheLineAfter = new AtomicBoolean(); - - EventQueue.invokeAndWait(() -> { - dispatcher.runOnUi(() -> { throw new IllegalStateException("boom"); }); - reachedTheLineAfter.set(true); - }); - - assertTrue(reachedTheLineAfter.get(), - "an inline failure must not propagate into the caller"); - } -} -``` - -- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** - -```bash -./gradlew :app-desktop:test --tests '*AwtUiDispatcherTest*' -``` - -Erwartung: Übersetzungsfehler, `AwtUiDispatcher` existiert nicht. - -- [ ] **Schritt 3: Die Umsetzung schreiben** - -```java -package kst4contest.view; - -import kst4contest.observe.UiDispatcher; - -import java.awt.EventQueue; - -/** - * The {@link UiDispatcher} backed by the AWT event dispatch thread. - * - *

That is the thread Compose Desktop composes on, so this is where user-interface - * state belongs. The contract is the one {@code JavaFxUiDispatcher} had, down to the - * inline execution: a task handed over from the user-interface thread runs before - * {@link #runOnUi(Runnable)} returns rather than queueing behind work that is already - * pending. Several callers in {@code ChatController} ask {@link #isUiThread()} first and - * rely on exactly that. - */ -public class AwtUiDispatcher implements UiDispatcher { - - @Override - public void runOnUi(Runnable task) { - if (EventQueue.isDispatchThread()) { - runGuarded(task); - } else { - EventQueue.invokeLater(() -> runGuarded(task)); - } - } - - @Override - public boolean isUiThread() { - return EventQueue.isDispatchThread(); - } - - private static void runGuarded(Runnable task) { - try { - task.run(); - } catch (RuntimeException e) { - // Must not tear down the AWT event dispatch thread. - System.err.println("[observe] UI task failed: " + e); - } - } -} -``` - -- [ ] **Schritt 4: Tests laufen lassen** - -```bash -./gradlew :app-desktop:test --tests '*AwtUiDispatcherTest*' -``` - -Erwartung: 5 Tests grün. - -- [ ] **Schritt 5: Ganzen Build prüfen** - -```bash -./gradlew clean build -grep -h " Unit)` - aus `Theme.kt`, `Form.button(text, enabled, onClick)` aus `FormControls.kt`, `Density` aus - `Density.kt`. -- Liefert, von Java aus aufrufbar: - - `ComposeAlert.show(title: String, header: String?, body: String, darkMode: Boolean)` — kehrt - sofort zurück. - - `ComposeAlert.confirm(title: String, header: String?, body: String, confirmText: String, cancelText: String, darkMode: Boolean): Boolean` — - blockiert, liefert `true` für den Bestätigungsknopf. - - `ComposeAlert.acknowledge(title: String, header: String?, body: String, darkMode: Boolean)` — - blockiert bis „OK". - - `ComposeAlert.showWithLink(title: String, header: String?, body: String, linkText: String, linkTarget: String, darkMode: Boolean)` — - kehrt sofort zurück, der Verweis geht über `ExternalDocuments.open` aus Aufgabe 5. Diese - Überladung wird erst in Aufgabe 5 fertiggestellt; hier entsteht sie ohne Verweiszeile und - bekommt sie dort. - -- [ ] **Schritt 1: Den fehlschlagenden Test für die toolkit-freien Regeln schreiben** - -Getestet wird, was ohne Bildschirm entscheidbar ist: die Knopfzeile. Das Zeichnen selbst deckt -die GUI-Abnahme ab — dasselbe Verhältnis wie bei `raiseSteps`/`deiconifiedState` in -`ComposeWindowHost.kt`. - -```kotlin -package kst4contest.view.compose - -import org.junit.jupiter.api.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class ComposeAlertTest { - - @Test - fun anAcknowledgementHasOneButtonAndItConfirms() { - val buttons = alertButtons(confirmText = "OK", cancelText = null) - - assertEquals(listOf(AlertButton("OK", confirming = true)), buttons) - } - - @Test - fun aConfirmationOffersTheConfirmingButtonFirst() { - val buttons = alertButtons(confirmText = "Switch profile", cancelText = "Cancel") - - assertEquals( - listOf( - AlertButton("Switch profile", confirming = true), - AlertButton("Cancel", confirming = false), - ), - buttons, - ) - } - - @Test - fun closingTheWindowCountsAsNotConfirming() { - // The JavaFX Alert returned Optional.empty when dismissed, and every caller - // treated that as "do not proceed". The window close button must do the same. - assertTrue(!dismissalConfirms()) - } -} -``` - -- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** - -```bash -./gradlew :app-desktop:test --tests '*ComposeAlertTest*' -``` - -Erwartung: Übersetzungsfehler, `alertButtons`/`AlertButton`/`dismissalConfirms` existieren nicht. - -- [ ] **Schritt 3: `ComposeAlert.kt` schreiben** - -```kotlin -package kst4contest.view.compose - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import java.awt.Dialog -import java.awt.Dimension -import java.awt.EventQueue -import androidx.compose.ui.awt.ComposeDialog - -/** One button of an alert, and whether pressing it means "go ahead". */ -internal data class AlertButton(val text: String, val confirming: Boolean) - -/** - * The button row of an alert. - * - * The confirming button comes first because that is the order the JavaFX dialogs showed - * and the operator's muscle memory for the profile switch sits on the left-hand button. - */ -internal fun alertButtons(confirmText: String, cancelText: String?): List = - if (cancelText == null) { - listOf(AlertButton(confirmText, confirming = true)) - } else { - listOf(AlertButton(confirmText, confirming = true), AlertButton(cancelText, confirming = false)) - } - -/** - * What closing the window without pressing a button means. - * - * The JavaFX Alert returned an empty Optional and every caller read that as "do not - * proceed"; `closeWindowEvent` would otherwise quit the client when the operator - * dismisses the question. - */ -internal fun dismissalConfirms(): Boolean = false - -/** - * The replacement for the JavaFX {@code Alert}. - * - * A modal {@link ComposeDialog} rather than a Compose `application { }` window on its own - * thread: a modal AWT dialog shown from the event thread runs a nested event pump, so it - * blocks its caller *and* keeps drawing. The thread-plus-latch pattern that - * [OperatorProfilePickerWindow] uses cannot do that — once the dispatcher hands menu - * actions to the event thread, blocking it with a latch would deadlock against the window - * that is trying to draw on the very same thread. - */ -object ComposeAlert { - - /** The font size the JavaFX dialogs had; the settings windows use the same value. */ - private const val DIALOG_FONT_SIZE_SP = 12f - - private val DIALOG_SIZE = Dimension(520, 260) - - /** Shows the alert and returns at once. For notices nothing waits on. */ - @JvmStatic - @JvmOverloads - fun show(title: String, header: String?, body: String, darkMode: Boolean = false) { - EventQueue.invokeLater { - open(title, header, body, confirmText = "OK", cancelText = null, - darkMode = darkMode, modal = false) - } - } - - /** Shows the alert and blocks until the operator acknowledges it. */ - @JvmStatic - @JvmOverloads - fun acknowledge(title: String, header: String?, body: String, darkMode: Boolean = false) { - open(title, header, body, confirmText = "OK", cancelText = null, - darkMode = darkMode, modal = true) - } - - /** - * Asks a yes/no question and blocks until it is answered. - * - * @return true only when the operator pressed the confirming button; dismissing the - * window counts as a no, the way an empty Optional did. - */ - @JvmStatic - @JvmOverloads - fun confirm( - title: String, - header: String?, - body: String, - confirmText: String, - cancelText: String, - darkMode: Boolean = false, - ): Boolean = open(title, header, body, confirmText, cancelText, darkMode, modal = true) - - private fun open( - title: String, - header: String?, - body: String, - confirmText: String, - cancelText: String?, - darkMode: Boolean, - modal: Boolean, - ): Boolean { - var answer = dismissalConfirms() - - val dialog = ComposeDialog( - null, - if (modal) Dialog.ModalityType.APPLICATION_MODAL else Dialog.ModalityType.MODELESS, - ) - dialog.title = title - dialog.size = DIALOG_SIZE - dialog.setLocationRelativeTo(null) - /* - * Above everything else on purpose. These dialogs are asked for from Compose - * windows, which are not their owner; without this the question opens behind the - * window that asked it and the operator sees nothing happen at all. The JavaFX - * profile-switch confirmation needed the same and said so. - */ - dialog.isAlwaysOnTop = true - - dialog.setContent { - Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) { - AlertContent( - header = header, - body = body, - buttons = alertButtons(confirmText, cancelText), - onPressed = { pressed -> - answer = pressed.confirming - dialog.dispose() - }, - ) - } - } - - dialog.isVisible = true - return answer - } -} - -@Composable -private fun AlertContent( - header: String?, - body: String, - buttons: List, - onPressed: (AlertButton) -> Unit, -) { - Surface(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier.padding(15.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (header != null) { - Text(header, style = MaterialTheme.typography.titleMedium) - } - - Column( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - ) { - /* Scrolls rather than clips: onSimpleLogFileCreated shows five paragraphs. */ - Text(body, style = MaterialTheme.typography.bodyMedium) - } - - Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { - buttons.forEach { button -> - Form.button(button.text) { onPressed(button) } - } - } - } - } -} -``` - -Geprüft, bevor der Block geschrieben wurde: `Density.BUTTON_GAP` ist `6.dp`, und `Form.button` hat -die Signatur `fun button(text: String, enabled: Boolean = true, onClick: () -> Unit)` -(`FormControls.kt:367`). Beide so benutzen, keine eigenen Maße einführen. - -- [ ] **Schritt 4: Tests laufen lassen** - -```bash -./gradlew :app-desktop:test --tests '*ComposeAlertTest*' -``` - -Erwartung: 3 Tests grün. - -- [ ] **Schritt 5: Ganzen Build prüfen und committen** - -```bash -./gradlew clean build -git add app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt \ - app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeAlertTest.kt -git commit -m "Add the Compose alert dialog, modal so it keeps showAndWait semantics" -``` - ---- - -## Aufgabe 4: Die vier blockierenden Dialoge umstellen — **GUI-ABNAHME** - -**Dateien:** -- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - — `resolveOperatorProfileIfRequired` (um `:2431`), `showOperatorProfileSwitchDialog` (um `:2479`), - `confirmOperatorProfileSwitch` (um `:2532`), `closeWindowEvent` (um `:3083`) - -Die Zeilennummern sind Stand heute und verschieben sich mit jeder Änderung — nach dem -Methodennamen suchen, nicht nach der Zahl. - -- [ ] **Schritt 1: `resolveOperatorProfileIfRequired` umstellen** - -Ersetzen: - -```java - if (bootstrap.getStartupWarning() != null) { - Alert startupWarning = new Alert(AlertType.WARNING); - startupWarning.setTitle("Operator profile"); - startupWarning.setHeaderText("The requested operator profile was not found."); - startupWarning.setContentText(bootstrap.getStartupWarning()); - startupWarning.showAndWait(); - } -``` - -durch: - -```java - if (bootstrap.getStartupWarning() != null) { - ComposeAlert.acknowledge( - "Operator profile", - "The requested operator profile was not found.", - bootstrap.getStartupWarning()); - } -``` - -- [ ] **Schritt 2: `showOperatorProfileSwitchDialog` umstellen** - -Ersetzen: - -```java - if (selectableProfiles.size() < 2) { - Alert noProfilesYet = new Alert(AlertType.INFORMATION); - noProfilesYet.setTitle("Operator profiles"); - noProfilesYet.setHeaderText("Only one operator profile is configured."); - noProfilesYet.setContentText( - "Additional profiles are created in the settings window on the " - + "\"Profiles\" tab. Each profile keeps its own settings and layout, " - + "and can either share the station worked database or use its own."); - noProfilesYet.showAndWait(); - return; - } -``` - -durch: - -```java - if (selectableProfiles.size() < 2) { - ComposeAlert.acknowledge( - "Operator profiles", - "Only one operator profile is configured.", - "Additional profiles are created in the settings window on the " - + "\"Profiles\" tab. Each profile keeps its own settings and layout, " - + "and can either share the station worked database or use its own.", - chatcontroller.getChatPreferences().isGUI_darkModeActive()); - return; - } -``` - -- [ ] **Schritt 3: `confirmOperatorProfileSwitch` umstellen** - -Die Methode schrumpft auf den Aufruf. Der `alwaysOnTop`-Block entfällt, weil `ComposeAlert` das -selbst macht; der Kommentar, der die Begründung trägt, wandert mit nach `ComposeAlert.kt` — er -steht dort bereits. - -```java - private boolean confirmOperatorProfileSwitch(OperatorProfile targetProfile) { - - return ComposeAlert.confirm( - "Switch operator profile", - "Switch to \"" + targetProfile.getDisplayName() + "\"?", - "The ON4KST connection is closed and all windows are rebuilt with the " - + "settings and layout of the selected profile.\n\n" - + "Unsaved settings of the current profile are lost. Window sizes, " - + "divider and column widths are saved automatically.", - "Switch profile", - "Cancel", - chatcontroller.getChatPreferences().isGUI_darkModeActive()); - } -``` - -- [ ] **Schritt 4: `closeWindowEvent` umstellen** - -Die Methode nimmt heute ein `javafx.stage.WindowEvent` entgegen, das sie nie benutzt — beide -Aufrufer übergeben `null`. Der Parameter fällt mit, sonst bleibt ein JavaFX-Import nur für ihn -stehen. - -```java - /** - * Asks whether the chat connection may be given up, and quits when it may. - * - *

Blocking on purpose: the answer decides whether the application exits. The former - * {@code WindowEvent} parameter is gone with the JavaFX stage; both callers passed null - * and the body never read it.

- */ - private void closeWindowEvent() { - System.out.println("Window close request ..."); - - boolean quit = ComposeAlert.confirm( - "Quit application", - null, - "Do you want to disconnect from the Chat?", - "Yes", - "Cancel", - chatcontroller.getChatPreferences().isGUI_darkModeActive()); - - if (quit) { - System.out.println("closewindowevent: exiting the application"); - - // Routed through the launcher so the runtime that is actually live - // releases its resources. After a profile switch that is no longer the - // instance JavaFX would call stop() on. - ApplicationRuntimeLauncher.exitApplication(); - } - } -``` - -Beide Aufrufstellen mitziehen: `menuActionExit()` ruft `closeWindowEvent()`, und die Stelle um -`:4668` (`uiDispatcher.runOnUi(() -> closeWindowEvent(null))`) wird -`uiDispatcher.runOnUi(this::closeWindowEvent)`. - -- [ ] **Schritt 5: Import ergänzen** - -In `Kst4ContestApplication.java` bei den übrigen Importen: - -```java -import kst4contest.view.compose.ComposeAlert; -``` - -- [ ] **Schritt 6: Bauen und Testzahl prüfen** - -```bash -./gradlew clean build -grep -h "Replaces {@code Application.getHostServices().showDocument(...)}, which went away - * with {@code extends Application}. JavaFX treated web and mail addresses alike; AWT does - * not, so the scheme is decided here: {@code Desktop.mail} for mail, {@code Desktop.browse} - * for everything else. A {@code browse("mailto:...")} silently opens nothing on Linux.

- */ -public final class ExternalDocuments { - - private static final Logger LOGGER = Logger.getLogger(ExternalDocuments.class.getName()); - - private static final String MAIL_SCHEME = "mailto:"; - - private ExternalDocuments() { - // Utility class. - } - - /** Whether the address names a mail recipient rather than a document. */ - public static boolean isMailAddress(final String address) { - - if (address == null) { - return false; - } - - return address.trim().toLowerCase(Locale.ROOT).startsWith(MAIL_SCHEME); - } - - /** - * Opens the address, or logs why it could not be opened. - * - *

A failure here is never worth terminating anything: the operator asked for a - * web page, not for a state change.

- * - * @param address an http/https address or a mailto address - */ - public static void open(final String address) { - - if (address == null || address.isBlank()) { - return; - } - - try { - URI target = URI.create(address.trim()); - - if (!Desktop.isDesktopSupported()) { - LOGGER.log(Level.WARNING, "No desktop integration available for {0}", address); - return; - } - - Desktop desktop = Desktop.getDesktop(); - - if (isMailAddress(address)) { - desktop.mail(target); - } else { - desktop.browse(target); - } - } catch (Exception e) { - LOGGER.log(Level.WARNING, "Could not open " + address, e); - } - } -} -``` - -- [ ] **Schritt 4: Tests laufen lassen** - -```bash -./gradlew :app-desktop:test --tests '*ExternalDocumentsTest*' -``` - -Erwartung: 5 Tests grün. - -- [ ] **Schritt 5: `showWithLink` in `ComposeAlert.kt` ergänzen** - -In `ComposeAlert` hinzufügen: - -```kotlin - /** - * Shows a notice with one clickable line below the text, and returns at once. - * - * Only `onSimpleLogFileCreated` needs this; the JavaFX version put a Hyperlink into - * the dialog pane. Kept as its own entry point rather than a nullable parameter on - * [show], so the ordinary notice stays a three-argument call. - */ - @JvmStatic - @JvmOverloads - fun showWithLink( - title: String, - header: String?, - body: String, - linkText: String, - linkTarget: String, - darkMode: Boolean = false, - ) { - EventQueue.invokeLater { - val dialog = ComposeDialog(null, Dialog.ModalityType.MODELESS) - dialog.title = title - dialog.size = DIALOG_SIZE - dialog.setLocationRelativeTo(null) - dialog.isAlwaysOnTop = true - dialog.setContent { - Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) { - AlertContent( - header = header, - body = body, - buttons = alertButtons("OK", null), - onPressed = { dialog.dispose() }, - link = linkText to linkTarget, - ) - } - } - dialog.isVisible = true - } - } -``` - -und `AlertContent` um den optionalen Verweis erweitern: - -```kotlin -@Composable -private fun AlertContent( - header: String?, - body: String, - buttons: List, - onPressed: (AlertButton) -> Unit, - link: Pair? = null, -) { -``` - -sowie innerhalb des scrollenden `Column`, nach dem `Text(body, ...)`: - -```kotlin - if (link != null) { - Text( - link.first, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(top = 8.dp) - .clickable { ExternalDocuments.open(link.second) }, - ) - } -``` - -Zusätzliche Importe in `ComposeAlert.kt`: `androidx.compose.foundation.clickable` und -`kst4contest.view.ExternalDocuments`. - -- [ ] **Schritt 6: Die fünf nicht-blockierenden `Alert`-Stellen umstellen** - -`alertWindowEvent` (statisch, wird auch von `ApplicationRuntimeLauncher` gerufen — die -Entwurfsfarbe ist dort nicht erreichbar, also bleibt der Standardwert): - -```java - public static void alertWindowEvent(String warning) { - System.out.println("Alert due to ... " + warning); - ComposeAlert.show("WARNING", null, warning); - } -``` - -`onSimpleLogFileCreated` — der Textkörper bleibt Wort für Wort erhalten, der `Hyperlink` wird die -Verweiszeile: - -```java - @Override - public void onSimpleLogFileCreated(Path filePath) { - ComposeAlert.showWithLink( - "Simplelogfile created", - "The selected Simplelogfile did not exist and has been created", - "File: " + filePath + "\n\n" - + "First check whether you need the Simplelogfile integration. If your logging " - + "application provides a supported network interface, use that interface for " - + "band and locator information.\n\n" - + "If you use Simplelogfile, configure your logging application to write its live log to this file. " - + "Then log a test QSO and verify that the callsign is marked as worked " - + "in KST4Contest within one minute.\n\n" - + "Before each contest, verify that the logging application writes the current " - + "contest log to this exact file. KST4Contest does not reset Simplelogfile-derived " - + "Worked marks automatically when a new contest starts.", - "Open the Simplelogfile manual", - SIMPLE_LOG_MANUAL_URL, - chatcontroller.getChatPreferences().isGUI_darkModeActive()); - } -``` - -`menuActionConnect` — beide Zweige: - -```java - void menuActionConnect() { - String call = chatcontroller.getChatPreferences().getStn_loginCallSign(); - String pass = chatcontroller.getChatPreferences().getStn_loginPassword(); - boolean darkMode = chatcontroller.getChatPreferences().isGUI_darkModeActive(); - - if (call == null || call.isBlank() || pass == null || pass.isBlank()) { - ComposeAlert.show( - "Cannot connect", - "Login credentials missing", - "Please configure your callsign and password in Settings first.", - darkMode); - return; - } - - try { - chatcontroller.execute(); - } catch (InterruptedException | IOException e) { - LOGGER.log(java.util.logging.Level.SEVERE, "Exception", e); - ComposeAlert.show( - "Connection failed", - null, - "Could not connect: " + e.getMessage(), - darkMode); - } - } -``` - -`menuActionShowAbout`: - -```java - void menuActionShowAbout() { - ComposeAlert.show( - "About kst4contest", - "kst4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION - + ": ON4KST Chatclient by DO5AMF and DN9APW", - chatcontroller.getChatPreferences().getProgramVersion(), - chatcontroller.getChatPreferences().isGUI_darkModeActive()); - } -``` - -- [ ] **Schritt 7: Die tote `showUserInputErrorWindow` löschen** - -Die statische Methode um `:454` hat keinen Aufrufer mehr (geprüft über das ganze Modul). Sie fällt -samt ihrem `Alert`. - -- [ ] **Schritt 8: Die sieben `getHostServices()`-Stellen umstellen** - -Jeweils `getHostServices().showDocument(X)` → `ExternalDocuments.open(X)`: -`menuActionOpenDonationPage`, `menuActionOpenHomepage`, `menuActionOpenNewsgroup`, -`menuActionOpenOv3tDonationPage`, `menuActionContactAuthor`, die Stelle um `:4000` -(`address -> runOnUi(() -> getHostServices().showDocument(address))` → -`address -> runOnUi(() -> ExternalDocuments.open(address))`) und die um `:4413`. Die Stelle in -`onSimpleLogFileCreated` ist mit Schritt 6 bereits weg. - -- [ ] **Schritt 9: Bauen, Testzahl prüfen, GUI-ABNAHME** - -```bash -./gradlew clean build -grep -h " 0); - assertTrue(ScreenBounds.availableHeight() > 0); - } - - @Test - void headlessFallsBackToASizeTheWindowCanActuallyUse() { - // Packaging and CI run headless. Returning zero would hand Compose a 0x0 window. - assertEquals(ScreenBounds.FALLBACK_WIDTH, ScreenBounds.widthOf(null)); - assertEquals(ScreenBounds.FALLBACK_HEIGHT, ScreenBounds.heightOf(null)); - } - - @Test - void aKnownBoundIsPassedThrough() { - java.awt.Rectangle bounds = new java.awt.Rectangle(0, 0, 2560, 1400); - - assertEquals(2560.0, ScreenBounds.widthOf(bounds)); - assertEquals(1400.0, ScreenBounds.heightOf(bounds)); - } -} -``` - -- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** - -```bash -./gradlew :app-desktop:test --tests '*ScreenBoundsTest*' -``` - -- [ ] **Schritt 3: `ScreenBounds` schreiben** - -```java -package kst4contest.view; - -import java.awt.GraphicsEnvironment; -import java.awt.Rectangle; - -/** - * The screen area a window may actually use. - * - *

Replaces {@code javafx.stage.Screen.getPrimary().getVisualBounds()}. - * {@code GraphicsEnvironment.getMaximumWindowBounds()} is the AWT equivalent: it excludes - * task bars and docks, which is why the JavaFX code used the visual bounds rather than the - * raw bounds.

- */ -public final class ScreenBounds { - - /** Used when there is no display at all, so a window still gets a usable size. */ - public static final double FALLBACK_WIDTH = 1234.0; - - /** Used when there is no display at all, so a window still gets a usable size. */ - public static final double FALLBACK_HEIGHT = 768.0; - - private ScreenBounds() { - // Utility class. - } - - /** The usable width of the primary screen, or {@link #FALLBACK_WIDTH} without one. */ - public static double availableWidth() { - return widthOf(maximumWindowBounds()); - } - - /** The usable height of the primary screen, or {@link #FALLBACK_HEIGHT} without one. */ - public static double availableHeight() { - return heightOf(maximumWindowBounds()); - } - - static double widthOf(final Rectangle bounds) { - return bounds == null || bounds.width <= 0 ? FALLBACK_WIDTH : bounds.width; - } - - static double heightOf(final Rectangle bounds) { - return bounds == null || bounds.height <= 0 ? FALLBACK_HEIGHT : bounds.height; - } - - private static Rectangle maximumWindowBounds() { - - if (GraphicsEnvironment.isHeadless()) { - return null; - } - - return GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds(); - } -} -``` - -- [ ] **Schritt 4: Die Aufrufstelle umstellen** - -Um `:3978`, in der Methode, die das Compose-Hauptfenster öffnet: - -```java - double[] storedSize = chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW(); - MainWindowSize size = MainWindowFrame.INSTANCE.startupSize( - storedSize, ScreenBounds.availableHeight(), ScreenBounds.availableWidth()); -``` - -Die Zeile `javafx.geometry.Rectangle2D screen = Screen.getPrimary().getVisualBounds();` fällt. - -- [ ] **Schritt 5: Die beiden toten Bildschirmhelfer löschen** - -`getScreenAwareMainSceneSizeHW(double[])` und `ensureStageFitsPrimaryScreen(Stage)` haben keinen -Aufrufer mehr. Beide löschen, zusammen mit `GuiUtils.applyApplicationIcon` **nur dann**, wenn nach -Aufgabe 9 kein `primaryStage` mehr existiert — das gehört nach Aufgabe 10, hier nur die beiden -Bildschirmhelfer. - -- [ ] **Schritt 6: Bauen, prüfen, committen** - -```bash -./gradlew clean build -grep -h " { … selectedStationMessagesFeed.push(…) })` | -| die Stelle mit dem Fokus-Kommentar um `:1132` | `Platform.runLater(() -> { txt_chatMessageUserInput.requestFocus(); … })` | -| `updateTimelineVisuals` (Wächter um `:1665`) | `if (!Platform.isFxApplicationThread()) { Platform.runLater(this::updateTimelineVisuals); return; }` | -| `updateConnectionStateIndicator` (Wächter um `:2104`) | dito, mit `updateConnectionStateIndicator(state, detail)` | -| `showBlinkingBandUpgradeIndicator` um `:2195` | `Platform.runLater(() -> showBlinkingBandUpgradeIndicator(…))` | -| der Sked-Erinnerer in `start(…)` um `:2845` | `Platform.runLater(() -> showBlinkingSkedWarnIndicator(text))` | -| das Ende von `start(…)` um `:2898` | `Platform.runLater(this::updateTimelineVisuals)` | -| `onThreadStateChanged`-Umfeld um `:3172`, `:3177`, `:3196` | drei `Platform.runLater(…)` | -| die Stelle um `:3303` | `Platform.runLater(() -> { … forceRedraw() … })` | -| die Stelle um `:4179` | `Platform.runLater(() -> { … selectedStationMessagesFeed.push(…) })` | -| die Stelle um `:4988` mit dem anonymen `Runnable` | `Platform.runLater(new Runnable() { … })` | - -**Nicht** anfassen: `Platform.exit()` um `:2439` und `Platform.setImplicitExit(false)` um `:2786` — -die gehören zum Lebenszyklus und fallen in Aufgabe 10. - -Der Kommentar um `:4982` („Platform.runLater() resets the flag after the current JavaFX event -cycle") beschreibt danach das Falsche. Er wird zu: „Handing the reset to the user-interface thread -lets the current event cycle finish first, so a …". Die Aussage bleibt, der Toolkit-Name -verschwindet. - -Ebenso der Kommentar um `:1128` („on purpose and not uiDispatcher: the dispatcher runs the task -inline when …"). **Diesen zuerst lesen** — er begründet ausdrücklich, warum dort *nicht* der -Dispatcher steht. Trifft die Begründung weiter zu, bleibt die Stelle wie sie ist und bekommt nur -einen Zusatz, dass sie bewusst an `Platform` hängt; dann wandert sie in Aufgabe 10 mit auf -`EventQueue.invokeLater`. Trifft sie nicht mehr zu, wird sie hier mit umgestellt und der Kommentar -gelöscht. **Diese Entscheidung in der Commit-Nachricht festhalten.** - -- [ ] **Schritt 2: `LayoutAutosave` umhängen** - -`LayoutAutosave.requestSave` hat heute `Platform.isFxApplicationThread()`/`Platform.runLater` -fest eingebaut. Die Klasse bekommt den Dispatcher über den Konstruktor, statt das Toolkit zu -kennen: - -```java - private final UiDispatcher uiDispatcher; - - public LayoutAutosave(final ChatPreferences preferences, final UiDispatcher uiDispatcher) { - this.preferences = preferences; - this.uiDispatcher = uiDispatcher; - } -``` - -und in `requestSave`: - -```java - if (!uiDispatcher.isUiThread()) { - uiDispatcher.runOnUi(this::requestSave); - return; - } -``` - -Die einzige Erzeugungsstelle ist `Kst4ContestApplication` um `:2808`: -`layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences(), uiDispatcher);`. -Einen Test für `LayoutAutosave` gibt es nicht (geprüft: `grep -rn "new LayoutAutosave" app-desktop/src` -findet nur diese eine Zeile), also ist kein weiterer Aufrufer anzupassen. - -- [ ] **Schritt 3: Bauen und Testzahl prüfen** - -```bash -./gradlew clean build -grep -h " { - closeRequest.consume(); - ApplicationRuntimeLauncher.exitApplication(); - }); - GuiUtils.applyApplicationIcon(primaryStage); - - startRuntime(); - } - - /** - * Builds the running application: profile, chat controller, listeners and windows. - * - *

Toolkit-free on purpose. A profile switch calls this on a fresh instance, and it - * must not need a JavaFX stage to do so.

- */ - public void startRuntime() throws InterruptedException, IOException, URISyntaxException { - - if (!resolveOperatorProfileIfRequired()) { - return; - } - - ApplicationRuntimeLauncher.setCurrent(this); - - // … the rest of today's start(Stage) body, unchanged, from - // ApplicationFileUtils.copyResourceIfRequired(…) down to the final - // uiDispatcher.runOnUi(this::updateTimelineVisuals); - } -``` - -Der `resolveOperatorProfileIfRequired`-Wächter und `setCurrent(this)` wandern mit nach -`startRuntime()`, weil der Profilwechsel beides braucht. - -- [ ] **Schritt 2: `switchProfile` ohne Bühne** - -In `ApplicationRuntimeLauncher`: - -```java - try { - nextRuntime.startRuntime(); - } catch (Exception e) { -``` - -Der Import `javafx.stage.Stage` fällt damit aus `ApplicationRuntimeLauncher`. - -- [ ] **Schritt 3: Bauen und GUI-ABNAHME** - -```bash -./gradlew clean build && ./gradlew :app-desktop:run -``` - -Zu prüfen: Profilwechsel über Menü **und** über die Einstellungen → Profile. Nach dem Wechsel -müssen Hauptfenster, Einstellungsfenster und Monitorfenster auf dem neuen Profil stehen, und die -Verbindung muss sich neu aufbauen lassen. - -- [ ] **Schritt 4: Committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ - app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java -git commit -m "Split startRuntime out of start(Stage) so a profile switch needs no stage" -``` - ---- - -## Aufgabe 9: Der Tausch — **GUI-ABNAHME** - -Eine Zeile. Hier und nur hier ändert sich, auf welchem Faden die Oberfläche läuft. - -**Dateien:** -- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` (um `:204`) -- Löschen: `app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java` - -- [ ] **Schritt 1: Vor der Löschung das Durchlauf-Skript laufen lassen** - -```bash -python3 docs/superpowers/notes/javafx-compose-sweep.py -``` - -- [ ] **Schritt 2: Den Dispatcher tauschen** - -```java - /** Hands worker-thread results over to the AWT event dispatch thread, where Compose draws. */ - private final UiDispatcher uiDispatcher = new AwtUiDispatcher(); -``` - -Die Javadoc von `runOnUi(Runnable)` direkt darunter spricht noch vom JavaFX-Faden — mit anpassen. - -- [ ] **Schritt 3: `JavaFxUiDispatcher` löschen** - -```bash -git rm app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java -``` - -- [ ] **Schritt 4: Bauen und Testzahl prüfen** - -```bash -./gradlew clean build -grep -h " **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 den mit -> **GUI-ABNAHME** markierten Aufgaben die Anwendung starten und von Marc bedienen lassen. - -**Ziel:** Der Operateur bestimmt sechs Farbrollen pro Entwurf und pro Profil. Die Änderung -wirkt sofort in allen Fenstern, überlebt den Neustart und ist exakt auf die Auslieferung -zurücksetzbar. - -**Architektur:** Drei Schichten mit ausdrücklichem Vorrang — Auslieferung, dann eine CSS-Datei -im Profilverzeichnis, dann die Abweichungen aus den Einstellungen. Die Auflösung ist eine reine -Funktion, die zu jeder Rolle auch ihre Herkunft liefert. Ein `PaletteStore` hält das Ergebnis -als Compose-Zustand, damit `Kst4ContestTheme` es liest statt `remember(darkMode)` — das ist der -einzige Grund, warum eine Änderung alle offenen Fenster erreicht. - -**Werkzeuge:** Java 21, Kotlin 2.2, Compose Multiplatform 1.8.2, JUnit 5, Gradle Wrapper. - -**Spezifikation:** `docs/superpowers/specs/2026-10-04-farben-anpassbar-design.md`. -**Übergeordnet:** `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 8. - -## Projektweite Vorgaben - -- Kommunikation mit Marc auf **Deutsch**; Quelltextkommentare und Javadoc **ausschließlich - Englisch**. 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: **868 Tests, 0 Fehler**. -- **Immer mit Pfadangabe committen** (`git commit -- `). Dieser Arbeitsbaum trägt - vorbestehende gestapelte und ungestapelte Änderungen von vor der Sitzung. -- **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, kein Versions-Bump. **Keine neue Abhängigkeit.** -- `core` bleibt toolkit-frei: **kein** `androidx.compose`-Import in `core/src/main`. Farben - werden dort als Zeichenketten geführt, nicht als `Color`. -- **`JavaFxStylesheet` bleibt ein Leser.** Kein CSS-Schreiber, nirgends. -- **Die Reiterreihenfolge im Einstellungsfenster ändert sich nicht.** Der neue Reiter wird - **angehängt**; `SettingsTabs.kt:200`–`:204` begründet ausdrücklich, warum keine Position - verschoben wird. -- **Nicht anfassen:** die ausgelieferten Vorlagen `KST4ContestDefaultDay.css` und - `…Evening.css`, `derive`s Rechenweg samt `javafx-derive-reference.txt` und - `JavaFxStylesheetTest`, `fontSizePx` und die bestehende Schriftgrößeneinstellung. - Ausdrücklich erlaubt und in Aufgabe 3 verlangt: **ein Satz** in `derive`s Javadoc, der auf - diese Etappe verweist und mit ihr erledigt ist. Der Rechenweg bleibt unberührt. - -## Prüfschwerpunkte - -Acht 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 am Quelltext **gemessen** worden und keine Vermutung. - -1. **Eine Rolle ist ein Selektor und eine Eigenschaft.** Drei der sechs stehen nicht in - `.root` — Text in `.label`, die Trennlinie in `.separator *.line` —, und - `-fx-background-color` allein kommt in der Abendvorlage ein Dutzend Mal vor. Ein Leser, der - nur die Eigenschaft führt, findet die halbe Palette nie und die andere Hälfte am falschen - Ort. → Aufgabe 1 und 3. -2. **Der gewählte Akzent muss der sein, den der Operateur sieht.** `Theme.kt:97` bildet - Materials `primary` auf `textAccent` ab und nicht auf `accent`. Folgt `textAccent` der - Akzentrolle nicht, ist der Regler ein Regler, der nichts tut — durchgeschrieben und nicht - gezeichnet, genau die Fehlerklasse der Zeichenschicht-Tests. → A4. -3. **Der Kontrastwächter darf nicht über den Auslieferungsstand warnen.** Gemessen: der - Tagesakzent liegt bei 2,86, der Abendakzent bei 2,26, Text im Abendfeld bei etwa 2,9 — alle - drei unter dem WCAG-AA-Boden von 4,5. Eine absolute Grenze leuchtet ab dem ersten Start. → A5. -4. **Eine Datei, die nur eine Rolle nennt, darf nur diese Rolle ändern.** `JavaFxStylesheet.read` - füllt fehlende Rollen mit Modena-Rückfallwerten; als Schicht 2 benutzt, würde eine - Datei mit einer einzigen Zeile **alle sechs** Rollen überschreiben. → A3. -5. **Eine kaputte Datei darf den Start nicht verhindern.** Unlesbar, kein CSS, halb - geschrieben — die Rolle fällt auf die Auslieferung zurück und die Anwendung startet. → A3. -6. **Die Rettungsleine darf nicht an der Palette hängen, die sie retten soll.** Der - Auslieferungs-Knopf muss seine Farben aus Schicht 1 nehmen, geprüft mit einer Palette, in - der jede Rolle verändert ist. → A9. -7. **Tag und Abend dürfen sich nicht gegenseitig überschreiben.** Zwei Paletten, zwei - Abweichungsmengen, eine gemeinsame Speicherung — der naheliegende Fehler ist, dass eine - Änderung am Abend den Tag mitzieht. → A2 und A4. -8. **Eine Farbangabe, die der Operateur falsch eintippt**, darf nicht zu einer schwarzen - Oberfläche führen. `#GGG`, `rot`, leer, `#1234567` — unparsbare Eingabe lässt die Rolle, wie - sie war, und sagt es. → A8. - ---- - -## Dateiübersicht - -**Neu in `core`** (toolkit-frei, Farben als Zeichenketten): - -| Datei | Verantwortung | -|---|---| -`core/src/main/java/kst4contest/model/PaletteRole.java` | die sechs Rollen, mit ihrem CSS-Eigenschaftsnamen | -`core/src/main/java/kst4contest/model/PaletteOverrides.java` | Kodierung einer Abweichungsmenge als eine Zeichenkette, parsen und schreiben | -`core/src/test/java/kst4contest/model/PaletteOverridesTest.java` | die Kodierung, einschließlich Müll als Eingabe | - -**Neu in `app-desktop`:** - -| Datei | Verantwortung | -|---|---| -`.../compose/PaletteResolution.kt` | die Dreischicht-Auflösung samt Herkunft je Rolle — reine Funktion | -`.../compose/PaletteStore.kt` | das Ergebnis als Compose-Zustand, die drei Rückwege, und `PaletteStoreFactory` | -`.../compose/ContrastRule.kt` | WCAG-Kontrastverhältnis, relativ zur Auslieferung gemessen | -`.../compose/tabs/ColoursTabState.kt` | typisierte Fassade über Einstellungen und Store | -`.../compose/tabs/ColoursTab.kt` | die Oberfläche, mit der Rettungsleine in Auslieferungsfarben | -dazu vier Testdateien | | - -**Geändert:** `ChatPreferences.java` (zwei Einstellungen), `JavaFxStylesheet.kt` -(`declaredRoles` ergänzen), `Theme.kt` (liest den Store, `contrastingText` wird `internal`), -`MainMenuBar.kt` (offener Menütitel), `OperatorProfilePaths.java` (Pfad der Profilvorlage), -`SettingsTabs.kt` (Reiter angehängt), `Kst4ContestApplication.java` (Store beim Start bilden). - ---- - -## Aufgabe 1: Die sechs Rollen und ihre Deklaration - -**Dateien:** -- Anlegen: `core/src/main/java/kst4contest/model/PaletteRole.java` -- Anlegen: `core/src/test/java/kst4contest/model/PaletteRoleTest.java` - -**Schnittstellen:** -- Liefert: `enum PaletteRole` mit `SURFACE, WINDOW_SURFACE, FIELD_INTERIOR, TEXT, ACCENT, - SEPARATOR`, je mit `selector()`, `cssProperty()` und `key()`. -- Liefert: `PaletteRole.of(String selector, String cssProperty)` → die Rolle oder `null`. - -**Eine Rolle ist ein Selektor und eine Eigenschaft, nicht nur eine Eigenschaft.** Gemessen an -den ausgelieferten Vorlagen: - -| Rolle | Selektor | Eigenschaft | Schlüssel | -|---|---|---|---| -| `SURFACE` | `.root` | `-fx-base` | `surface` | -| `WINDOW_SURFACE` | `.root` | `-fx-background` | `windowSurface` | -| `FIELD_INTERIOR` | `.root` | `-fx-control-inner-background` | `fieldInterior` | -| `TEXT` | `.label` | `-fx-text-fill` | `text` | -| `ACCENT` | `.root` | `-fx-accent` | `accent` | -| `SEPARATOR` | `.separator *.line` | `-fx-background-color` | `separator` | - -Drei der sechs stehen **nicht** in `.root` — `JavaFxStylesheet.read` liest sie aus `.label` -und `.separator *.line` (`JavaFxStylesheet.kt:103` und `:105`). Ein Entwurf, der nur die -Eigenschaft führt, findet Text und Trennlinie nie und findet `-fx-background-color` an einem -Dutzend fremder Stellen. `parseBlocks` schlüsselt auf den wörtlichen Selektor mit normierten -Leerzeichen, deshalb ist `.separator *.line` genau so zu schreiben. - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -```java -package kst4contest.model; - -import org.junit.jupiter.api.Test; - -import java.util.HashSet; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * The six roles the operator may set. - * - *

A role is a selector and a property, not a property alone: the shipped sheets state - * three of the six outside `.root`, and `-fx-background-color` on its own appears a dozen - * times in one sheet. The selector is what tells the separator's line from a scroll bar.

- * - *

All three strings are part of a file format -- two of them the operator's own - * hand-edited stylesheet, the third the preferences XML. Renaming any of them silently - * drops a colour somebody chose, which is why they are pinned here rather than inlined.

- */ -class PaletteRoleTest { - - @Test - void thereAreSixRolesAndEachNamesOneDeclaration() { - assertEquals(6, PaletteRole.values().length); - - Set declarations = new HashSet<>(); - for (PaletteRole role : PaletteRole.values()) { - assertTrue(role.cssProperty().startsWith("-fx-"), role + " has no CSS property"); - assertTrue(role.selector().startsWith("."), role + " has no selector"); - assertTrue( - declarations.add(role.selector() + "|" + role.cssProperty()), - role + " names the same declaration as another role"); - } - } - - @Test - void theThreeRolesOutsideRootKeepTheirOwnSelectors() { - /* - * These three are the reason a role carries a selector at all. The values are the - * ones JavaFxStylesheet.read already uses, so the two must not drift apart. - */ - assertEquals(".label", PaletteRole.TEXT.selector()); - assertEquals("-fx-text-fill", PaletteRole.TEXT.cssProperty()); - - assertEquals(".separator *.line", PaletteRole.SEPARATOR.selector()); - assertEquals("-fx-background-color", PaletteRole.SEPARATOR.cssProperty()); - - assertEquals(".root", PaletteRole.SURFACE.selector()); - } - - @Test - void theStorageKeysAreDistinctAndStable() { - // These strings land in the operator's preferences XML. Renaming one drops a colour. - assertEquals("surface", PaletteRole.SURFACE.key()); - assertEquals("windowSurface", PaletteRole.WINDOW_SURFACE.key()); - assertEquals("fieldInterior", PaletteRole.FIELD_INTERIOR.key()); - assertEquals("text", PaletteRole.TEXT.key()); - assertEquals("accent", PaletteRole.ACCENT.key()); - assertEquals("separator", PaletteRole.SEPARATOR.key()); - } - - @Test - void aDeclarationResolvesBackToItsRole() { - assertEquals(PaletteRole.SURFACE, PaletteRole.of(".root", "-fx-base")); - assertEquals(PaletteRole.TEXT, PaletteRole.of(".label", "-fx-text-fill")); - } - - @Test - void theSamePropertyUnderAnotherSelectorIsNotThatRole() { - /* - * The whole point of carrying the selector. Both sheets set -fx-text-fill on a - * dozen selectors and -fx-background-color on more; only one of each is a role. - */ - assertNull(PaletteRole.of(".button:hover", "-fx-text-fill")); - assertNull(PaletteRole.of(".scroll-bar", "-fx-background-color")); - } - - @Test - void anUnknownDeclarationIsNotARole() { - assertNull(PaletteRole.of(".root", "-fx-font-size")); - assertNull(PaletteRole.of("", "")); - assertNull(PaletteRole.of(null, null)); - } -} -``` - -- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** - -```bash -./gradlew :core:test --tests '*PaletteRoleTest*' -``` - -Erwartung: Übersetzungsfehler, `PaletteRole` existiert nicht. - -- [ ] **Schritt 3: Die Umsetzung schreiben** - -```java -package kst4contest.model; - -/** - * A colour the operator may set, and the stylesheet declaration it corresponds to. - * - *

In core and therefore free of any toolkit: a role is a name and three strings, and the - * colour itself travels as text. Turning text into a drawable colour is the user - * interface's job.

- * - *

A declaration is a selector and a property together. Three of the six roles are stated - * outside `.root` by the shipped sheets, and `-fx-background-color` alone appears a dozen - * times in one of them -- on scroll bars, buttons and toggle buttons. Without the selector, - * half the roles would never be found and the rest would be found in the wrong places.

- * - *

All three strings are part of a file format. The selector and the property are what a - * hand-edited stylesheet writes; {@link #key()} is what the stored overrides are written - * under. Renaming any of them silently drops a colour an operator chose.

- */ -public enum PaletteRole { - - /** The control surface; the window surface is normally a `derive` of this. */ - SURFACE(".root", "-fx-base", "surface"), - - /** The window's own area. Neither shipped sheet states it, so both derive it. */ - WINDOW_SURFACE(".root", "-fx-background", "windowSurface"), - - /** Inside a text field or a list. */ - FIELD_INTERIOR(".root", "-fx-control-inner-background", "fieldInterior"), - - /** Label text. Stated on `.label` and not on `.root`. */ - TEXT(".label", "-fx-text-fill", "text"), - - ACCENT(".root", "-fx-accent", "accent"), - - /** The separator's line, whose selector is what tells it from a scroll bar. */ - SEPARATOR(".separator *.line", "-fx-background-color", "separator"); - - private final String selector; - private final String cssProperty; - private final String key; - - PaletteRole(final String selector, final String cssProperty, final String key) { - this.selector = selector; - this.cssProperty = cssProperty; - this.key = key; - } - - /** The stylesheet selector this role is stated on. */ - public String selector() { - return selector; - } - - /** The stylesheet property this role is read from. */ - public String cssProperty() { - return cssProperty; - } - - /** The name this role is stored under in the preferences. */ - public String key() { - return key; - } - - /** - * The role a stylesheet declaration belongs to. - * - * @param selector a selector from a stylesheet, may be null - * @param cssProperty a property name from a stylesheet, may be null - * @return the role, or null for the hundreds of declarations that are not ours - */ - public static PaletteRole of(final String selector, final String cssProperty) { - - if (selector == null || cssProperty == null) { - return null; - } - - for (PaletteRole role : values()) { - if (role.selector.equals(selector) && role.cssProperty.equals(cssProperty)) { - return role; - } - } - - return null; - } -} -``` - -- [ ] **Schritt 4: Tests laufen lassen** - -```bash -./gradlew :core:test --tests '*PaletteRoleTest*' -``` - -Erwartung: 6 Tests grün. - -- [ ] **Schritt 5: Committen** - -```bash -git add core/src/main/java/kst4contest/model/PaletteRole.java \ - core/src/test/java/kst4contest/model/PaletteRoleTest.java -git commit -m "Name the six palette roles and the two file formats they appear in" -- \ - core/src/main/java/kst4contest/model/PaletteRole.java \ - core/src/test/java/kst4contest/model/PaletteRoleTest.java -``` - ---- - -## Aufgabe 2: Die Abweichungen als eine Zeichenkette - -Zwölf einzelne XML-Elemente (sechs Rollen × zwei Entwürfe) wären zwölf Schreibblöcke, zwölf -Leseblöcke und zwölf Feldpaare in einer Datei, die schon 3.300 Zeilen hat. Stattdessen **eine** -Zeichenkette je Entwurf, mit einer reinen Funktion davor. - -**Dateien:** -- Anlegen: `core/src/main/java/kst4contest/model/PaletteOverrides.java` -- Anlegen: `core/src/test/java/kst4contest/model/PaletteOverridesTest.java` - -**Schnittstellen:** -- Liefert: `PaletteOverrides.parse(String stored)` → `Map` (nur die - wirklich genannten Rollen; nie `null`), `PaletteOverrides.format(Map)` - → `String`, und `PaletteOverrides.isValidColour(String)` → `boolean`. -- Format: `surface=#ECECEC;accent=#0096C9`. Unbekannte Schlüssel und unparsbare Farben werden - beim Lesen übersprungen. - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -```java -package kst4contest.model; - -import org.junit.jupiter.api.Test; - -import java.util.LinkedHashMap; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * How a set of colour overrides survives in the preferences XML. - * - *

One string per design rather than twelve elements. The parsing is where an operator's - * stored colours are either kept or quietly lost, and the input is not always ours: a - * hand-edited XML, a file written by an older version, or a half-finished write.

- */ -class PaletteOverridesTest { - - @Test - void anEmptyStoreMeansNoOverridesRatherThanAFailure() { - assertTrue(PaletteOverrides.parse(null).isEmpty()); - assertTrue(PaletteOverrides.parse("").isEmpty()); - assertTrue(PaletteOverrides.parse(" ").isEmpty()); - } - - @Test - void onlyTheRolesActuallyNamedComeBack() { - Map parsed = PaletteOverrides.parse("surface=#ECECEC"); - - // The whole point of the layering: one named role overrides one role. - assertEquals(1, parsed.size()); - assertEquals("#ECECEC", parsed.get(PaletteRole.SURFACE)); - } - - @Test - void aRoundTripKeepsEveryRole() { - Map original = new LinkedHashMap<>(); - for (PaletteRole role : PaletteRole.values()) { - original.put(role, "#10203" + role.ordinal()); - } - - assertEquals(original, PaletteOverrides.parse(PaletteOverrides.format(original))); - } - - @Test - void anUnknownKeyIsSkippedAndTheRestSurvives() { - // An older or newer version may have written a role this one does not know. - Map parsed = - PaletteOverrides.parse("surface=#ECECEC;chrome=#123456;accent=#0096C9"); - - assertEquals(2, parsed.size()); - assertEquals("#ECECEC", parsed.get(PaletteRole.SURFACE)); - assertEquals("#0096C9", parsed.get(PaletteRole.ACCENT)); - } - - @Test - void anUnparsableColourIsSkippedRatherThanStored() { - /* - * This is the difference between an operator losing one colour and an operator - * losing the readability of the whole client: a bad value must not become black. - */ - Map parsed = - PaletteOverrides.parse("surface=rot;accent=#0096C9;text=#GGGGGG;separator="); - - assertEquals(1, parsed.size()); - assertEquals("#0096C9", parsed.get(PaletteRole.ACCENT)); - } - - @Test - void garbageIsSurvivedWithoutAnException() { - // Half-written files exist. None of these may throw. - for (String garbage : new String[] { - ";;;", "=", "surface", "surface=", "=#ECECEC", "surface==#ECECEC", - "surface=#ECECEC;", "\n\t", "a=b=c", - }) { - PaletteOverrides.parse(garbage); - } - } - - @Test - void whatCountsAsAColour() { - assertTrue(PaletteOverrides.isValidColour("#ECECEC")); - assertTrue(PaletteOverrides.isValidColour("#ececec")); - assertTrue(PaletteOverrides.isValidColour(" #ECECEC ")); - - assertFalse(PaletteOverrides.isValidColour(null)); - assertFalse(PaletteOverrides.isValidColour("")); - assertFalse(PaletteOverrides.isValidColour("ECECEC")); - assertFalse(PaletteOverrides.isValidColour("#ECECE")); - assertFalse(PaletteOverrides.isValidColour("#ECECECE")); - assertFalse(PaletteOverrides.isValidColour("#GGGGGG")); - assertFalse(PaletteOverrides.isValidColour("rot")); - } - - @Test - void formattingAnEmptyMapGivesAnEmptyString() { - assertEquals("", PaletteOverrides.format(new LinkedHashMap<>())); - } -} -``` - -- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** - -```bash -./gradlew :core:test --tests '*PaletteOverridesTest*' -``` - -- [ ] **Schritt 3: Die Umsetzung schreiben** - -```java -package kst4contest.model; - -import java.util.LinkedHashMap; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Pattern; - -/** - * A set of palette overrides, encoded as one string. - * - *

One string per design rather than twelve XML elements: the preferences file is written - * element by element by hand, and twelve more would be twelve write blocks, twelve read - * blocks and twelve fields in a class that is already three thousand lines long.

- * - *

Reading is deliberately forgiving and never throws. The input is not always this - * version's own: a hand-edited XML, a file from an older release, or a write interrupted - * half way. An unknown key or an unparsable colour is skipped and the rest survives -- - * losing one colour is a nuisance, losing the readability of the client is not.

- */ -public final class PaletteOverrides { - - private static final Pattern SIX_DIGIT_HEX = Pattern.compile("^#[0-9a-fA-F]{6}$"); - - private static final String PAIR_SEPARATOR = ";"; - private static final String KEY_VALUE_SEPARATOR = "="; - - private PaletteOverrides() { - // Utility class. - } - - /** - * Reads a stored set of overrides. - * - * @param stored the stored string, may be null or malformed - * @return the roles actually named with a usable colour, never null - */ - public static Map parse(final String stored) { - - Map overrides = new LinkedHashMap<>(); - - if (stored == null || stored.isBlank()) { - return overrides; - } - - for (String pair : stored.split(PAIR_SEPARATOR)) { - - int separator = pair.indexOf(KEY_VALUE_SEPARATOR); - - if (separator <= 0) { - continue; - } - - String key = pair.substring(0, separator).trim(); - String value = pair.substring(separator + 1).trim(); - - if (!isValidColour(value)) { - continue; - } - - for (PaletteRole role : PaletteRole.values()) { - if (role.key().equals(key)) { - overrides.put(role, normalise(value)); - break; - } - } - } - - return overrides; - } - - /** - * Writes a set of overrides for storage. - * - * @param overrides the roles to store; entries with an unusable colour are left out - * @return the string to store, empty when there is nothing to store - */ - public static String format(final Map overrides) { - - if (overrides == null || overrides.isEmpty()) { - return ""; - } - - StringBuilder stored = new StringBuilder(); - - for (PaletteRole role : PaletteRole.values()) { - - String colour = overrides.get(role); - - if (!isValidColour(colour)) { - continue; - } - - if (stored.length() > 0) { - stored.append(PAIR_SEPARATOR); - } - - stored.append(role.key()).append(KEY_VALUE_SEPARATOR).append(normalise(colour)); - } - - return stored.toString(); - } - - /** - * Whether a string is a colour this application can use. - * - *

Six hexadecimal digits with a leading hash, and nothing else. The stylesheets use - * named colours and functions too, but those are the shipped sheets' business; what an - * operator types has one form so that what they typed is what they get.

- * - * @param colour the text to check, may be null - * @return true when it names a colour - */ - public static boolean isValidColour(final String colour) { - return colour != null && SIX_DIGIT_HEX.matcher(colour.trim()).matches(); - } - - private static String normalise(final String colour) { - return colour.trim().toUpperCase(Locale.ROOT); - } -} -``` - -- [ ] **Schritt 4: Tests laufen lassen** - -```bash -./gradlew :core:test --tests '*PaletteOverridesTest*' -``` - -Erwartung: 8 Tests grün. - -- [ ] **Schritt 5: Die zwei Einstellungen in `ChatPreferences`** - -Zwei Felder bei den übrigen GUI-Einstellungen, mit Getter und Setter nach dem Muster der -Nachbarn: - -```java - /** - * The operator's colour overrides for the daylight design, as PaletteOverrides encodes - * them. Per profile, like every other preference: the file this is written to is the - * profile's own. - */ - private String guiOptions_paletteOverridesDay = ""; - - /** The same for the evening design. Two designs, two independent sets. */ - private String guiOptions_paletteOverridesEvening = ""; -``` - -Getter und Setter dazu (`getGuiOptions_paletteOverridesDay`, `setGuiOptions_paletteOverridesDay`, und -dasselbe für `Evening`), ein `null` im Setter wird zu `""`. - -In `writePreferencesToXmlFile()` neben den übrigen `guiOptions`-Elementen zwei Elemente -schreiben, mit `upsertDirectChildText` oder im Stil der Nachbarzeilen: -`guiOptions_paletteOverridesDay` und `guiOptions_paletteOverridesEvening`. - -Im Lesepfad daneben zwei Lesestellen nach dem Muster der Nachbarn, mit `""` als Rückfall, -wenn das Element fehlt — eine Einstellungsdatei aus einer älteren Version hat es nicht. - -**`CONFIG_VERSION` nicht erhöhen**, ohne zu prüfen, was daran hängt: - -```bash -grep -rn "CONFIG_VERSION" core/src/main app-desktop/src/main -``` - -Trägt die Zahl eine Migrationslogik, gehört die Erhöhung dazu; trägt sie nur eine Notiz, nicht. -Die Entscheidung in der Commit-Nachricht festhalten. - -- [ ] **Schritt 6: Den Test für die Rundreise durch die Datei schreiben** - -In `core/src/test/java/kst4contest/model/ChatPreferencesPaletteTest.java`: - -```java -package kst4contest.model; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -/** - * The overrides survive a write and a read, and the two designs stay apart. - * - *

The second part is the one worth pinning: two designs sharing one storage mechanism is - * exactly the shape in which a change to the evening palette quietly drags the daylight one - * with it.

- */ -class ChatPreferencesPaletteTest { - - @Test - void theTwoDesignsAreStoredIndependently() { - ChatPreferences prefs = new ChatPreferences(); - - prefs.setGuiOptions_paletteOverridesDay("surface=#FFFFFF"); - prefs.setGuiOptions_paletteOverridesEvening("surface=#000000"); - - assertEquals("surface=#FFFFFF", prefs.getGuiOptions_paletteOverridesDay()); - assertEquals("surface=#000000", prefs.getGuiOptions_paletteOverridesEvening()); - } - - @Test - void anUnsetOverrideReadsAsEmptyAndNotAsNull() { - // A preferences file from an older release has no such element at all. - ChatPreferences prefs = new ChatPreferences(); - - assertEquals("", prefs.getGuiOptions_paletteOverridesDay()); - assertEquals("", prefs.getGuiOptions_paletteOverridesEvening()); - } - - @Test - void aNullSetterValueBecomesEmpty() { - ChatPreferences prefs = new ChatPreferences(); - - prefs.setGuiOptions_paletteOverridesDay(null); - - assertEquals("", prefs.getGuiOptions_paletteOverridesDay()); - } -} -``` - -Eine Rundreise über die echte Datei schreibt in das Benutzerprofil und gehört deshalb -**nicht** hierher — `ChatPreferencesStationMapVisibilityTest` tut das bereits und ist das -Muster, falls Marc es ausdrücklich will. - -- [ ] **Schritt 7: Bauen, Testzahl prüfen, committen** - -```bash -./gradlew clean build -grep -h "` — nur die - Rollen, die `source` an ihrem eigenen Selektor tatsächlich nennt. -- Liefert: `JavaFxStylesheet.declaredRolesOfFile(file: java.io.File): Map` — - dasselbe für eine Datei, leer bei fehlender oder unlesbarer Datei, wirft nie. -- `read(resourcePath)` bleibt **unverändert** in Signatur und Verhalten: Schicht 1 braucht eine - vollständige Palette. -- Dazu nötig: `parseBlocks` und `colorOf` sind heute `private`. Sie bleiben privat — - `declaredRoles` steht in derselben Datei und benutzt sie von innen. - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -```kotlin -package kst4contest.view.compose - -import kst4contest.model.PaletteRole -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import java.io.File - -/** - * Which roles a stylesheet actually names. - * - * Not the same question as `read`, and conflating the two is the trap of this stage: `read` - * fills a missing role from the Modena defaults, so a file saying only `-fx-base` would - * silently override all six. A layer needs presence, not a complete palette. - * - * How far apart the two questions are is visible in the shipped sheets themselves -- the - * daylight one states no colour at all, and `read` still returns a full palette for it. - */ -class StylesheetDeclaredRolesTest { - - private fun shipped(name: String): String = - JavaFxStylesheet::class.java.getResource(name)!!.readText() - - @Test - fun aSheetNamingOneRoleDeclaresOnlyThatRole() { - val declared = JavaFxStylesheet.declaredRoles(".root { -fx-base: #ECECEC; }") - - assertEquals(setOf(PaletteRole.SURFACE), declared.keys) - } - - @Test - fun aRoleStatedOutsideRootIsFoundAtItsOwnSelector() { - /* - * Three of the six live outside .root. A reader that only looked there would never - * find the text colour or the separator line -- which is half the palette. - */ - val declared = JavaFxStylesheet.declaredRoles( - """ - .label { -fx-text-fill: #112233; } - .separator *.line { -fx-background-color: #445566; } - """.trimIndent() - ) - - assertEquals(setOf(PaletteRole.TEXT, PaletteRole.SEPARATOR), declared.keys) - } - - @Test - fun theSamePropertyUnderAnotherSelectorIsNotARole() { - // -fx-background-color appears a dozen times in the evening sheet. One is the role. - val declared = JavaFxStylesheet.declaredRoles( - ".scroll-bar { -fx-background-color: #445566; } .button:hover { -fx-text-fill: red; }" - ) - - assertTrue(declared.isEmpty(), "found roles where there are none: ${declared.keys}") - } - - @Test - fun aSheetNamingNothingDeclaresNothing() { - assertTrue(JavaFxStylesheet.declaredRoles("").isEmpty()) - assertTrue(JavaFxStylesheet.declaredRoles("/* just a comment */").isEmpty()) - assertTrue(JavaFxStylesheet.declaredRoles(".button { -fx-padding: 2px; }").isEmpty()) - } - - @Test - fun theShippedDaylightSheetStatesNoColourAtAll() { - /* - * Measured, not assumed: its .root carries only -fx-font-size, and every colour in - * the daylight design comes from JavaFX's Modena defaults by way of `read`. If this - * ever starts declaring a role, the daylight palette has gained a second source and - * the resolver's layer 1 needs looking at. - */ - val declared = JavaFxStylesheet.declaredRoles(shipped("/KST4ContestDefaultDay.css")) - - assertTrue(declared.isEmpty(), "the daylight sheet now declares ${declared.keys}") - } - - @Test - fun theShippedEveningSheetStatesFiveOfTheSixRoles() { - /* - * It states the surface, the field interior, the accent, the label text and the - * separator line, and derives the window surface rather than stating it. This pins - * the selector-and-property mapping against a real file: get a selector wrong and - * this count drops without any other test noticing. - */ - val declared = JavaFxStylesheet.declaredRoles(shipped("/KST4ContestDefaultEvening.css")) - - assertEquals( - setOf( - PaletteRole.SURFACE, - PaletteRole.FIELD_INTERIOR, - PaletteRole.ACCENT, - PaletteRole.TEXT, - PaletteRole.SEPARATOR, - ), - declared.keys, - ) - } - - @Test - fun aMissingFileDeclaresNothingRatherThanThrowing() { - val absent = File("/nonexistent/kst4contest/does-not-exist.css") - - assertTrue(JavaFxStylesheet.declaredRolesOfFile(absent).isEmpty()) - } - - @Test - fun anUnreadableOrNonsenseFileDeclaresNothingRatherThanThrowing() { - /* - * A client that will not start because of a broken colour file is worse than one in - * the wrong colours. Half-written files and files that are not CSS at all both exist, - * and this one is hand-edited by the operator. - */ - val nonsense = File.createTempFile("kst4contest-palette", ".css") - try { - nonsense.writeText("this is not css { -fx-base") - assertTrue(JavaFxStylesheet.declaredRolesOfFile(nonsense).isEmpty()) - } finally { - nonsense.delete() - } - - val directory = File.createTempFile("kst4contest-palette-dir", "").let { - it.delete() - it.mkdirs() - it - } - try { - assertTrue(JavaFxStylesheet.declaredRolesOfFile(directory).isEmpty()) - } finally { - directory.delete() - } - } -} -``` - -- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** - -```bash -./gradlew :app-desktop:test --tests '*StylesheetDeclaredRolesTest*' -``` - -Erwartung: Übersetzungsfehler, `declaredRoles` existiert nicht. - -- [ ] **Schritt 3: Die beiden Funktionen ergänzen** - -In `JavaFxStylesheet`, hinter `read`, unter Wiederverwendung der vorhandenen privaten -`parseBlocks` und `colorOf`: - -```kotlin - /** - * The roles a stylesheet source actually names. - * - * Deliberately not [read]: that one fills a missing role from the Modena defaults, which - * is right for the shipped sheets and wrong for a layer meant to override one role at a - * time. A sheet saying only `-fx-base` must declare exactly one role. - * - * Each role is looked up at its own selector, because three of the six are stated - * outside `.root` and one of those properties -- `-fx-background-color` -- appears a - * dozen times elsewhere in the evening sheet. `.root` stays the scope in which a - * `-fx-` reference or a `derive(...)` is resolved, exactly as [read] resolves them. - */ - fun declaredRoles(source: String): Map { - val blocks = parseBlocks(source) - val root = blocks[".root"].orEmpty() - - return PaletteRole.values().mapNotNull { role -> - val stated = blocks[role.selector()]?.get(role.cssProperty()) - ?: return@mapNotNull null - val colour = colorOf(stated, root) ?: return@mapNotNull null - role to colour - }.toMap() - } - - /** - * The roles a stylesheet file names, or none when it cannot be read. - * - * Never throws. A client that will not start because of a broken colour file is worse - * than a client in the wrong colours, and this file is one the operator edited by hand. - */ - fun declaredRolesOfFile(file: java.io.File): Map = - runCatching { - if (!file.isFile) emptyMap() else declaredRoles(file.readText()) - }.getOrDefault(emptyMap()) -``` - -Import ergänzen: `kst4contest.model.PaletteRole`. - -`colorOf` liefert bei einem Wert, den es nicht versteht — einem Farbverlauf etwa —, `null`; -die Rolle gilt dann als nicht genannt und fällt auf die darunterliegende Schicht zurück. Das -ist gewollt und wird vom Test `aSheetNamingNothingDeclaresNothing` mitabgedeckt. - -- [ ] **Schritt 4: Die `derive`-Genauigkeit bewerten und die Entscheidung festhalten** - -`JavaFxStylesheet.derive`s Javadoc nennt diese Etappe ausdrücklich: die Nachbildung weicht bei -**gesättigten** Farben um bis zu drei Stufen je Kanal ab, und das war nur deshalb unerheblich, -weil jedes `derive(...)` in beiden ausgelieferten Vorlagen von `-fx-base` ausgeht — einem Grau. - -Eine handgeschriebene Datei kann `-fx-base` auf ein gesättigtes Rot setzen. Dann geht -`-fx-control-inner-background: derive(-fx-base, 35%)` durch die ungenaue Stelle. - -Die Festlegung, und sie gehört als Kommentar an `declaredRoles`: - -> Eine Abweichung von drei Stufen je Kanal ist in einer Farbe, die der Operateur selbst -> gewählt hat, nicht wahrnehmbar und nicht wichtig. Der Pin bleibt, wie er ist; `derive` -> wird für diese Etappe **nicht** angefasst. Was der Reiter setzt, geht ohnehin nicht durch -> `derive`: die Auflösung nimmt fertige Farben. - -Dazu den veralteten Satz im `derive`-Javadoc berichtigen — er sagt, Etappe 8 müsse das -„revisit", und das ist mit dieser Entscheidung erledigt. Stehen lassen würde einen späteren -Leser auf eine Aufgabe schicken, die es nicht mehr gibt. - -- [ ] **Schritt 5: Tests laufen lassen, bauen, committen** - -```bash -./gradlew :app-desktop:test --tests '*StylesheetDeclaredRolesTest*' -./gradlew clean build -git add app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt \ - app-desktop/src/test/kotlin/kst4contest/view/compose/StylesheetDeclaredRolesTest.kt -git commit -m "Read which roles a stylesheet names, not a whole palette with defaults" -- \ - app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt \ - app-desktop/src/test/kotlin/kst4contest/view/compose/StylesheetDeclaredRolesTest.kt -``` - ---- - -## Aufgabe 4: Die Dreischicht-Auflösung - -Das Herz der Etappe, und reine Funktion. - -**Dateien:** -- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteResolution.kt` -- Anlegen: `app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteResolutionTest.kt` - -**Schnittstellen:** -- Liefert: `enum class PaletteSource { SHIPPED, FILE, CHANGED }` -- Liefert: `data class ResolvedRole(val colour: Color, val source: PaletteSource)` -- Liefert: `data class ResolvedPalette(val roles: Map, val palette: JavaFxPalette)` -- Liefert: - ```kotlin - fun resolvePalette( - shipped: JavaFxPalette, - fromFile: Map, - changed: Map, - ): ResolvedPalette - ``` -- `roles` enthält **immer alle sechs** Rollen; `palette` ist `shipped` mit den aufgelösten - Rollen eingesetzt und den übrigen Feldern von `shipped` (`buttonHoverGradient`, - `buttonPressedBorder`, `fontSizePx`). - -**`textAccent` muss der Akzentrolle folgen, sonst tut der Akzentregler so gut wie nichts.** -`Theme.kt:97` bildet Materials `primary` auf `textAccent` ab und **nicht** auf `accent`, mit -einer ausdrücklichen Begründung: `-fx-accent` färbt nur JavaFX' Auswahlbalken, während das -Grün aus `.text-field .text` das ist, was der Operateur als Akzent sieht. Setzt er also -`-fx-accent` und `textAccent` bleibt beim Grün der Vorlage, ändert sich fast nichts Sichtbares -— genau der Fehlschlag, den Etappe 3b viermal hatte. - -Also: ist `ACCENT` überschrieben, nimmt `textAccent` dieselbe Farbe. Ist es nicht -überschrieben, bleibt `textAccent` der Mittelwert, den die Vorlage ergibt. - -**Folge, die mitgemacht werden muss:** `Kst4ContestMenuRow` zeichnet den offenen Menütitel als -`palette.textAccent` auf `palette.accent` (`MainMenuBar.kt:165` und `:169`). Fallen die beiden -zusammen, ist der Titel unsichtbar. Der offene Titel nimmt deshalb eine Farbe, die zum Akzent -kontrastiert — `Theme.kt`s vorhandenes privates `contrastingText` wird dafür `internal`. - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -```kotlin -package kst4contest.view.compose - -import androidx.compose.ui.graphics.Color -import kst4contest.model.PaletteRole -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -/** - * The three layers and which one wins, role by role. - * - * The provenance is not decoration. Two sources that can decide the same colour is a place - * where nobody can later say why a colour is what it is, and saying so per role is the one - * thing that makes the arrangement defensible -- so it is pinned as hard as the colours. - */ -class PaletteResolutionTest { - - private val shipped = JavaFxStylesheet.read("/KST4ContestDefaultDay.css") - - private val red = Color(0xFFFF0000) - private val green = Color(0xFF00FF00) - - @Test - fun withNothingSetEveryRoleComesFromTheShippedSheet() { - val resolved = resolvePalette(shipped, emptyMap(), emptyMap()) - - assertEquals(PaletteRole.values().toSet(), resolved.roles.keys) - resolved.roles.forEach { (role, it) -> - assertEquals(PaletteSource.SHIPPED, it.source, "$role") - } - assertEquals(shipped.base, resolved.palette.base) - assertEquals(shipped.accent, resolved.palette.accent) - } - - @Test - fun aFileOverridesTheShippedSheetForTheRolesItNames() { - val resolved = resolvePalette(shipped, mapOf(PaletteRole.SURFACE to red), emptyMap()) - - assertEquals(red, resolved.roles[PaletteRole.SURFACE]!!.colour) - assertEquals(PaletteSource.FILE, resolved.roles[PaletteRole.SURFACE]!!.source) - - // And only those roles. - assertEquals(PaletteSource.SHIPPED, resolved.roles[PaletteRole.ACCENT]!!.source) - assertEquals(shipped.accent, resolved.roles[PaletteRole.ACCENT]!!.colour) - } - - @Test - fun aChangeOverridesBothTheFileAndTheShippedSheet() { - val resolved = resolvePalette( - shipped, - mapOf(PaletteRole.SURFACE to red), - mapOf(PaletteRole.SURFACE to green), - ) - - assertEquals(green, resolved.roles[PaletteRole.SURFACE]!!.colour) - assertEquals(PaletteSource.CHANGED, resolved.roles[PaletteRole.SURFACE]!!.source) - } - - @Test - fun theResolvedRolesReachThePaletteTheWindowsDraw() { - val resolved = resolvePalette( - shipped, - emptyMap(), - mapOf( - PaletteRole.SURFACE to red, - PaletteRole.WINDOW_SURFACE to green, - PaletteRole.FIELD_INTERIOR to red, - PaletteRole.TEXT to green, - PaletteRole.ACCENT to red, - PaletteRole.SEPARATOR to green, - ), - ) - - assertEquals(red, resolved.palette.base) - assertEquals(green, resolved.palette.windowBackground) - assertEquals(red, resolved.palette.controlInnerBackground) - assertEquals(green, resolved.palette.labelTextFill) - assertEquals(red, resolved.palette.accent) - assertEquals(green, resolved.palette.separatorLine) - } - - @Test - fun theDerivedFieldsStayWithTheShippedSheet() { - // The hover gradient, the pressed border and the font size are not roles the operator - // sets here; they follow the shipped sheet rather than becoming further decisions. - val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.SURFACE to red)) - - assertEquals(shipped.buttonHoverGradient, resolved.palette.buttonHoverGradient) - assertEquals(shipped.buttonPressedBorder, resolved.palette.buttonPressedBorder) - assertEquals(shipped.fontSizePx, resolved.palette.fontSizePx) - } - - @Test - fun aChosenAccentIsAlsoTheAccentTheOperatorActuallySees() { - /* - * Theme.kt maps Material's primary onto textAccent and not onto accent, because - * -fx-accent only paints JavaFX's selection bar while the green of .text-field .text - * is what reads as the accent. Leave textAccent on the sheet's green and setting the - * accent changes almost nothing visible -- a setting written through and not drawn, - * which is precisely the fault class the drawing-layer tests exist for. - */ - val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.ACCENT to red)) - - assertEquals(red, resolved.palette.accent) - assertEquals(red, resolved.palette.textAccent) - } - - @Test - fun anUntouchedAccentLeavesTheSheetsOwnTextAccentAlone() { - // Without an override the sheet's gradient midpoint stands; it is not the accent. - val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.SURFACE to red)) - - assertEquals(shipped.textAccent, resolved.palette.textAccent) - } - - @Test - fun theWindowSurfaceIsNotDraggedAlongByTheSurface() { - /* - * The reason these are two roles rather than one: the sheets state the window - * surface separately, and an operator who sets only the control surface must not - * have the window surface changed under them. - */ - val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.SURFACE to red)) - - assertEquals(red, resolved.palette.base) - assertEquals(shipped.windowBackground, resolved.palette.windowBackground) - } -} -``` - -- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** - -```bash -./gradlew :app-desktop:test --tests '*PaletteResolutionTest*' -``` - -- [ ] **Schritt 3: Die Umsetzung schreiben** - -```kotlin -package kst4contest.view.compose - -import androidx.compose.ui.graphics.Color -import kst4contest.model.PaletteRole - -/** Which of the three layers a colour in force came from. */ -enum class PaletteSource { SHIPPED, FILE, CHANGED } - -/** One role's colour, and where it came from. */ -data class ResolvedRole(val colour: Color, val source: PaletteSource) - -/** - * The palette in force, and the provenance of every role in it. - * - * @param roles always all six, so the settings tab can show a source for each - * @param palette what the windows draw - */ -data class ResolvedPalette( - val roles: Map, - val palette: JavaFxPalette, -) - -/** - * Resolves the three layers, role by role: the later layer wins. - * - * The shipped sheet is the floor and always complete. A file and a change each carry only - * the roles they name — which is why [JavaFxStylesheet.declaredRoles] exists rather than - * `read`: a file saying only `-fx-base` must override exactly one role. - * - * Fields that are not roles — the hover gradient, the pressed border, the font size — stay - * with the shipped sheet. They are derived values rather than decisions an operator wants to - * make one by one. - * - * The exception is `textAccent`, which follows the accent role: Material's primary is mapped - * onto it rather than onto `accent`, because `-fx-accent` paints only JavaFX's selection bar - * while the green of `.text-field .text` is what an operator reads as the accent. Left on - * the sheet's own value, a chosen accent would change almost nothing visible. - */ -fun resolvePalette( - shipped: JavaFxPalette, - fromFile: Map, - changed: Map, -): ResolvedPalette { - - val shippedColour = mapOf( - PaletteRole.SURFACE to shipped.base, - PaletteRole.WINDOW_SURFACE to shipped.windowBackground, - PaletteRole.FIELD_INTERIOR to shipped.controlInnerBackground, - PaletteRole.TEXT to shipped.labelTextFill, - PaletteRole.ACCENT to shipped.accent, - PaletteRole.SEPARATOR to shipped.separatorLine, - ) - - val roles = PaletteRole.values().associateWith { role -> - when { - changed.containsKey(role) -> ResolvedRole(changed.getValue(role), PaletteSource.CHANGED) - fromFile.containsKey(role) -> ResolvedRole(fromFile.getValue(role), PaletteSource.FILE) - else -> ResolvedRole(shippedColour.getValue(role), PaletteSource.SHIPPED) - } - } - - fun colourOf(role: PaletteRole) = roles.getValue(role).colour - - val accentIsChosen = roles.getValue(PaletteRole.ACCENT).source != PaletteSource.SHIPPED - - return ResolvedPalette( - roles = roles, - palette = shipped.copy( - base = colourOf(PaletteRole.SURFACE), - windowBackground = colourOf(PaletteRole.WINDOW_SURFACE), - controlInnerBackground = colourOf(PaletteRole.FIELD_INTERIOR), - labelTextFill = colourOf(PaletteRole.TEXT), - accent = colourOf(PaletteRole.ACCENT), - textAccent = if (accentIsChosen) colourOf(PaletteRole.ACCENT) else shipped.textAccent, - separatorLine = colourOf(PaletteRole.SEPARATOR), - ), - ) -} -``` - -- [ ] **Schritt 3b: Den offenen Menütitel lesbar halten** - -In `Theme.kt` wird `contrastingText` von `private` auf `internal` gesetzt, mit einem Satz -dazu, warum es die Datei verlässt. In `MainMenuBar.kt:165` nimmt der offene Titel dann: - -```kotlin - color = if (open) contrastingText(palette.accent) else palette.labelTextFill, -``` - -Vorher stand dort `palette.textAccent` — eine Farbe, die jetzt dieselbe sein kann wie der -Hintergrund dahinter. `contrastingText` wählt Schwarz oder Weiß nach derselben -Helligkeitsformel, die JavaFX benutzt, und ist damit von Bauart aus lesbar; geprüft wird es in -der GUI-Abnahme von Aufgabe 9, Punkt 1. - -- [ ] **Schritt 4: Tests laufen lassen, bauen, committen** - -```bash -./gradlew :app-desktop:test --tests '*PaletteResolutionTest*' -./gradlew clean build -git commit -m "Resolve the three palette layers role by role, with their provenance" -- \ - app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteResolution.kt \ - app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt \ - app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt \ - app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteResolutionTest.kt -``` - -Die Datei muss vorher `git add`ed sein, damit die Pfadangabe sie findet; `git add` mit -denselben vier Pfaden, nicht `git add -A`. - ---- - -## Aufgabe 5: Der Kontrastwächter - -> **Eine Lücke der Spezifikation, gemessen gefüllt.** Der Entwurf nennt das -> WCAG-Kontrastverhältnis als Maß, aber **keine** Grenze. Der naheliegende Boden wäre WCAG AA -> mit 4,5. Die ausgelieferten Vorlagen halten diesen Boden selbst nicht -> ein: -> -> | Paar | Tag | Abend | -> |---|---|---| -> | Text auf Fläche | 17,78 | 7,26 | -> | Text im Feld | 21,0 | **≈2,9** | -> | Akzent auf Fläche | **2,86** | **2,26** | -> -> Eine absolute Grenze würde also ab dem ersten Start in beiden Entwürfen warnen, ohne dass -> der Operateur etwas getan hätte — und eine Warnung, die immer leuchtet, bringt ihm bei, -> Warnungen zu übersehen. Die Grenze bis zum Durchkommen der Vorlagen zu senken (auf etwa -> 2,2) macht sie wertlos. -> -> Der Wächter wird deshalb **relativ**: gewarnt wird, wenn ein Paar unter dem Boden liegt -> **und** schlechter ist als derselbe Paarwert der Auslieferung. Damit warnt der -> Auslieferungsstand von Bauart aus nie, eine Verbesserung warnt nie, und berichtet wird -> genau das, was der Entwurf eigentlich meint: „diese Änderung hat das Lesen schwerer -> gemacht, als es war". -> -> Marc hat das mit dem Plan abgenommen. Die beiden Abendwerte sind von Hand gerechnet, der -> Feldwert über eine Annäherung an `derive`; der Test rechnet sie echt. - -**Dateien:** -- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt` -- Anlegen: `app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt` - -**Schnittstellen:** -- Liefert: `fun contrastRatio(a: Color, b: Color): Double` — WCAG, 1.0 bis 21.0 -- Liefert: `data class ContrastWarning(val what: String, val ratio: Double, val shippedRatio: Double)` -- Liefert: `fun contrastWarnings(resolved: JavaFxPalette, shipped: JavaFxPalette): List` - — die drei Paare, die wirklich übereinanderliegen: Text auf Fläche, Text auf Feldinnerem, - Akzent auf Fläche. -- Grenze: `CONTRAST_FLOOR = 4.5` (WCAG AA für Fließtext). - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -```kotlin -package kst4contest.view.compose - -import androidx.compose.ui.graphics.Color -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import kotlin.math.abs - -/** - * The readability measure, which only warns. - * - * A pure function, and therefore the part that can be wrong without anyone noticing: a - * warning that never fires is indistinguishable from a palette that is fine, and a warning - * that always fires teaches the operator to ignore warnings. The reference numbers below are - * the ones WCAG itself states for black and white. - * - * The comparison is against the shipped sheet rather than against the bare floor, because - * the shipped sheets do not meet that floor themselves -- measured: the evening accent sits - * at 2.26 on its own surface. What is reported is therefore "this change made a pair harder - * to read than it shipped", which is the question the operator is actually asking. - */ -class ContrastRuleTest { - - private val day = JavaFxStylesheet.read("/KST4ContestDefaultDay.css") - private val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css") - - private fun assertClose(expected: Double, actual: Double, what: String) { - assertTrue(abs(expected - actual) < 0.05, "$what: expected ~$expected but was $actual") - } - - @Test - fun blackOnWhiteIsTheMaximumOf21() { - assertClose(21.0, contrastRatio(Color.Black, Color.White), "black on white") - assertClose(21.0, contrastRatio(Color.White, Color.Black), "white on black") - } - - @Test - fun aColourAgainstItselfIsTheMinimumOf1() { - assertClose(1.0, contrastRatio(Color.Black, Color.Black), "black on black") - assertClose(1.0, contrastRatio(Color(0xFF8899AA), Color(0xFF8899AA)), "grey on grey") - } - - @Test - fun theRatioDoesNotDependOnWhichIsTheBackground() { - assertEquals(contrastRatio(Color(0xFF123456), Color(0xFFEEDDCC)), - contrastRatio(Color(0xFFEEDDCC), Color(0xFF123456))) - } - - @Test - fun midGreyOnWhiteIsAroundFourAndAHalf() { - // #767676 on white is the canonical WCAG AA boundary example: 4.54. - assertClose(4.54, contrastRatio(Color(0xFF767676), Color.White), "#767676 on white") - } - - @Test - fun aShippedSheetNeverWarnsAboutItself() { - /* - * The property the whole relative rule exists for. Both sheets have pairs under the - * floor -- the day accent at 2.86, the evening accent at 2.26 -- so an absolute rule - * would warn on a client nobody has touched. - */ - assertTrue(contrastWarnings(day, day).isEmpty(), "the daylight sheet warns about itself") - assertTrue(contrastWarnings(evening, evening).isEmpty(), "the evening sheet warns about itself") - } - - @Test - fun theShippedSheetsReallyDoHavePairsUnderTheFloor() { - /* - * Measured here rather than asserted in prose, because it is the premise of the rule - * above. If a later change to the sheets makes every pair pass, the relative rule - * becomes equivalent to the absolute one and the deviation can be dropped. - */ - val accentOnSurface = contrastRatio(day.accent, day.base) - - assertTrue( - accentOnSurface < CONTRAST_FLOOR, - "the daylight accent now passes at $accentOnSurface; the relative rule may no " - + "longer be needed", - ) - } - - @Test - fun blackTextOnABlackSurfaceWarnsAboutEveryPairItAppearsIn() { - val unreadable = day.copy( - base = Color.Black, - controlInnerBackground = Color.Black, - labelTextFill = Color.Black, - accent = Color.Black, - ) - - val warnings = contrastWarnings(unreadable, day) - - assertEquals(3, warnings.size, "all three pairs must warn: ${warnings.map { it.what }}") - warnings.forEach { - assertTrue(it.ratio < CONTRAST_FLOOR, it.what) - assertTrue(it.ratio < it.shippedRatio, "${it.what} is not actually worse than shipped") - } - } - - @Test - fun anImprovementDoesNotWarnEvenWhileStillUnderTheFloor() { - /* - * The evening accent ships at 2.26. An operator who moves it to 3.5 has made things - * better and must not be told off for it -- that is how a guard loses its authority. - */ - val better = evening.copy(accent = Color.White) - - val accentWarnings = contrastWarnings(better, evening).filter { it.what.contains("Accent") } - - assertTrue(accentWarnings.isEmpty(), "an improvement warned: $accentWarnings") - } - - @Test - fun aPairThatStaysComfortablyReadableDoesNotWarn() { - // Worse than shipped but still well over the floor: nothing to say. - val slightlyWorse = day.copy(labelTextFill = Color(0xFF444444)) - - val textWarnings = contrastWarnings(slightlyWorse, day).filter { it.what.contains("Text") } - - assertTrue(textWarnings.isEmpty(), "a readable pair warned: $textWarnings") - } -} -``` - -- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** - -```bash -./gradlew :app-desktop:test --tests '*ContrastRuleTest*' -``` - -- [ ] **Schritt 3: Die Umsetzung schreiben** - -```kotlin -package kst4contest.view.compose - -import androidx.compose.ui.graphics.Color -import kotlin.math.pow - -/** WCAG AA for body text. Below this a pair is reported, never refused. */ -const val CONTRAST_FLOOR = 4.5 - -/** - * One pair that reads badly, named the way the settings tab shows it. - * - * @param ratio what the pair measures now - * @param shippedRatio what the same pair measures in the shipped sheet, so the tab can say - * how far the change moved it - */ -data class ContrastWarning(val what: String, val ratio: Double, val shippedRatio: Double) - -/** - * The WCAG contrast ratio of two colours, between 1.0 and 21.0. - * - * Relative luminance and not brightness: the two are easy to confuse, and the JavaFX colour - * derivation in this same package is a standing reminder that guessing a colour formula - * produces values that look plausible and are wrong. - */ -fun contrastRatio(a: Color, b: Color): Double { - val lighter = maxOf(relativeLuminance(a), relativeLuminance(b)) - val darker = minOf(relativeLuminance(a), relativeLuminance(b)) - return (lighter + 0.05) / (darker + 0.05) -} - -private fun relativeLuminance(colour: Color): Double { - fun channel(value: Float): Double { - val v = value.toDouble() - return if (v <= 0.03928) v / 12.92 else ((v + 0.055) / 1.055).pow(2.4) - } - - return 0.2126 * channel(colour.red) + - 0.7152 * channel(colour.green) + - 0.0722 * channel(colour.blue) -} - -/** - * The pairs that actually sit on top of each other and read worse than they shipped. - * - * Two conditions, both required: under [CONTRAST_FLOOR], and worse than the same pair in the - * shipped sheet. The second is there because the shipped sheets do not meet the floor - * themselves -- the evening accent measures 2.26 on its own surface -- so an absolute rule - * would warn about a client nobody has touched, which is how an operator learns to ignore - * warnings. With this rule the shipped state never warns and an improvement never warns. - * - * Only three pairs, deliberately. Every pair of six roles would be fifteen, most of which - * never meet on screen, and a warning about a pair that cannot occur costs the same - * attention as a real one. - */ -fun contrastWarnings(resolved: JavaFxPalette, shipped: JavaFxPalette): List { - - fun pairsOf(palette: JavaFxPalette) = listOf( - "Text on the surface" to (palette.labelTextFill to palette.base), - "Text inside a field" to (palette.labelTextFill to palette.controlInnerBackground), - "Accent on the surface" to (palette.accent to palette.base), - ) - - val shippedRatios = pairsOf(shipped).associate { (what, colours) -> - what to contrastRatio(colours.first, colours.second) - } - - return pairsOf(resolved).mapNotNull { (what, colours) -> - val ratio = contrastRatio(colours.first, colours.second) - val shippedRatio = shippedRatios.getValue(what) - - if (ratio < CONTRAST_FLOOR && ratio < shippedRatio) { - ContrastWarning(what, ratio, shippedRatio) - } else { - null - } - } -} -``` - -- [ ] **Schritt 4: Tests laufen lassen** - -```bash -./gradlew :app-desktop:test --tests '*ContrastRuleTest*' -``` - -Schlägt `aShippedSheetNeverWarnsAboutItself` fehl, ist die relative Bedingung falsch -herum — nicht die Vorlage schuld. - -- [ ] **Schritt 5: Bauen, committen** - -```bash -./gradlew clean build -git add app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt \ - app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt -git commit -m "Warn when a change reads worse than the shipped sheet, not below a fixed floor" -- \ - app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt \ - app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt -``` - ---- - -## Aufgabe 6: Der Store, und dass eine Änderung alle Fenster erreicht - -**Dateien:** -- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStore.kt` -- Anlegen: `app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreTest.kt` -- Ändern: `app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt` - -**Schnittstellen:** -- Liefert: - ```kotlin - class PaletteStore( - private val shippedOf: (darkMode: Boolean) -> JavaFxPalette, - private val fileRolesOf: (darkMode: Boolean) -> Map, - private val storedOverridesOf: (darkMode: Boolean) -> String, - private val storeOverrides: (darkMode: Boolean, encoded: String) -> Unit, - ) - ``` - mit `resolved(darkMode: Boolean): ResolvedPalette`, `set(darkMode, role, colour: String)`, - `resetToShipped(darkMode)`, `discardChanges(darkMode)`, `restoreSnapshot(darkMode)`, - `takeSnapshot()`, und `shipped(darkMode): JavaFxPalette` für die Rettungsleine. -- Die vier Lambdas statt eines `ChatPreferences`: der Store wird damit ohne Benutzerprofil auf - der Platte testbar — `ChatPreferences`' Konstruktor kopiert Ressourcen in das Heimatverzeichnis. -- Liefert: `val LocalPaletteStore: ProvidableCompositionLocal` mit `null` als - Vorgabe, damit ein Fenster ohne Store (Tests, der Profilwähler vor dem Start) weiter die - ausgelieferte Palette zeichnet. - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -```kotlin -package kst4contest.view.compose - -import androidx.compose.ui.graphics.Color -import kst4contest.model.PaletteRole -import kst4contest.model.PaletteOverrides -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -/** - * The store, and the three ways back. - * - * Built over four lambdas rather than over ChatPreferences, so none of this touches the - * operator's home directory: that constructor copies resources into it. - */ -class PaletteStoreTest { - - private val stored = mutableMapOf(false to "", true to "") - private var fileRoles: Map = emptyMap() - - private fun store() = PaletteStore( - shippedOf = { dark -> - JavaFxStylesheet.read( - if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css" - ) - }, - fileRolesOf = { fileRoles }, - storedOverridesOf = { dark -> stored.getValue(dark) }, - storeOverrides = { dark, encoded -> stored[dark] = encoded }, - ) - - @Test - fun aChangeIsVisibleInTheResolvedPaletteAndInTheStorage() { - val store = store() - - store.set(darkMode = false, role = PaletteRole.ACCENT, colour = "#FF0000") - - assertEquals(Color(0xFFFF0000), store.resolved(false).palette.accent) - assertEquals(PaletteSource.CHANGED, store.resolved(false).roles[PaletteRole.ACCENT]!!.source) - assertEquals("accent=#FF0000", stored.getValue(false)) - } - - @Test - fun anUnparsableColourLeavesTheRoleAsItWas() { - // The operator typed something that is not a colour. Nothing may turn black. - val store = store() - val before = store.resolved(false).palette.accent - - store.set(false, PaletteRole.ACCENT, "rot") - - assertEquals(before, store.resolved(false).palette.accent) - assertEquals("", stored.getValue(false)) - } - - @Test - fun theTwoDesignsDoNotDragEachOtherAlong() { - val store = store() - - store.set(darkMode = true, role = PaletteRole.ACCENT, colour = "#FF0000") - - assertEquals(PaletteSource.CHANGED, store.resolved(true).roles[PaletteRole.ACCENT]!!.source) - assertEquals(PaletteSource.SHIPPED, store.resolved(false).roles[PaletteRole.ACCENT]!!.source) - } - - @Test - fun resetToShippedBeatsAFileAsWell() { - /* - * The acceptance criterion of this stage: "exactly the shipped colours". With a file - * in the middle, clearing the changes would not get there -- the file would. - */ - fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00)) - val store = store() - val shippedAccent = store.shipped(false).accent - - store.resetToShipped(false) - - assertEquals(shippedAccent, store.resolved(false).palette.accent) - assertEquals(PaletteSource.CHANGED, store.resolved(false).roles[PaletteRole.ACCENT]!!.source) - assertEquals(6, PaletteOverrides.parse(stored.getValue(false)).size) - } - - @Test - fun discardingChangesLetsTheFileApplyAgain() { - fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00)) - val store = store() - store.set(false, PaletteRole.ACCENT, "#FF0000") - - store.discardChanges(false) - - assertEquals(Color(0xFF00FF00), store.resolved(false).palette.accent) - assertEquals(PaletteSource.FILE, store.resolved(false).roles[PaletteRole.ACCENT]!!.source) - assertTrue(stored.getValue(false).isEmpty()) - } - - @Test - fun theSnapshotRestoresWhatWasInForceWhenItWasTaken() { - val store = store() - store.set(false, PaletteRole.ACCENT, "#FF0000") - - store.takeSnapshot() - store.set(false, PaletteRole.ACCENT, "#0000FF") - store.set(false, PaletteRole.TEXT, "#00FF00") - store.restoreSnapshot(false) - - // Including the change that was already there: the snapshot is not "shipped". - assertEquals(Color(0xFFFF0000), store.resolved(false).palette.accent) - assertEquals(PaletteSource.SHIPPED, store.resolved(false).roles[PaletteRole.TEXT]!!.source) - } - - @Test - fun theLifelineReadsTheShippedSheetAndNotTheResolvedPalette() { - /* - * The rescue button is drawn in these colours. If it ever read the resolved palette, - * it would vanish in exactly the palette it exists to undo -- and nobody would find - * out until they needed it. - */ - val store = store() - val shippedBase = store.shipped(false).base - - PaletteRole.values().forEach { store.set(false, it, "#000000") } - - assertEquals(shippedBase, store.shipped(false).base) - assertEquals(Color.Black, store.resolved(false).palette.base) - } -} -``` - -- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** - -```bash -./gradlew :app-desktop:test --tests '*PaletteStoreTest*' -``` - -- [ ] **Schritt 3: Den Store schreiben** - -`PaletteStore` hält je Entwurf einen `mutableStateOf` mit der kodierten -Abweichungsmenge, aus den vier Lambdas beim Bau gefüllt. `resolved(darkMode)` liest diesen -Zustand — **das ist der Grund, warum eine Änderung die Fenster erreicht** — und ruft -`resolvePalette(shippedOf(darkMode), fileRolesOf(darkMode), parse(zustand))`. - -- `set` prüft mit `PaletteOverrides.isValidColour` und tut bei ungültiger Eingabe **nichts**. -- `resetToShipped` schreibt alle sechs Rollen mit den Werten aus `shippedOf(darkMode)` als - ausdrückliche Änderung. -- `discardChanges` setzt den Zustand auf `""`. -- `takeSnapshot` merkt die kodierten Zeichenketten beider Entwürfe; `restoreSnapshot` setzt sie - zurück. Ohne vorherigen `takeSnapshot` ist `restoreSnapshot` eine No-op. -- Jede Änderung ruft `storeOverrides`, damit `ChatPreferences` dieselbe Durchschreibe-Semantik - hat wie jeder andere Einstellungsreiter — die XML schreibt erst - `savePreferencesFromSettings`, wie bei allen anderen Einstellungen auch. **Das gehört in die - Klassen-Javadoc**, weil es die eine Stelle ist, an der „wirkt sofort" und „überlebt den - Neustart" auseinanderfallen. -- Eine Farbe nach `Color`: `Color(("ff" + hex.removePrefix("#")).toLong(16))`. Die - Hilfsfunktion dafür ist privat und wird von `PaletteOverrides.isValidColour` vorab - abgesichert. - -- [ ] **Schritt 4: `Theme.kt` auf den Store umhängen** - -```kotlin - val store = LocalPaletteStore.current - - val palette = if (store != null) { - /* - * Read from the store's state, not remembered: that read is what makes a palette - * change recompose every open window. remember(darkMode) kept the palette until - * someone toggled day/evening, which is why a change used to reach nothing. - */ - store.resolved(darkMode).palette - } else { - remember(darkMode) { - JavaFxStylesheet.read(if (darkMode) EVENING_STYLESHEET else DAYLIGHT_STYLESHEET) - } - } -``` - -Der `null`-Zweig ist nicht Vorsicht, sondern nötig: der Profilwähler zeichnet, bevor ein Profil -und damit ein Store existiert. - -- [ ] **Schritt 5: Tests laufen lassen, bauen, committen** - -```bash -./gradlew :app-desktop:test --tests '*PaletteStoreTest*' -./gradlew clean build -git add app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStore.kt \ - app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt \ - app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreTest.kt -git commit -m "Hold the palette as state so a change reaches every open window" -- \ - app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStore.kt \ - app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt \ - app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreTest.kt -``` - ---- - -## Aufgabe 7: Den Store im Laufzeitaufbau bilden und bereitstellen - -**Dateien:** -- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -- Ändern: `app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt` -- Ändern: `core/src/main/java/kst4contest/controller/OperatorProfilePaths.java` -- Ändern: `core/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java` - -**Schnittstellen:** -- Liefert: `OperatorProfilePaths.paletteRelativeFileName(OperatorProfile profile, boolean darkMode)` - → `String`. - -**Die Pfadbildung kommt nach `OperatorProfilePaths`, nicht in eine neue Klasse.** Dort stehen -schon `preferencesRelativeFileName` und `workedDatabaseRelativeFileName` mit genau dieser -Form: Wurzelprofil flach, jedes andere Profil unter `profileRelativeDirectory(profile)`. Eine -zweite Stelle, die dieselbe Form nachbaut, ist die Stelle, an der ein Profil später die Farben -eines anderen liest. Die Methode nimmt deshalb auch ein `OperatorProfile` und nicht -`(boolean, String)`, wie ihre beiden Geschwister. - -- [ ] **Schritt 1: Den Test für die Pfadbildung schreiben** - -Angehängt an das vorhandene `OperatorProfilePathsTest`, weil er dieselbe Form prüft wie die -Tests der beiden Geschwistermethoden: - -```java - @Test - void theRootProfileKeepsTheHistoricFlatStylesheetNames() { - OperatorProfile root = OperatorProfilePaths.buildRootProfile("Default"); - - assertEquals( - "KST4ContestDefaultDay.css", - OperatorProfilePaths.paletteRelativeFileName(root, false)); - assertEquals( - "KST4ContestDefaultEvening.css", - OperatorProfilePaths.paletteRelativeFileName(root, true)); - } - - @Test - void anotherProfileGetsItsStylesheetInItsOwnDirectory() { - /* - * The same shape the preferences and the worked database already take. A different - * shape here would have one profile reading another profile's colours. - */ - OperatorProfile other = new OperatorProfile(); - other.setProfileId("A"); - - assertEquals( - "profiles/A/KST4ContestDefaultDay.css", - OperatorProfilePaths.paletteRelativeFileName(other, false)); - } -``` - -**Den Aufbau eines Nicht-Wurzelprofils aus dem Nachbartest übernehmen**, nicht erfinden: wie -`OperatorProfilePathsTest` ein Profil mit `profileId` baut, steht dort schon, und `isRootProfile()` -hängt daran, ob die Kennung `ROOT_PROFILE_ID` ist. - -- [ ] **Schritt 2: Laufen lassen, umsetzen, laufen lassen** - -```bash -./gradlew :core:test --tests '*OperatorProfilePathsTest*' -``` - -Die Methode, neben ihren beiden Geschwistern und nach deren Muster: - -```java - /** - * Returns the stylesheet file name relative to the application directory. - * - *

The operator's own stylesheet for one design, which is the middle of the three - * palette layers. Shaped like the preferences and the worked database: the root profile - * keeps the historic flat name, every other profile gets it inside its own directory.

- * - * @param profile profile to resolve - * @param darkMode true for the evening design, false for the daylight one - * @return relative stylesheet file name - */ - public static String paletteRelativeFileName(final OperatorProfile profile, final boolean darkMode) { - - final String fileName = darkMode - ? ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING - : ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT; - - if (profile.isRootProfile()) { - return fileName; - } - - return profileRelativeDirectory(profile) + "/" + fileName; - } -``` - -**Achtung, eine Namensgleichheit mit Folgen:** die Datei des Wurzelprofils heißt genauso wie -die Kopie, die `Kst4ContestApplication` beim Start ins Anwendungsverzeichnis schreibt -(`STYLE_DEFAULTCSSDAY_FILE`, `Kst4ContestApplication.java:162`). Das ist **gewollt** — genau -diese Kopie soll der Ausweg sein — aber es heißt: wer beim Start die Kopie überschreibt, -überschreibt die Handarbeit des Operateurs. Vor Aufgabe 10 ist deshalb nachzusehen, ob dieser -Startschreibvorgang noch existiert und ob er bedingungslos überschreibt: - -```bash -grep -n "STYLE_DEFAULTCSSDAY_FILE\|STYLE_DEFAULTCSSEVENING_FILE" \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Überschreibt er bedingungslos, **anhalten und Marc sagen**: dann ist entweder der -Startschreibvorgang auf „nur wenn nicht vorhanden" zu ändern oder die Dateischicht braucht -einen anderen Namen. Das zu entscheiden ist nicht Teil dieser Aufgabe. - -- [ ] **Schritt 3: Den Store in `startRuntime()` bilden** - -Nach dem `chatcontroller`, vor dem ersten Fenster. Die vier Lambdas: - -```java - OperatorProfileSelection activeProfile = ActiveOperatorProfile.get(); - paletteStore = PaletteStoreFactory.create( - chatcontroller.getChatPreferences(), - activeProfile); -``` - -`PaletteStoreFactory` ist eine kleine Kotlin-Funktion neben `PaletteStore.kt`, die die vier -Lambdas aus Einstellungen und Profil zusammensetzt — damit bleibt der Java-Aufruf einzeilig und -der Store ohne `ChatPreferences` testbar. - -Beim **Profilwechsel** entsteht der Store neu, weil `startRuntime()` auf einer frischen -`Kst4ContestApplication` läuft; nichts weiter zu tun. Das in einem Kommentar festhalten, weil -es sonst wie ein Versäumnis aussieht. - -- [ ] **Schritt 4: Den Store an die Fenster geben** - -`ComposeWindowHost.show` bekommt einen Parameter `paletteStore: PaletteStore? = null` und legt -ihn um den Inhalt: - -```kotlin - CompositionLocalProvider(LocalPaletteStore provides paletteStore) { - Kst4ContestTheme( - darkMode = darkModeState.value, - baseFontSizeSp = baseFontSizeSp, - ) { - content(::exitApplication) - } - } -``` - -Dasselbe in `ComposeAlert` und `OperatorProfilePickerWindow` **nicht** — Dialoge bleiben bei -der ausgelieferten Palette, solange niemand das Gegenteil verlangt; der `null`-Zweig aus -Aufgabe 6 deckt sie ab. Das ist eine Festlegung und gehört in einen Kommentar. - -Jeder Fenster-Wirt (`MainWindowHost`, `SettingsWindow`, `MonitorWindow`, `UpdateWindow`, -`StationMapWindow`, `TopPriorityCandidatesWindow`) reicht den Store durch. Die Aufrufer in -`Kst4ContestApplication` übergeben `paletteStore`. - -- [ ] **Schritt 5: Bauen, Testzahl prüfen, GUI-ABNAHME** - -```bash -./gradlew clean build && ./gradlew :app-desktop:run --args="--profile default" -``` - -Zu prüfen: alle Fenster sehen aus wie vorher. **Diese Scheibe darf nichts verändern** — sie -verlegt nur, woher die Farben kommen. Jeder sichtbare Unterschied ist ein Fehler. - -- [ ] **Schritt 6: Committen** - -```bash -git add app-desktop/src/main/kotlin/kst4contest/view/compose/ \ - core/src/main/java/kst4contest/controller/OperatorProfilePaths.java \ - core/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -git commit -m "Build the palette store per profile and hand it to every window" -- \ - app-desktop/src/main/kotlin/kst4contest/view/compose/ \ - core/src/main/java/kst4contest/controller/OperatorProfilePaths.java \ - core/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java \ - app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - ---- - -## Aufgabe 8: Der Zustand des Reiters - -**Dateien:** -- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTabState.kt` -- Anlegen: `app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColoursTabStateTest.kt` - -**Schnittstellen:** -- Liefert: `class ColoursTabState(private val store: PaletteStore, private val darkModeNow: () -> Boolean)` - mit `roles(): List`, `set(role, text)`, `warnings(): List`, - `resetToShipped()`, `discardChanges()`, `restoreSnapshot()`, `exportStylesheet(): String?`, - und `lastRefusal: String?`. -- Liefert: `data class RoleRow(val role: PaletteRole, val label: String, val hex: String, - val source: PaletteSource)`. -- `set` mit unparsbarer Eingabe setzt `lastRefusal` auf einen Satz, den der Reiter zeigt, und - ändert nichts. - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -Deckt mindestens ab: dass `roles()` sechs Zeilen mit Hex und Herkunft liefert; dass `set` mit -`"rot"`, `""`, `"#GGG"` und `"#1234567"` nichts ändert und `lastRefusal` setzt; dass eine gültige -Eingabe `lastRefusal` löscht; dass `roles()` dem aktiven Entwurf folgt (`darkModeNow`); dass -`warnings()` die Warnungen liefert, und zwar als -`contrastWarnings(store.resolved(darkModeNow()).palette, store.shipped(darkModeNow()))` — mit -**beiden** Paletten, weil der Wächter relativ zur Auslieferung misst. - -Aufbau wie `PaletteStoreTest`: ein `PaletteStore` über vier Lambdas, kein `ChatPreferences`. - -- [ ] **Schritt 2–4: Laufen lassen, umsetzen, laufen lassen** - -```bash -./gradlew :app-desktop:test --tests '*ColoursTabStateTest*' -``` - -Die Beschriftungen der sechs Rollen auf Englisch, wie jeder andere Oberflächentext: -`Surface`, `Window surface`, `Field interior`, `Text`, `Accent`, `Separator line`. Etappe 9 -zieht sie später in ein Ressourcenbündel; hier gehören sie an **eine** Stelle, damit das -später ein Handgriff ist. - -- [ ] **Schritt 5: Bauen, committen** - ---- - -## Aufgabe 9: Der Reiter — **GUI-ABNAHME** - -**Dateien:** -- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt` -- Ändern: `app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt` - -- [ ] **Schritt 1: Den Reiter bauen** - -Je Rolle eine Zeile über `Form.text`: Beschriftung, Hex-Feld, und rechts die Herkunft als -Text (`shipped`, `from file`, `changed`). Darunter die Warnungen des Kontrastwächters, dann die -drei Rückwege und der Ausfuhr-Knopf. - -**Die Rettungsleine wird nicht mit `Form.button` gezeichnet**, weil der die aktive Palette -nimmt. Sie bekommt ihre Farben aus `store.shipped(darkModeNow())`: - -```kotlin -/** - * The one control that refuses the operator's palette. - * - * Drawn from the shipped sheet, always. An operator who sets text and surface to the same - * black cannot see any other button -- including the ones that would undo it. That this - * looks out of place under a customised palette is the point: it is a lifeline, and not - * fitting in is what makes it findable. - */ -@Composable -private fun ResetToShippedButton(shipped: JavaFxPalette, onClick: () -> Unit) { … } -``` - -- [ ] **Schritt 2: Den Reiter anhängen** - -In `SettingsTabs.kt` **nach** `SettingsTab("Profiles", …)`, mit dem Kommentar, der dort schon -begründet, warum angehängt und nicht einsortiert wird: - -```kotlin - SettingsTab("Colours") { ColoursTab(ColoursTabState(paletteStore, darkModeNow)) }, -``` - -`paletteStore` und `darkModeNow` müssen bis hierher durchgereicht werden; `SettingsTabs` hat -`prefs` und `host` schon, also denselben Weg nehmen. - -- [ ] **Schritt 3: Bauen, Testzahl prüfen, GUI-ABNAHME** - -```bash -./gradlew clean build && ./gradlew :app-desktop:run --args="--profile default" -``` - -Zu prüfen, und das ist die eigentliche Abnahme dieser Etappe: - -1. Eine Farbe ändern → **alle offenen Fenster** ziehen sofort mit, nicht erst nach einem - Fensterwechsel. -2. „Zurück zum Stand von vorher" stellt her, was beim Öffnen des Reiters galt. -3. Einstellungen speichern, Anwendung beenden, neu starten → die Farbe ist noch da. -4. **Text und Fläche auf dasselbe Schwarz stellen** → der Auslieferungs-Knopf bleibt lesbar - und holt alles zurück. Das ist der Test, für den die Rettungsleine existiert. -5. Tag/Abend umschalten → die beiden Paletten sind unabhängig. -6. Zweites Profil → andere Farben, und das erste bleibt unberührt. -7. Eine Farbe mit schlechtem Kontrast → die Warnung erscheint, und speichern geht trotzdem. - Gegenprobe, die genauso wichtig ist: **im unberührten Zustand steht keine Warnung da.** -8. Unsinn in ein Feld tippen → nichts ändert sich, der Reiter sagt warum. - -- [ ] **Schritt 4: Committen** - ---- - -## Aufgabe 10: Die Datei als Ausweg - -- [ ] **Schritt 1: Der Ausfuhr-Knopf** - -„Write this design's stylesheet into my profile" kopiert die ausgelieferte Vorlage an den Pfad -aus `OperatorProfilePaths.paletteRelativeFileName`, erzeugt das Profilverzeichnis, falls es fehlt, und sagt, wohin geschrieben -wurde. Überschreibt **nicht**, wenn die Datei schon da ist, sondern sagt, dass sie da ist — -sonst verliert ein Operateur seine Handarbeit mit einem Fehlklick. - -- [ ] **Schritt 2: Test** - -Dass eine vorhandene Datei nicht überschrieben wird, und dass der Pfad der aus Aufgabe 7 ist. -Mit einem temporären Verzeichnis, nicht im Benutzerprofil. - -- [ ] **Schritt 3: GUI-ABNAHME** - -Datei ausführen lassen, von Hand eine Farbe darin ändern, Anwendung neu starten → die Farbe -gilt, und der Reiter zeigt bei dieser Rolle `from file`. Dann dieselbe Rolle im Reiter ändern → -`changed` gewinnt. Dann „auf Auslieferung" → die ausgelieferte Farbe, trotz Datei. - -Danach die Datei absichtlich zerstören (halbe Zeile, Binärmüll) und neu starten → die -Anwendung startet, die Rolle fällt auf die Auslieferung zurück. - -- [ ] **Schritt 4: Committen** - ---- - -## Aufgabe 11: Dokumentation - -- [ ] **Schritt 1: Handbuch, beide Sprachen** - -`github_docs/de-Funktionen.md` und `github_docs/en-Features.md` bekommen einen Abschnitt zum -Farbreiter. Darin **muss** stehen: - -- dass eine Änderung sofort gilt und wie man zurückkommt, -- **dass der Auslieferungs-Knopf immer in den ausgelieferten Farben gezeichnet ist und auch - dann funktioniert, wenn man nichts mehr lesen kann** — das ist die Stelle, an der ein - Operateur es nachliest, und der Risikoabschnitt der Spezifikation verlangt es ausdrücklich, - weil ein Tastaturweg nur hilft, wer ihn kennt, -- dass die Farben pro Profil gelten, -- wo die Datei liegt und dass eine Änderung im Reiter sie überstimmt. - -Beide Sprachen semantisch gleich, wie `AGENTS.md` verlangt. - -- [ ] **Schritt 2: `docs/PROJECT_CONTEXT.md`** - -Die Dreischicht-Quelle und ihren Vorrang eintragen — das ist eine dauerhafte -Architekturentscheidung und gehört nach `AGENTS.md` Punkt 6 dorthin. - -- [ ] **Schritt 3: Veraltete Aussagen suchen** - -```bash -grep -rniE "stylesheet|farbe|colour|color" github_docs/*.md docs/PROJECT_CONTEXT.md | grep -viE "changelog" | head -20 -``` - -Alles, was sagt, die Farben seien fest oder kämen aus dem Installationsverzeichnis, ist jetzt -falsch. - -- [ ] **Schritt 4: Committen** - ---- - -## Was unverändert bleibt - -- Die ausgelieferten Vorlagen `KST4ContestDefaultDay.css` und `…Evening.css`. -- `JavaFxStylesheet.read(resourcePath)` in Signatur und Verhalten — Schicht 1 braucht eine - vollständige Palette. -- Der `derive`-Pin, `javafx-derive-reference.txt` und `JavaFxStylesheetTest`. -- `fontSizePx` und die bestehende Schriftgrößeneinstellung. -- Die Reiterreihenfolge im Einstellungsfenster. -- Dialoge (`ComposeAlert`, `OperatorProfilePickerWindow`) bei der ausgelieferten Palette. -- `core` toolkit-frei: Farben dort als Zeichenketten. - -## Bekannte Fallstricke - -- **`ComposeStationMapDrawingTest` ist flaky** unter paralleler Last. Bei Rot zuerst isoliert - nachprüfen. -- **PMD/SpotBugs** melden rund 4448 vorbestehende Befunde und brechen den Build nicht. -- **`ChatPreferences`' Konstruktor kopiert Ressourcen in das Heimatverzeichnis.** Deshalb - bauen `PaletteStoreTest` und `ColoursTabStateTest` über Lambdas und nicht über - `ChatPreferences`. -- **Der Rückfalltest aus Etappe 7, Klasse 4**, prüft, dass die Farben aus der Stilvorlage - kommen. Er muss nach dieser Etappe weiter gelten — er liest die Vorlage direkt und ist von - den Abweichungen unberührt, aber das ist nachzuprüfen, nicht anzunehmen. -- **Eine Zahl aus diesem Plan ist keine Messung.** Die Testzahlen stehen als Erwartung da; - abgelesen wird aus der XML. diff --git a/docs/superpowers/plans/2026-10-04-javafx-entfernen-teil5-etappe6.md b/docs/superpowers/plans/2026-10-04-javafx-entfernen-teil5-etappe6.md deleted file mode 100644 index 883c9548..00000000 --- a/docs/superpowers/plans/2026-10-04-javafx-entfernen-teil5-etappe6.md +++ /dev/null @@ -1,1141 +0,0 @@ -# Umsetzungsplan — JavaFX entfernen, Teil 5: Etappe 6, die Reste und der Build - -> **Für ausführende Agenten:** Schritte sind als Kästchen (`- [ ]`) geführt. Nach jeder Scheibe -> ein Commit und ein `./gradlew clean build` mit Auswertung der Test-XML. Nach den mit -> **GUI-ABNAHME** markierten Scheiben die Anwendung starten und von Marc bedienen lassen. - -**Ziel:** `grep -rl "javafx\." core/src/main app-desktop/src/main` liefert nichts, und der -`org.openjfx`-Block ist aus dem Build und dem Versionskatalog verschwunden. - -**Architektur:** Zwei Stellen benutzen JavaFX noch lebend und werden ersetzt, bevor gelöscht wird: -der 300-ms-Entpreller der Benutzerliste (`javafx.animation.PauseTransition` → `javax.swing.Timer`) -und `FxRosterBinding` (`ObservableList` → gewöhnliche Liste plus Rückruf). Alles übrige ist ein -Schatten, der nie in einer Szene hängt und nur sich selbst füttert; er fällt in Teilscheiben, -damit eine später gefundene Regression einer Scheibe zuzuordnen ist. - -**Werkzeuge:** Java 21, Gradle Wrapper, Kotlin 2.2.0, Compose Multiplatform 1.8.2, JUnit 5. - -**Spezifikation:** `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`, Etappe 6. -**Lebendbefund:** `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`. -**Vorgänger:** `docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md`. - -## Projektweite Vorgaben - -- Kommunikation mit Marc auf **Deutsch**; Quelltextkommentare und Javadoc **ausschließlich - Englisch**. 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: **835 Tests, 0 Fehler**. -- **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. -- **Immer mit Pfadangabe committen** (`git commit -- `). Dieser Arbeitsbaum trägt - vorbestehende gestapelte und ungestapelte Änderungen von vor der Sitzung; ein nackter - `git commit` nimmt sie mit. -- Kein Push, kein Merge, kein Tag, kein Release, kein Versions-Bump. Keine neue Abhängigkeit. -- **Nicht anfassen:** `selectedCallSignInfoStageChatMember` (gewöhnliches Feld, von - `ComposeChatInputActions.panelSelection()` gelesen), die 91 MP3-Dateien, `PlayAudioUtils`, - `UiDispatcher`/`AwtUiDispatcher`/`DirectUiDispatcher`, `macOsConnectionStateMenuTitle` samt Test, - `JavaFxStylesheet.kt` samt eingefrorener `derive`-Tabelle (der Name beschreibt die Herkunft der - Farben und bleibt), `StationMapClusterer`/`StationMapStatusText` in `core`, und die - **Compose**-Dateien `compose/TimelineView.kt` und `compose/map/PathProfileChart.kt`. - -## Die Namensfalle, die diesen Plan sonst zerlegt - -Vier Namen existieren **zweimal** — einmal JavaFX, einmal Compose. Gelöscht wird nur die -JavaFX-Fassung. Wer nach dem bloßen Namen greift, löscht die lebende: - -| Name | **löschen** | **behalten** | -|---|---|---| -`TimelineView` | `app-desktop/src/main/java/kst4contest/view/TimelineView.java` | `app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt` | -`PathProfileChart` | `app-desktop/src/main/java/kst4contest/view/map/PathProfileChart.java` | `app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt` | - -Dazu: `MapHtmlResources` und `TileProxyServer` existieren nur einmal. Die Treffer für -`MapHtmlResources` und `StationMapView` in `core/src/test` und in `StationMapWindow.kt` sind -**Kommentare**, die beschreiben, woher eine Formulierung stammt — geprüft, nicht angenommen. Sie -bleiben stehen; sie dokumentieren Herkunft. - -## Prüfschwerpunkte - -Fünf Dinge, die diese Etappe stillschweigend brechen kann. Jedes bekommt seinen Test in der -Scheibe, der der Code gehört. - -1. **Der Entpreller muss nach dem Tausch noch entprellen.** `core` ruft `onUserListUpdated` bei - jeder Listenänderung; ohne Zusammenfassung zeichnet die Stationstabelle bei Contest-Last - dauernd neu. Fällt der Timer ganz aus, aktualisiert sie **nie** — und beides sieht im Build - gleich aus. → S1. -2. **Der Nachrichtenbereich der ausgewählten Station muss weiter folgen.** `FxRosterBinding` - speist ihn, und die Gleichheitsprüfung darin verhindert, dass eine unveränderte Liste ein - Änderungsereignis auslöst. Fällt sie weg, kämpft die Tabelle bei jedem Takt mit dem Bediener um - die ausgewählte Zeile. → S2. -3. **`tableSelection()` muss weiter `null` liefern, nicht werfen.** `ComposeChatInputActions` - liest die gelöschte Tabelle; die lebende Sendezeile ruft das bei jedem Schnipsel und jeder - Kurztaste. → S3a. -4. **Die Zeitstrahl-Kandidaten müssen Feld für Feld gleich bleiben.** Die zehn Werte werden heute - 1:1 umkopiert; eine vertauschte Reihenfolge im Konstruktor fällt nicht auf, weil fünf davon - Zahlen sind. → S3d. -5. **Die Anwendung muss ohne gestartetes JavaFX-Toolkit hochkommen.** Die Startmethode fällt erst - mit dem letzten Steuerelement; fällt sie zu früh oder zu spät, ist der Start kaputt - beziehungsweise bleibt eine tote Abhängigkeit. → S6. - ---- - -## S1 — Der Entpreller der Benutzerliste - -**Dateien:** -- Anlegen: `app-desktop/src/main/java/kst4contest/view/CoalescingTrigger.java` -- Anlegen: `app-desktop/src/test/java/kst4contest/view/CoalescingTriggerTest.java` -- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - (`onUserListUpdated`, das Feld `userListRefreshCoalescer`, `stopAnimation`, `shutdownRuntime`) - -**Schnittstellen:** -- Liefert: `new CoalescingTrigger(int delayMs, Runnable action)` mit `void trigger()` und - `void cancel()`. `trigger()` startet die Wartezeit neu; `action` läuft auf dem - AWT-Ereignisfaden. - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -```java -package kst4contest.view; - -import org.junit.jupiter.api.Test; - -import java.awt.EventQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * The coalescer behind the station list. - * - *

Untested while it ran on a JavaFX {@code PauseTransition}, and both ways of being wrong - * are invisible in a build: too eager and the table redraws on every message under contest - * load, never and the table stops updating altogether.

- */ -class CoalescingTriggerTest { - - @Test - void aBurstOfTriggersProducesOneRun() throws Exception { - AtomicInteger runs = new AtomicInteger(); - CountDownLatch ran = new CountDownLatch(1); - CoalescingTrigger trigger = new CoalescingTrigger(120, () -> { - runs.incrementAndGet(); - ran.countDown(); - }); - - // What a busy chat looks like: many updates, one intended redraw. - for (int i = 0; i < 25; i++) { - trigger.trigger(); - } - - assertTrue(ran.await(5, TimeUnit.SECONDS), "the burst never produced a run"); - Thread.sleep(300); - assertEquals(1, runs.get(), "a burst must coalesce into a single run"); - } - - @Test - void theActionRunsOnTheEventThread() throws Exception { - AtomicBoolean onEventThread = new AtomicBoolean(); - CountDownLatch ran = new CountDownLatch(1); - CoalescingTrigger trigger = new CoalescingTrigger(60, () -> { - onEventThread.set(EventQueue.isDispatchThread()); - ran.countDown(); - }); - - trigger.trigger(); - - assertTrue(ran.await(5, TimeUnit.SECONDS)); - assertTrue(onEventThread.get(), "the action redraws tables and must be on the UI thread"); - } - - @Test - void aLaterTriggerPostponesTheRun() throws Exception { - AtomicInteger runs = new AtomicInteger(); - CoalescingTrigger trigger = new CoalescingTrigger(250, runs::incrementAndGet); - - trigger.trigger(); - Thread.sleep(150); - trigger.trigger(); - Thread.sleep(150); - - // 300 ms have passed but the second trigger restarted the wait at 150 ms. - assertEquals(0, runs.get(), "a new trigger must restart the delay, not let the old one fire"); - } - - @Test - void cancellingBeforeTheDelayElapsesSuppressesTheRun() throws Exception { - AtomicInteger runs = new AtomicInteger(); - CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet); - - trigger.trigger(); - trigger.cancel(); - Thread.sleep(400); - - assertEquals(0, runs.get(), "a cancelled trigger must not run; shutdown relies on it"); - } - - @Test - void triggeringAgainAfterACancelStillWorks() throws Exception { - AtomicInteger runs = new AtomicInteger(); - CountDownLatch ran = new CountDownLatch(1); - CoalescingTrigger trigger = new CoalescingTrigger(80, () -> { - runs.incrementAndGet(); - ran.countDown(); - }); - - trigger.trigger(); - trigger.cancel(); - trigger.trigger(); - - assertTrue(ran.await(5, TimeUnit.SECONDS), "a cancel must not disable the trigger for good"); - assertEquals(1, runs.get()); - } -} -``` - -- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** - -```bash -./gradlew :app-desktop:test --tests '*CoalescingTriggerTest*' -``` - -Erwartung: Übersetzungsfehler, `CoalescingTrigger` existiert nicht. - -- [ ] **Schritt 3: Die Umsetzung schreiben** - -```java -package kst4contest.view; - -import javax.swing.Timer; -import java.util.Objects; - -/** - * Collapses a burst of requests into one run, a fixed delay after the last of them. - * - *

Replaces the {@code javafx.animation.PauseTransition} the station list used. A - * {@code javax.swing.Timer} fires on the AWT event thread, which is where the user - * interface lives, and {@code restart()} means exactly what {@code playFromStart()} meant. - * A PauseTransition would need a running JavaFX toolkit, which the application no longer - * has.

- */ -public final class CoalescingTrigger { - - private final Timer timer; - - /** - * @param delayMs how long to wait after the last request - * @param action runs on the user-interface thread once the delay elapses - */ - public CoalescingTrigger(final int delayMs, final Runnable action) { - Objects.requireNonNull(action, "action"); - this.timer = new Timer(delayMs, event -> action.run()); - this.timer.setRepeats(false); - } - - /** Requests a run, restarting the delay. */ - public void trigger() { - timer.restart(); - } - - /** Drops a pending run. Idempotent, and the trigger stays usable afterwards. */ - public void cancel() { - timer.stop(); - } -} -``` - -- [ ] **Schritt 4: Tests laufen lassen** - -```bash -./gradlew :app-desktop:test --tests '*CoalescingTriggerTest*' -``` - -Erwartung: 5 Tests grün. - -- [ ] **Schritt 5: `onUserListUpdated` umstellen** - -Das Feld `private Animation userListRefreshCoalescer;` wird -`private CoalescingTrigger userListRefreshCoalescer;`. Die Methode wird: - -```java - @Override - public void onUserListUpdated(String reason) { - uiDispatcher.runOnUi(() -> { - pendingUserListUpdateReason = reason; - - if (userListRefreshCoalescer == null) { - userListRefreshCoalescer = new CoalescingTrigger( - USER_LIST_REFRESH_DELAY_MS, - () -> { - forceChatMemberFilterRefresh(); - - if (composeMainWindowState != null) { - composeMainWindowState.getStations().forceRedraw(); - } - - refreshStationMapIfVisible(); - - System.out.println( - "KST4Capp, UI Update Trigger: " + pendingUserListUpdateReason); - }); - } - - userListRefreshCoalescer.trigger(); - }); - } -``` - -Der `tbl_chatMember.refresh()`-Aufruf fällt hier mit: er zeichnet eine Tabelle neu, die keine -Zeilen hat, und das Feld fällt in S3b. Dazu die Konstante bei den übrigen Konstanten der Klasse: - -```java - /** The delay the JavaFX PauseTransition used, kept so the feel does not change. */ - private static final int USER_LIST_REFRESH_DELAY_MS = 300; -``` - -- [ ] **Schritt 6: `shutdownRuntime` und `stopAnimation` nachziehen** - -In `shutdownRuntime` wird - -```java - stopAnimation(userListRefreshCoalescer); - userListRefreshCoalescer = null; -``` - -zu - -```java - if (userListRefreshCoalescer != null) { - userListRefreshCoalescer.cancel(); - userListRefreshCoalescer = null; - } -``` - -`private static void stopAnimation(Animation)` verliert damit seinen letzten Aufrufer und wird -gelöscht. Prüfen mit `grep -n "stopAnimation" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` -— sind weitere Aufrufer da, bleibt die Methode und nur dieser eine Aufruf wechselt. - -- [ ] **Schritt 7: Bauen und Testzahl prüfen** - -```bash -./gradlew clean build -grep -h "` und kopiert es sofort (`SelectedStationMessagesFeed.kt:23-26`), die -`ObservableList` wird also nie als beobachtbare Liste benutzt. Statt eines Listeners bekommt die -Klasse einen Rückruf. - -**Dateien:** -- Umbenennen und ändern: `app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java` - → `app-desktop/src/main/java/kst4contest/view/RosterMirror.java` -- Umbenennen und ändern: `app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java` - → `app-desktop/src/test/java/kst4contest/view/RosterMirrorTest.java` -- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` - -**Schnittstellen:** -- Liefert: `RosterMirror.mirror(ObservableRoster, UiDispatcher)` und - `RosterMirror.derived(UiDispatcher, Supplier>, ObservableRoster...)`, dazu - `List list()`, `void refresh()`, `void dispose()` und neu - `void onChanged(Consumer> listener)`. - -- [ ] **Schritt 1: Den bestehenden Test lesen, bevor etwas angefasst wird** - -```bash -cat app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java -``` - -Er deckt den Vertrag schon ab. Jede Zusicherung darin muss nach der Umstellung weiter gelten; -nur `ObservableList` wird zu `List`. Die Datei wird mit `git mv` umbenannt, damit die Geschichte -erhalten bleibt. - -- [ ] **Schritt 2: Den bestehenden No-Event-Test umschreiben und einen zweiten ergänzen** - -`ObservableRoster` ist eine **Schnittstelle**; die Tests benutzen `SimpleRoster` aus -`kst4contest.observe` als echte Umsetzung, nicht als Attrappe. Die bestehende Zusicherung -`anUnchangedResultProducesNoListChangeEvent` prüft heute über einen JavaFX-`ListChangeListener`. -Sie wird auf den Rückruf umgeschrieben — gleiche Aussage, neuer Weg: - -```java - @Test - void anUnchangedResultNotifiesNobody() { - SimpleRoster roster = new SimpleRoster<>(); - roster.add("DN9APW"); - RosterMirror mirror = RosterMirror.mirror(roster, new DirectUiDispatcher()); - - AtomicInteger notifications = new AtomicInteger(); - mirror.onChanged(rows -> notifications.incrementAndGet()); - - mirror.refresh(); - mirror.refresh(); - - /* - * The equality check is not an optimisation. The selected-station message pane - * replaces its rows on every notification, and the periodic refresh would fight - * the operator for the selected row on every tick. - */ - assertEquals(0, notifications.get(), - "an unchanged station list must not make the message pane drop its selection"); - } -``` - -Dazu neu, weil bisher nichts prüft, dass eine **echte** Änderung ankommt — der Fall, in dem der -Nachrichtenbereich stehen bliebe: - -```java - @Test - void aRealChangeNotifiesOnceWithTheNewContent() { - SimpleRoster roster = new SimpleRoster<>(); - roster.add("DN9APW"); - RosterMirror mirror = RosterMirror.mirror(roster, new DirectUiDispatcher()); - - List> seen = new java.util.ArrayList<>(); - mirror.onChanged(seen::add); - - roster.add("DO5AMF"); - - assertEquals(1, seen.size(), "a real change must notify exactly once"); - assertEquals(List.of("DN9APW", "DO5AMF"), seen.get(0)); - } -``` - -Die übrigen fünf Zusicherungen der Datei bleiben inhaltlich gleich; nur -`ObservableList`/`List.copyOf(binding.list())` wird `List.copyOf(mirror.list())` und der -`javafx.collections`-Import fällt. - -- [ ] **Schritt 3: Laufen lassen und den Fehlschlag sehen** - -```bash -./gradlew :app-desktop:test --tests '*RosterMirrorTest*' -``` - -Erwartung: Übersetzungsfehler, `RosterMirror` existiert nicht. - -- [ ] **Schritt 4: Umbenennen und umstellen** - -```bash -git mv app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java \ - app-desktop/src/main/java/kst4contest/view/RosterMirror.java -git mv app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java \ - app-desktop/src/test/java/kst4contest/view/RosterMirrorTest.java -``` - -In `RosterMirror.java`: die beiden `javafx.collections`-Importe fallen, -`private final ObservableList mirror = FXCollections.observableArrayList();` wird -`private final List mirror = new ArrayList<>();`, `list()` liefert -`Collections.unmodifiableList(mirror)`, und `applyNow()` endet statt mit `mirror.setAll(computed)` -mit: - -```java - if (mirror.equals(computed)) { - return; - } - - mirror.clear(); - mirror.addAll(computed); - - List snapshot = List.copyOf(mirror); - for (Consumer> listener : changeListeners) { - listener.accept(snapshot); - } -``` - -Der lange Kommentar über der Gleichheitsprüfung **bleibt wörtlich erhalten** — er trägt die -Begründung und den Hinweis auf `ChatMember.equals(ChatMember)` als Überladung. Nur die Erwähnung -von `setAll` und `TableView` wird auf den Rückruf umformuliert. Dazu neu: - -```java - private final List>> changeListeners = new ArrayList<>(); - - /** - * Registers a listener for real content changes. - * - *

Replaces the {@code ListChangeListener} a {@code TableView} used to attach. It is - * called on the user-interface thread with an immutable snapshot, and only when the - * content actually changed.

- */ - public void onChanged(Consumer> listener) { - changeListeners.add(Objects.requireNonNull(listener, "listener")); - } -``` - -Die Klassen-Javadoc verliert ihre `TableView`- und `ObservableList`-Sätze und benennt stattdessen -die Compose-Senke. - -- [ ] **Schritt 5: Die Aufrufstellen nachziehen** - -In `Kst4ContestApplication`: -- `FxRosterBinding` → `RosterMirror` überall (Feld `rosterBindings`, `mirrorOf`, `derivedBinding`, - `chatMemberListBinding`, `selectedCallSignInfoMessageBinding`, der `dispose`-Aufruf in - `shutdownRuntime`). -- `private javafx.collections.ObservableList mirrorOf(...)` wird - `private java.util.List mirrorOf(...)`. -- Der `ListChangeListener` bei `:1402` wird der Rückruf: - -```java - selectedCallSignInfoMessageBinding.onChanged(rows -> { - if (selectedStationMessagesFeed != null) { - selectedStationMessagesFeed.push(rows); - } - }); -``` - -- Die drei verbleibenden `ObservableList`-Erwähnungen in `applySelectedCallSignInfoFilter`, bei - `:3431` (`insertTextSnippet`) und `:1905` (`refreshShortcutButtons`) werden `List`. Beide - letzteren fallen in S3 ganz; hier nur den Typ anpassen, damit es übersetzt. - -- [ ] **Schritt 6: Bauen, Testzahl prüfen, committen** - -```bash -./gradlew clean build -grep -h "The JavaFX station table it used to consult never had rows after the window was - * disconnected, so this always answered null; it must keep doing that rather than throw - * once the table is gone.

- */ -class ComposeChatInputActionsTest { - - @Test - void thereIsNoTableSelectionAnyMore() { - ComposeChatInputActions actions = new ComposeChatInputActions(new Kst4ContestApplication()); - - assertNull(actions.tableSelection(), "the station table is gone; this must answer null"); - } - - @Test - void anUnbuiltRuntimeHasNoPanelOrScoreSelection() { - ComposeChatInputActions actions = new ComposeChatInputActions(new Kst4ContestApplication()); - - assertNull(actions.panelSelection()); - assertNull(actions.scoreSelection(), "no chat controller yet, so no score selection"); - } -} -``` - -**Achtung:** `new Kst4ContestApplication()` erzeugt die Steuerelementfelder. Solange welche -übrig sind, braucht dieser Test ein gestartetes JavaFX-Toolkit und schlägt sonst mit -`Toolkit not initialized` fehl. Darum steht der Test hier und nicht früher: nach S3a ist -`txt_chatMessageUserInput` weg, und nach S3d ist kein Steuerelement mehr im Konstruktor. Schlägt -er in dieser Scheibe noch fehl, **nicht** das Toolkit im Test starten — den Test nach S3d -verschieben und das in der Commit-Nachricht festhalten. - -- [ ] **Schritt 2: Laufen lassen** - -```bash -./gradlew :app-desktop:test --tests '*ComposeChatInputActionsTest*' -``` - -- [ ] **Schritt 3: Den JavaFX-Zweig der Sendezeile löschen** - -Löschen: die Felder `txt_chatMessageUserInput`, `sendButton`, `lastAutoPreparedSendText`, -`lastAutoPreparedCqTargetCallsign`, `lastAutoPreparedCqTargetCategory`, und die Methoden -`prepareCqTextForCallsign` (beide Überladungen), `canOverwriteSendTextWithAutoPreparedText`, -`appendResolvedMessageText`, `insertTextSnippet`, `resolveSnippetIndex` sowie der -`EventHandler`-`handle`-Block um `:2309`, der nur diese Felder beschreibt. - -Vor jeder Löschung die Aufrufer prüfen: - -```bash -P=app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -for m in prepareCqTextForCallsign canOverwriteSendTextWithAutoPreparedText \ - appendResolvedMessageText insertTextSnippet resolveSnippetIndex; do - echo "--- $m ---"; grep -rn "\b$m\b" app-desktop/src core/src --include="*.java" --include="*.kt" -done -``` - -Hat eine davon einen Aufrufer außerhalb der Löschliste, bleibt sie stehen und verliert nur ihren -JavaFX-Teil — die „gemischte Methode an ihrer Naht trennen", die der Lebendbefund als zentrale -Falle beschreibt. In `focusChatMemberAndPrepareCq` fällt nur der `prepareCqTextForCallsign`-Aufruf; -die Methode selbst ist lebend (`:924`, `:1584`) und setzt -`selectedCallSignInfoStageChatMember`, was bleiben muss. Bei `:4110` fällt die Zeile -`prepareCqTextForCallsign(receiverCallsign, message.getChatCategory(), false);` samt dem Kommentar -`// Sync with JavaFX state just in case`; `chatInput.prepareCq(...)` darüber bleibt. - -- [ ] **Schritt 4: Bauen, Testzahl prüfen, GUI-ABNAHME** - -```bash -./gradlew clean build && ./gradlew :app-desktop:run --args="--profile default" -``` - -Zu prüfen: Station auswählen → `/cq RUFZEICHEN ` erscheint vorbelegt im Eingabefeld und der Fokus -liegt darin; eigenen Text tippen, dann eine andere Station wählen → der getippte Text wird -**nicht** überschrieben; Schnipsel über Strg+1 bis Strg+0; die Kurztastenknöpfe; eine private -Nachricht senden. - -- [ ] **Schritt 5: Committen** - -```bash -git add app-desktop/src/main/java/kst4contest/view/ app-desktop/src/test/java/kst4contest/view/ -git commit -m "Delete the shadow send line; the Compose chat input is the only one left" -- \ - app-desktop/src/main/java/kst4contest/view/ app-desktop/src/test/java/kst4contest/view/ -``` - -### S3b — Die Stationstabelle - -**Belegt:** `onUserListUpdated` rief nur `refresh()` darauf, nie `setItems` — die Tabelle hat -keine Zeilen. `getEffectiveSelectedChatMember` hat sie als mittlere von drei Quellen, und dieser -Zweig kann nie feuern. - -**Dateien:** `Kst4ContestApplication.java`, `ComposeChatInputActions.java` - -- [ ] **Schritt 1: `tbl_chatMember` und die Zellenfabriken löschen** - -Löschen: das Feld `tbl_chatMember`, die Methoden `refreshChatMemberTable`, -`createBandStatusCellFactory`, `applyQrgUiFormatting`, `applyTruncatedTextCells`, -`focusChatMemberAndPrepareCq`s Tabellenhälfte (`:950`–`:955`), `moveSelectedTableEntry`, -`markOperatorChatMemberSelectionIntent` samt dem Feld -`operatorInitiatedChatMemberSelectionChange`, und der Tabellenzweig in -`getEffectiveSelectedChatMember`. Jede davon vorher wie in S3a auf Aufrufer prüfen. - -`getEffectiveSelectedChatMember` behält zwei Quellen: - -```java - private ChatMember getEffectiveSelectedChatMember() { - if (selectedCallSignInfoStageChatMember != null) { - return selectedCallSignInfoStageChatMember; - } - - /* - * The JavaFX station table used to sit between these two. It never received rows - * after the window was disconnected, so this branch could not fire. - */ - if (chatcontroller != null - && chatcontroller.getScoreService() != null - && chatcontroller.getScoreService().getSelectedChatMember() != null) { - return chatcontroller.getScoreService().getSelectedChatMember(); - } - - return null; - } -``` - -- [ ] **Schritt 2: `ComposeChatInputActions.tableSelection()` nachziehen** - -```java - @Override - public ChatMember tableSelection() { - /* - * The JavaFX station table is gone. Kept rather than removed from the interface - * because ChatInputState's three-step lookup is the documented selection order and - * a Compose station table may fill this in again. - */ - return null; - } -``` - -- [ ] **Schritt 3: Bauen, Testzahl prüfen, committen** - -```bash -./gradlew clean build -grep -h "The Compose chat input reads the own QRG from preferences when it needs it, so there - * is no follower left to attach. The Compose settings still call this through the shared - * interface, and an empty body is honest about there being nothing to do.

- */ - @Override - public void applyOwnQrgFollower(boolean enabled) { - // intentionally empty: no JavaFX text field left to follow - } -``` - -**Vorher prüfen**, dass die Compose-Sendezeile die eigene QRG wirklich selbst liest: - -```bash -grep -rn "getMYQRGFirstCat\|ownQrg" app-desktop/src/main/kotlin/kst4contest/view/compose/ | head -``` - -Liest sie sie **nicht**, ist die No-op falsch — dann **anhalten und Marc fragen**, weil die -MYQRG-Knöpfe der Sendezeile sonst leer einfügen. - -Ebenso `refreshShortcutButtonsFromSettings`, das `refreshShortcutButtons` ruft: dieselbe -Behandlung, mit derselben Vorprüfung gegen `ChatInputState`. - -- [ ] **Schritt 2: Bauen, Testzahl prüfen, committen** - -```bash -./gradlew clean build -git add app-desktop/src/main/java/kst4contest/view/ -git commit -m "Delete the shadow QRG, QTF and shortcut controls" -- \ - app-desktop/src/main/java/kst4contest/view/ -``` - -### S3d — Der Zeitstrahl-DTO — **GUI-ABNAHME** - -`TimelineView.CandidateEvent` ist ein reines Zwischen-DTO: `buildTimelinePriorityCandidateEvents` -baut es, und `updateTimelineVisuals` kopiert es Feld für Feld in -`kst4contest.view.compose.TimelineCandidate`. Der Builder baut künftig direkt den Compose-Typ, und -die Umkopierschleife fällt — ein Übersetzungsschritt weniger. - -**Dateien:** `Kst4ContestApplication.java` - -- [ ] **Schritt 1: Die Feldreihenfolge festnageln, bevor etwas umgebaut wird** - -```bash -grep -n "class TimelineCandidate" -A14 app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineState.kt -sed -n '1645,1660p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java -``` - -Die zehn Werte in der heutigen Umkopierschleife sind die Sollreihenfolge: -`callSignRaw`, `displayCallSign`, `preferredChatCategory`, `timeUntilMs`, `minuteBucket`, -`laneIndex`, `targetAzimuth`, `score`, `opportunityPotentialPercent`, `tooltipText`. Fünf davon -sind Zahlen, eine Vertauschung fällt nicht auf — darum zuerst der Test. - -- [ ] **Schritt 2: Den Test schreiben, der die Reihenfolge festhält** - -In `app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineCandidateFieldsTest.kt`: - -```kotlin -package kst4contest.view.compose - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -/** - * Pins the field order of the timeline candidate. - * - * The JavaFX builder produced its own DTO and copied it across field by field; the builder - * now constructs this type directly. Five of the ten values are numbers, so a swapped pair - * would compile, run, and quietly draw the wrong marker at the wrong minute. - */ -class TimelineCandidateFieldsTest { - - @Test - fun theTenValuesKeepTheirPositions() { - val category = ChatCategory.values().first() - - val candidate = TimelineCandidate( - "DL1ABC", "DL1ABC/P", category, 90_000L, 3, 1, 215.0, 42.5, 77, "tooltip", - ) - - assertEquals("DL1ABC", candidate.callSignRaw) - assertEquals("DL1ABC/P", candidate.displayCallSign) - assertEquals(category, candidate.preferredChatCategory) - assertEquals(90_000L, candidate.timeUntilMs) - assertEquals(3, candidate.minuteBucket) - assertEquals(1, candidate.laneIndex) - assertEquals(215.0, candidate.targetAzimuth) - assertEquals(42.5, candidate.score) - assertEquals(77, candidate.opportunityPotentialPercent) - assertEquals("tooltip", candidate.tooltipText) - } -} -``` - -Geprüft, bevor der Block geschrieben wurde (`TimelineState.kt:39-50`): die Reihenfolge ist -`callSignRaw: String`, `displayCallSign: String`, `preferredChatCategory: ChatCategory?`, -`timeUntilMs: Long`, `minuteBucket: Int`, `laneIndex: Int`, `targetAzimuth: Double`, -`score: Double`, `opportunityPotentialPercent: Int`, `tooltipText: String?`. **Die dritte Stelle -ist eine `ChatCategory`, keine Zahl** — ein Punkt, an dem eine Vertauschung mit `minuteBucket` -oder `laneIndex` nicht einmal übersetzen würde, während eine Vertauschung der drei Zahlen -untereinander lautlos durchgeht. Import: `kst4contest.model.ChatCategory`. - -- [ ] **Schritt 3: Laufen lassen — er muss grün sein** - -```bash -./gradlew :app-desktop:test --tests '*TimelineCandidateFieldsTest*' -``` - -Dieser Test ist bewusst **kein** RED-Test: er beschreibt bestehendes Verhalten, das der Umbau -erhalten muss. Wird er rot, stimmt die angenommene Reihenfolge nicht und der Umbau wäre falsch -geworden — dann die Erwartungen an die Deklaration anpassen, nicht die Deklaration an den Test. - -- [ ] **Schritt 4: Den Builder umstellen** - -`buildTimelinePriorityCandidateEvents()` gibt `List` zurück, die beiden -`out.add(new TimelineView.CandidateEvent(...))`-Stellen bauen `new TimelineCandidate(...)` mit -derselben Werteliste, der `Comparator` am Ende wird -`.comparingInt(TimelineCandidate::getMinuteBucket).thenComparingInt(TimelineCandidate::getLaneIndex)` -— die aus Kotlin erzeugten Zugriffsmethoden prüfen, ein `data class` liefert `getMinuteBucket()`. -In `updateTimelineVisuals` fällt die Umkopierschleife, und `timelineFeed.push(...)` bekommt die -Liste direkt. - -- [ ] **Schritt 5: `TimelineView.java` löschen** - -```bash -grep -rn "kst4contest.view.TimelineView\|TimelineView\." app-desktop/src core/src --include="*.java" --include="*.kt" | grep -v "compose" -git rm app-desktop/src/main/java/kst4contest/view/TimelineView.java -``` - -Der `grep` muss leer sein. **Nur die Java-Datei**, nicht `compose/TimelineView.kt`. - -- [ ] **Schritt 6: Bauen, GUI-ABNAHME, committen** - -```bash -./gradlew clean build && ./gradlew :app-desktop:run --args="--profile default" -``` - -Zu prüfen: Sked anlegen → Marke erscheint auf dem Zeitstrahl, an der richtigen Minute, mit dem -richtigen Hinweistext; Prioritätskandidaten erscheinen; Rotordrehung ändert die Darstellung. - -```bash -git add -A app-desktop/src/main/java/kst4contest/view/ app-desktop/src/test/kotlin/kst4contest/view/compose/ -git commit -m "Build timeline candidates as the Compose type and delete the JavaFX timeline" -- \ - app-desktop/src/main/java/kst4contest/view/ app-desktop/src/test/kotlin/kst4contest/view/compose/ -``` - ---- - -## S4 — Die Kartendateien und der Kachel-Umleitungsserver - -**Dateien zum Löschen:** -- `app-desktop/src/main/java/kst4contest/view/map/StationMapView.java` -- `app-desktop/src/main/java/kst4contest/view/map/StationMapBridge.java` -- `app-desktop/src/main/java/kst4contest/view/map/MapHtmlResources.java` -- `app-desktop/src/test/java/kst4contest/test/MapHtmlResourcesContractTest.java` -- `app-desktop/src/main/java/kst4contest/view/map/PathProfileChart.java` (**nicht** die `.kt`) -- `app-desktop/src/main/java/kst4contest/view/MessageTextTableCell.java` -- `app-desktop/src/main/java/kst4contest/view/TruncatedTextTableCell.java` -- `app-desktop/src/main/resources/web/leaflet/leaflet.css` und `leaflet.js` -- `core/src/main/java/kst4contest/view/map/TileProxyServer.java` - -- [ ] **Schritt 1: Prüfen, dass nichts mehr darauf zeigt** - -```bash -for c in StationMapView StationMapBridge MapHtmlResources MessageTextTableCell \ - TruncatedTextTableCell TileProxyServer; do - echo "--- $c ---" - grep -rn "\b$c\b" app-desktop/src core/src --include="*.java" --include="*.kt" \ - | grep -v "/$c\.java:" | grep -vE "^\S+: *\*|^\S+: *//" -done -echo "--- PathProfileChart, nur die Java-Nutzer ---" -grep -rn "kst4contest.view.map.PathProfileChart" app-desktop/src core/src -echo "--- Leaflet-Ressourcen ---" -grep -rn "leaflet" app-desktop/src/main --include="*.java" --include="*.kt" -``` - -Jede Ausgabe muss leer sein oder ein Kommentar, der nur die Herkunft einer Formulierung -beschreibt. Kommentare bleiben stehen. Zeigt echter Code noch hin, **anhalten**: dann ist etwas -lebend, was dieser Plan für tot hält, und das gehört in den Lebendbefund, bevor es gelöscht wird. - -- [ ] **Schritt 2: Löschen** - -```bash -git rm app-desktop/src/main/java/kst4contest/view/map/StationMapView.java \ - app-desktop/src/main/java/kst4contest/view/map/StationMapBridge.java \ - app-desktop/src/main/java/kst4contest/view/map/MapHtmlResources.java \ - app-desktop/src/test/java/kst4contest/test/MapHtmlResourcesContractTest.java \ - app-desktop/src/main/java/kst4contest/view/map/PathProfileChart.java \ - app-desktop/src/main/java/kst4contest/view/MessageTextTableCell.java \ - app-desktop/src/main/java/kst4contest/view/TruncatedTextTableCell.java \ - core/src/main/java/kst4contest/view/map/TileProxyServer.java -git rm -r app-desktop/src/main/resources/web/leaflet/ -``` - -- [ ] **Schritt 3: Den `jdk.jsobject`-Eintrag im Build prüfen** - -`app-desktop/build.gradle.kts` listet in `modules(...)` `jdk.jsobject` mit der Begründung „carries -netscape.javascript for the map bridge". Die Brücke fällt in diesem Schritt: - -```bash -grep -rn "netscape.javascript" app-desktop/src core/src -``` - -Ist das leer, fällt `"jdk.jsobject"` aus der `modules(...)`-Liste und der Teilsatz aus dem -Kommentar darüber. `jdk.unsupported` **bleibt** — der Kommentar nennt dafür die Marlin-Begründung, -die mit JavaFX fällt, aber `sun.misc.Unsafe` wird auch von anderen Abhängigkeiten benutzt; das zu -entscheiden gehört nicht in diese Etappe. Den Kommentar entsprechend berichtigen. - -- [ ] **Schritt 4: Bauen, Testzahl prüfen, committen** - -```bash -./gradlew clean build -grep -h "&1 | grep -iE "skip|assum|javafx|PASS|FAIL" -``` - -Etappe 3 hat die `derive`-Werte als Daten eingecheckt, genau damit dieser Test das Entfernen -überlebt. Ruft er noch `com.sun.javafx.util.Utils` per Reflexion, muss der Zweig jetzt sauber -übersprungen werden, statt stillschweigend gegen sich selbst zu prüfen. Fällt er aus oder wird er -grün, ohne etwas zu prüfen, **anhalten und Marc sagen** — das wäre der Verlust des Farbpins, den -die Spezifikation ausdrücklich schützt. - -- [ ] **Schritt 6: GUI-ABNAHME, die letzte** - -```bash -./gradlew :app-desktop:run -``` - -Der vollständige Durchgang, weil ab hier kein JavaFX mehr einspringt: Profilwähler, Verbinden, -Stationsliste, Auswahl, private Nachricht, Zeitstrahl, Sked, Karte, Monitorfenster, -Einstellungsfenster, Entwurf hell/dunkel, alle Dialoge, Profilwechsel, Beenden über Menü und -Fensterkreuz, Spaltenbreiten über einen Neustart hinweg. - -- [ ] **Schritt 7: Committen** - -```bash -git add -A app-desktop/build.gradle.kts gradle/libs.versions.toml app-desktop/src -git commit -m "Remove the org.openjfx dependency; KST4Contest no longer uses JavaFX" -- \ - app-desktop/build.gradle.kts gradle/libs.versions.toml app-desktop/src -``` - ---- - -## S7 — Nachdokumentation - -- [ ] **Schritt 1: Die Dokumente nachziehen** - -```bash -grep -rn "JavaFX\|javafx" AGENTS.md CLAUDE.md docs/PROJECT_CONTEXT.md README.md 2>/dev/null -``` - -Zu berichtigen, weil es nach S6 falsch ist: `AGENTS.md` nennt KST4Contest einen „Java/JavaFX -desktop client" (Zeile 7) und „Java 21 / JavaFX 21.x" unter „Build and verification"; dazu die -Sätze über die JavaFX-`ObservableList` und die WebView-/Leaflet-Umgehung, die mit S4 ihren -Gegenstand verloren hat. `docs/PROJECT_CONTEXT.md` und `README.md` entsprechend. - -Nicht zu berichtigen: die Spezifikationen und Pläne unter `docs/superpowers/` — die beschreiben -den Weg und sollen den Vorzustand nennen. - -- [ ] **Schritt 2: Lebendbefund abschließen** - -Abschlussnotiz für Etappe 6 oben in `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`: -der Beweis aus S6 Schritt 4, die endgültige Testzahl, die Zeilenzahl von -`Kst4ContestApplication`, und was von den neun Schattenfeldern wie ersetzt wurde. - -- [ ] **Schritt 3: Dokumentationswirkung einschätzen** - -Nach `AGENTS.md` („Documentation and durable project context") eine kurze Einschätzung, ob -nutzersichtbares Verhalten betroffen ist. Betroffen sind die Dialoge (Compose statt JavaFX, andere -Knopfreihenfolge auf macOS, keine Symbole mehr) und die Karte (Compose-Canvas statt WebView). Die -betreffenden Abschnitte unter `github_docs/` in **beiden** Sprachen suchen und abgleichen; -veraltete Bildschirmfotos benennen, **nicht** erfinden. - -```bash -grep -rln "WebView\|Leaflet\|JavaFX" github_docs/ | head -``` - -- [ ] **Schritt 4: Committen** - -```bash -git add AGENTS.md docs/PROJECT_CONTEXT.md README.md docs/superpowers/notes/ github_docs/ -git commit -m "Update the binding docs and the manual for a KST4Contest without JavaFX" -- \ - AGENTS.md docs/PROJECT_CONTEXT.md README.md docs/superpowers/notes/ github_docs/ -``` - ---- - -## Was unverändert bleibt - -- Die Fachlogik in `core` und sämtliche Protokollformate. `core` verliert nur `TileProxyServer`. -- `UiDispatcher`, `AwtUiDispatcher`, `DirectUiDispatcher`. -- `selectedCallSignInfoStageChatMember` und `ComposeChatInputActions.panelSelection()`. -- `JavaFxStylesheet.kt` samt eingefrorener `derive`-Tabelle und `JavaFxStylesheetTest`. -- `macOsConnectionStateMenuTitle` und sein Test. -- Die 91 MP3-Dateien und die CW-Ausgabe in `PlayAudioUtils`. -- `StationMapClusterer` und `StationMapStatusText` in `core`. -- Verpackungs- und Vertriebsidentität: `packageName`, `bundleID`, der `kst4contest`-Paketname. - -## Bekannte Fallstricke - -- **`ComposeStationMapDrawingTest` ist flaky.** Bei einem roten Lauf zuerst isoliert nachprüfen. -- **PMD/SpotBugs** melden rund 4448 vorbestehende Befunde und brechen den Build nicht. Nur neue - melden. -- **Vier Klassennamen sind doppelt belegt.** Siehe die Tabelle oben. `git rm` nach dem bloßen - Namen löscht die lebende Compose-Fassung. -- **`new Kst4ContestApplication()` in einem Test** braucht ein gestartetes JavaFX-Toolkit, solange - noch ein Steuerelementfeld im Konstruktor steht. Das ist der Grund für die Reihenfolge in S3. -- **Die Testzahl bewegt sich in beide Richtungen:** S4 senkt sie (gelöschter Kartentest), S5 hebt - sie (die Testdatei wird erstmals ausgeführt). Jede Änderung in der Commit-Nachricht nennen. diff --git a/docs/superpowers/plans/2026-10-04-mehrsprachigkeit.md b/docs/superpowers/plans/2026-10-04-mehrsprachigkeit.md deleted file mode 100644 index 20e07272..00000000 --- a/docs/superpowers/plans/2026-10-04-mehrsprachigkeit.md +++ /dev/null @@ -1,2345 +0,0 @@ -# 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 -- `). 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, other: Set): Set` -- Liefert: `fun orphanKeys(base: Set, other: Set): Set` -- 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): Map>` - -- [ ] **Schritt 1: Das Modul anlegen** - -`settings.gradle.kts` um eine Zeile ergänzen, neben den bestehenden `include`-Aufrufen: - -```kotlin -/* - * 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`: - -```kotlin -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** - -```kotlin -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** - -```bash -./gradlew :i18n-generator:test -``` - -Erwartung: Übersetzungsfehler, `missingKeys` existiert nicht. - -- [ ] **Schritt 4: Die Umsetzung schreiben** - -```kotlin -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, other: Set): Set = base - other - -/** Keys a translation has and the base does not. Not allowed: a contributor's typo. */ -fun orphanKeys(base: Set, other: Set): Set = 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): Map> = - 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** - -```bash -./gradlew :i18n-generator:test -``` - -Erwartung: 11 Tests grün. - -- [ ] **Schritt 6: Bauen, Testzahl prüfen, committen** - -```bash -./gradlew clean build -grep -h ")` -- 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** - -```kotlin -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** - -```bash -./gradlew :i18n-generator:test --tests '*TranslationFilesTest*' -``` - -- [ ] **Schritt 3: Die Umsetzung schreiben** - -```kotlin -package kst4contest.i18n - -import java.io.File -import java.util.Properties - -/** One language's texts. */ -data class Translation(val language: String, val entries: Map) - -/** 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 { - val seen = mutableSetOf() - val twice = mutableSetOf() - - 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** - -```bash -./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): 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** - -```kotlin -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) = - 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** - -```bash -./gradlew :i18n-generator:test --tests '*StringsSourceTest*' -``` - -- [ ] **Schritt 3: Die Umsetzung schreiben** - -```kotlin -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): 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 = 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> = 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** - -```bash -./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, val coverage: List)` -- Liefert: `object GeneratorMain { @JvmStatic fun main(args: Array) }` — zwei - Argumente: Eingabe- und Ausgabeverzeichnis. Wirft `TranslationException`, womit die - Gradle-Aufgabe mit der Meldung des Erzeugers abbricht. - -- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** - -```kotlin -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 { - 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** - -```bash -./gradlew :i18n-generator:test --tests '*GenerationTest*' -``` - -- [ ] **Schritt 3: Die Umsetzung schreiben** - -```kotlin -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, val coverage: List) - -/** - * 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() - - 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) { - require(args.size == 2) { "usage: GeneratorMain " } - - 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`: - -```kotlin - @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: - -```kotlin - 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: - -```kotlin -/** - * 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, -) { - 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`: - -```properties -# The base language. Every key and every argument count comes from this file. -# To contribute a language: copy this file to strings_.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`: - -```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: - -```kotlin -/* - * 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** - -```bash -./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: - -```bash -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** - -```bash -./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']*',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. - -```bash -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** - -```kotlin -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** - -```bash -./gradlew :app-desktop:test --tests '*LocaleChoiceTest*' -``` - -```kotlin -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** - -```bash -./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` — 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** - -```kotlin -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** - -```bash -./gradlew :app-desktop:test --tests '*LanguageStoreTest*' -``` - -- [ ] **Schritt 3: Den Store schreiben** - -```kotlin -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: - -```kotlin -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() - - 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: - -```kotlin - 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`: - -```java - /** - * 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 = ""; -``` - -2. Getter und Setter nach dem Muster der Nachbarn, `null` wird `""`. -3. Ein `doc.createElement("guiOptions_language")`-Block im Schreibpfad neben - `guiOptions_paletteOverridesDay`. -4. 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** - -```java -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: - -```kotlin -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: - -```kotlin - 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`: - -```java - 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. - -```java -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 underLocale(Locale locale, java.util.function.Supplier 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** - -```java -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 stringFieldsUnder(Locale locale) throws Exception { - Locale previous = Locale.getDefault(); - try { - Locale.setDefault(locale); - ChatPreferences prefs = new ChatPreferences(); - Map 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 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** - -```kotlin -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 = - 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(), - 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`: - -```kotlin - 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: - -```kotlin - 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: - -```kotlin -fun mainMenuModel( - state: MainMenuState, - actions: MainMenuActions, - settingsWindowOpen: Boolean, - monitorWindowOpen: Boolean, - strings: Strings, -): List -``` - -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** - -```bash -./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** - -```bash -./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_.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** - -```bash -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.