diff --git a/.github/workflows/aur-nightly-git.yml b/.github/workflows/aur-nightly-git.yml index a27628f3..92d0947a 100644 --- a/.github/workflows/aur-nightly-git.yml +++ b/.github/workflows/aur-nightly-git.yml @@ -41,8 +41,8 @@ jobs: - name: Compute pkgver id: ver run: | - BASE=$(grep -m1 '' pom.xml \ - | sed 's/.*\(.*\)<\/version>.*/\1/' \ + BASE=$(grep -m1 '^version=' gradle.properties \ + | cut -d= -f2 \ | sed 's/[-.]nightly//') GIT_PKGVER="${BASE}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)" diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index e26aac00..fbea1440 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -107,9 +107,9 @@ jobs: sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest-bin/PKGBUILD sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest/PKGBUILD - # kst4contest-git: Basis aus pom.xml, Suffix aus git - BASE_VER=$(grep -m1 '' pom.xml \ - | sed 's/.*\(.*\)<\/version>.*/\1/' | sed 's/[-.]nightly//') + # kst4contest-git: Basis aus gradle.properties, Suffix aus git + BASE_VER=$(grep -m1 '^version=' gradle.properties \ + | cut -d= -f2 | sed 's/[-.]nightly//') GIT_PKGVER="${BASE_VER}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)" sed -i "s/^pkgver=.*/pkgver=${GIT_PKGVER}/" packaging/aur/kst4contest-git/PKGBUILD diff --git a/.github/workflows/docs-pdf.yml b/.github/workflows/docs-pdf.yml index aae3a02c..36ec1144 100644 --- a/.github/workflows/docs-pdf.yml +++ b/.github/workflows/docs-pdf.yml @@ -32,7 +32,7 @@ jobs: if [[ "${{ github.ref }}" == refs/tags/* ]]; then VERSION="${{ github.ref_name }}" else - VERSION="$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/')-${GITHUB_SHA::7}" + VERSION="$(grep -m1 '^version=' gradle.properties | cut -d= -f2)-${GITHUB_SHA::7}" fi echo "DOC_VERSION=$VERSION" >> "$GITHUB_ENV" diff --git a/.github/workflows/nightly-artifacts.yml b/.github/workflows/nightly-artifacts.yml index 73fc3b06..1bac8606 100644 --- a/.github/workflows/nightly-artifacts.yml +++ b/.github/workflows/nightly-artifacts.yml @@ -5,12 +5,15 @@ on: branches: - main paths: - - "src/**" - "packaging/icons/**" - "packaging/macos/**" - - "pom.xml" - - "mvnw" - - "mvnw.cmd" + - "core/**" + - "app-desktop/**" + - "*.gradle.kts" + - "gradle.properties" + - "gradle/**" + - "gradlew" + - "gradlew.bat" - ".github/workflows/nightly-artifacts.yml" workflow_dispatch: @@ -33,8 +36,7 @@ jobs: - name: Resolve nightly version info shell: pwsh run: | - $xml = [xml](Get-Content pom.xml) - $version = $xml.project.version + $version = (Select-String -Path gradle.properties -Pattern '^version=').Line.Split('=')[1] $shortSha = "${{ github.sha }}".Substring(0, 7) Add-Content -Path $env:GITHUB_ENV -Value "VERSION=$version" Add-Content -Path $env:GITHUB_ENV -Value "SHORT_SHA=$shortSha" @@ -53,27 +55,26 @@ jobs: - name: Build JAR and copy runtime dependencies shell: pwsh run: | - .\mvnw.cmd -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs + .\gradlew.bat -S :app-desktop:collectRuntime $jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $jar) { throw "No project JAR produced" } - Copy-Item $jar.FullName target/dist-libs/app.jar + Copy-Item $jar.FullName app-desktop/build/dist-libs/app.jar - name: Build app-image with jpackage shell: pwsh run: | New-Item -ItemType Directory -Force -Path dist | Out-Null - $addModules = & java packaging/AddModules.java + $addModules = (Select-String -Path gradle.properties -Pattern '^jpackageAddModules=').Line.Split('=')[1] if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" } jpackage ` --type app-image ` --name praktiKST ` --icon packaging/icons/kst4contest.ico ` - --input target/dist-libs ` + --input app-desktop/build/dist-libs ` --main-jar app.jar ` - --main-class kst4contest.view.Kst4ContestApplication ` - --module-path target/dist-libs ` + --main-class kst4contest.view.Main ` --add-modules $addModules ` --dest dist @@ -102,7 +103,7 @@ jobs: - name: Resolve nightly version info run: | - VERSION=$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/') + VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2) SHORT_SHA="${GITHUB_SHA::7}" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" @@ -112,31 +113,30 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Build JAR and copy runtime dependencies run: | - ./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 + ./gradlew -S :app-desktop:collectRuntime - name: Build app-image with jpackage run: | mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" + ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" jpackage \ --type app-image \ --name KST4Contest \ --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ + --input app-desktop/build/dist-libs \ --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ + --main-class kst4contest.view.Main \ --add-modules "$ADD_MODULES" \ --dest dist - name: Create AppDir metadata run: | + mkdir -p target rm -rf target/KST4Contest.AppDir cp -a dist/KST4Contest target/KST4Contest.AppDir @@ -163,6 +163,7 @@ jobs: - name: Build AppImage run: | + mkdir -p target wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage chmod +x target/appimagetool.AppImage APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir "dist/${ASSET_BASENAME}-linux-x86_64.AppImage" @@ -184,7 +185,7 @@ jobs: - name: Resolve nightly version info run: | - VERSION=$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/') + VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2) SHORT_SHA="${GITHUB_SHA::7}" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" @@ -199,26 +200,24 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends fakeroot - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Build JAR and copy runtime dependencies run: | - ./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 + ./gradlew -S :app-desktop:collectRuntime - name: Build Debian package run: | mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" + ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" jpackage \ --type deb \ --name KST4Contest \ --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ + --input app-desktop/build/dist-libs \ --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ + --main-class kst4contest.view.Main \ --add-modules "$ADD_MODULES" \ --linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \ --dest dist @@ -245,7 +244,7 @@ jobs: - name: Resolve nightly version info run: | - VERSION=$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/') + VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2) SHORT_SHA="${GITHUB_SHA::7}" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" @@ -260,26 +259,24 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends rpm - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Build JAR and copy runtime dependencies run: | - ./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 + ./gradlew -S :app-desktop:collectRuntime - name: Build Fedora package run: | mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" + ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" jpackage \ --type rpm \ --name KST4Contest \ --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ + --input app-desktop/build/dist-libs \ --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ + --main-class kst4contest.view.Main \ --add-modules "$ADD_MODULES" \ --linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \ --dest dist @@ -306,7 +303,7 @@ jobs: - name: Resolve nightly version info run: | - VERSION=$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/') + VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2) SHORT_SHA="${GITHUB_SHA::7}" echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV" @@ -323,31 +320,30 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends zstd - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Build JAR and copy runtime dependencies run: | - ./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 + ./gradlew -S :app-desktop:collectRuntime - name: Build app-image with jpackage run: | mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" + ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" jpackage \ --type app-image \ --name KST4Contest \ --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ + --input app-desktop/build/dist-libs \ --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ + --main-class kst4contest.view.Main \ --add-modules "$ADD_MODULES" \ --dest dist - name: Build Arch Linux package artifact run: | + mkdir -p target ARCH=$(uname -m) PKGVER=$(printf '%s' "${VERSION}-${SHORT_SHA}" | sed 's/[^[:alnum:].+_]/_/g') PKGROOT="target/archpkg" @@ -427,7 +423,7 @@ jobs: - name: Resolve nightly version info run: | - VERSION=$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/') + VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2) SHORT_SHA="${GITHUB_SHA::7}" echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV" @@ -439,8 +435,8 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Install Flatpak tooling run: | @@ -451,23 +447,22 @@ jobs: - name: Build app-image with jpackage run: | - ./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 + ./gradlew -S :app-desktop:collectRuntime mkdir -p target/flatpak-src - ADD_MODULES="$(java packaging/AddModules.java)" + ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" jpackage \ --type app-image \ --name KST4Contest \ --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ + --input app-desktop/build/dist-libs \ --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ + --main-class kst4contest.view.Main \ --add-modules "$ADD_MODULES" \ --dest target/flatpak-src - name: Create Flatpak manifest run: | + mkdir -p target mkdir -p dist cat > target/de.x08.KST4Contest.yml << 'EOF' app-id: de.x08.KST4Contest @@ -515,6 +510,7 @@ jobs: - name: Build Flatpak repo (nightly) run: | + mkdir -p target flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build nightly flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo @@ -622,7 +618,7 @@ jobs: - name: Resolve nightly version info run: | - VERSION=$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/') + VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2) SHORT_SHA="${GITHUB_SHA::7}" ARCH=$(uname -m) echo "VERSION=$VERSION" >> "$GITHUB_ENV" @@ -636,8 +632,8 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Import signing certificate env: diff --git a/.github/workflows/pr-compile-check.yml b/.github/workflows/pr-compile-check.yml index fd3228d2..d3ab9b94 100644 --- a/.github/workflows/pr-compile-check.yml +++ b/.github/workflows/pr-compile-check.yml @@ -24,11 +24,8 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw - - - name: Verify packaging module list matches module-info.java - run: java packaging/AddModules.java --verify-pom + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Compile - run: ./mvnw -B -DskipTests compile + run: ./gradlew -S compileJava compileTestJava diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index 2ca550c0..f999238b 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -37,27 +37,26 @@ jobs: - name: Build JAR and copy runtime dependencies shell: pwsh run: | - .\mvnw.cmd -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs + .\gradlew.bat -S :app-desktop:collectRuntime $jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $jar) { throw "No project JAR produced" } - Copy-Item $jar.FullName target/dist-libs/app.jar + Copy-Item $jar.FullName app-desktop/build/dist-libs/app.jar - name: Build app-image with jpackage shell: pwsh run: | New-Item -ItemType Directory -Force -Path dist | Out-Null - $addModules = & java packaging/AddModules.java + $addModules = (Select-String -Path gradle.properties -Pattern '^jpackageAddModules=').Line.Split('=')[1] if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" } jpackage ` --type app-image ` --name praktiKST ` --icon packaging/icons/kst4contest.ico ` - --input target/dist-libs ` + --input app-desktop/build/dist-libs ` --main-jar app.jar ` - --main-class kst4contest.view.Kst4ContestApplication ` - --module-path target/dist-libs ` + --main-class kst4contest.view.Main ` --add-modules $addModules ` --dest dist @@ -89,31 +88,30 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Build JAR and copy runtime dependencies run: | - ./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 + ./gradlew -S :app-desktop:collectRuntime - name: Build app-image with jpackage run: | mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" + ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" jpackage \ --type app-image \ --name KST4Contest \ --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ + --input app-desktop/build/dist-libs \ --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ + --main-class kst4contest.view.Main \ --add-modules "$ADD_MODULES" \ --dest dist - name: Create AppDir metadata run: | + mkdir -p target rm -rf target/KST4Contest.AppDir cp -a dist/KST4Contest target/KST4Contest.AppDir @@ -140,6 +138,7 @@ jobs: - name: Build AppImage run: | + mkdir -p target wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage chmod +x target/appimagetool.AppImage APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage @@ -169,26 +168,24 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends fakeroot - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Build JAR and copy runtime dependencies run: | - ./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 + ./gradlew -S :app-desktop:collectRuntime - name: Build Debian package run: | mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" + ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" jpackage \ --type deb \ --name KST4Contest \ --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ + --input app-desktop/build/dist-libs \ --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ + --main-class kst4contest.view.Main \ --add-modules "$ADD_MODULES" \ --linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \ --dest dist @@ -223,26 +220,24 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends rpm - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Build JAR and copy runtime dependencies run: | - ./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 + ./gradlew -S :app-desktop:collectRuntime - name: Build Fedora package run: | mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" + ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" jpackage \ --type rpm \ --name KST4Contest \ --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ + --input app-desktop/build/dist-libs \ --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ + --main-class kst4contest.view.Main \ --add-modules "$ADD_MODULES" \ --linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \ --dest dist @@ -277,31 +272,30 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends zstd - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Build JAR and copy runtime dependencies run: | - ./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 + ./gradlew -S :app-desktop:collectRuntime - name: Build app-image with jpackage run: | mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" + ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" jpackage \ --type app-image \ --name KST4Contest \ --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ + --input app-desktop/build/dist-libs \ --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ + --main-class kst4contest.view.Main \ --add-modules "$ADD_MODULES" \ --dest dist - name: Build Arch Linux package artifact run: | + mkdir -p target ARCH=$(uname -m) PKGVER=$(printf '%s' "${{ github.ref_name }}" | sed 's/[^[:alnum:].+_]/_/g') PKGROOT="target/archpkg" @@ -384,8 +378,8 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Install Flatpak tooling run: | @@ -396,23 +390,22 @@ jobs: - name: Build app-image with jpackage run: | - ./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 + ./gradlew -S :app-desktop:collectRuntime mkdir -p target/flatpak-src - ADD_MODULES="$(java packaging/AddModules.java)" + ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" jpackage \ --type app-image \ --name KST4Contest \ --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ + --input app-desktop/build/dist-libs \ --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ + --main-class kst4contest.view.Main \ --add-modules "$ADD_MODULES" \ --dest target/flatpak-src - name: Create Flatpak manifest run: | + mkdir -p target mkdir -p dist cat > target/de.x08.KST4Contest.yml << 'EOF' app-id: de.x08.KST4Contest @@ -470,6 +463,7 @@ jobs: - name: Build Flatpak repo run: | + mkdir -p target flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build "$OSTREE_BRANCH" flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo @@ -520,8 +514,8 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Import signing certificate env: diff --git a/.github/workflows/test-publish-version-xml.yml b/.github/workflows/test-publish-version-xml.yml new file mode 100644 index 00000000..3a4dd6e5 --- /dev/null +++ b/.github/workflows/test-publish-version-xml.yml @@ -0,0 +1,170 @@ +name: TEST publish-version-xml (local only) + +on: + workflow_dispatch: + +jobs: + publish-version-xml: + name: Publish Version Info XML via WebDAV + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4.1.7 + + - name: Generate kst4ContestVersionInfo.xml from GitHub releases + history + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python3 << 'PYEOF' + import subprocess, json + from xml.etree.ElementTree import Element, SubElement, ElementTree, parse, indent + + def safe_version(tag): + cleaned = tag.lstrip('v') + try: + float(cleaned) + except ValueError: + pass + return cleaned + + import os, urllib.request + + REPO = 'praktimarc/kst4contest' + token = os.environ['GH_TOKEN'] + + def gh_get(path): + url = f'https://api.github.com/repos/{REPO}{path}' + req = urllib.request.Request(url, headers={ + 'Authorization': f'Bearer {token}', + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + + def get_issues_for_release(since, until): + """Return closed issues in (since, until] categorised by label.""" + LABEL_MAP = {'enhancement': 'added', 'bug': 'fixed'} + buckets = {'added': [], 'fixed': []} + page = 1 + while True: + items = gh_get(f'/issues?state=closed&per_page=100&page={page}') + if not items: + break + for issue in items: + if 'pull_request' in issue: + continue + closed = issue.get('closed_at') or '' + if not (since < closed <= until): + continue + labels = [l['name'] for l in issue['labels']] + bucket = next((LABEL_MAP[l] for l in labels if l in LABEL_MAP), None) + if bucket: + buckets[bucket].append(f'- {issue["title"]}') + if len(items) < 100: + break + page += 1 + return {k: '\n'.join(v) for k, v in buckets.items()} + + def clean_body(body): + """Strip GitHub markdown noise, keep plain text.""" + import re + lines = [] + for line in (body or '').splitlines(): + if re.match(r'^#{1,3}\s', line): + continue + if line.strip().startswith('**Full Changelog**'): + continue + line = re.sub(r'\s+by @\S+ in https://\S+', '', line) + line = re.sub(r'^\* ', '- ', line) + if line.strip(): + lines.append(line) + return re.sub(r'\n{3,}', '\n\n', '\n'.join(lines)).strip() + + # --- Fetch all releases --- + raw = gh_get('/releases?per_page=100') + gh_releases = [ + { + 'tagName': r['tag_name'], + 'publishedAt': r['published_at'] or '', + 'name': r['name'] or r['tag_name'], + 'body': r['body'] or '', + 'isPrerelease': r['prerelease'], + } + for r in raw + ] + + # --- Simulate a stable 1.41 release for testing --- + gh_releases.insert(0, { + 'tagName': '1.41', + 'publishedAt': '2026-06-30T10:00:00Z', + 'name': 'Release 1.41', + 'body': 'Release 1.41', + 'isPrerelease': False, + }) + + gh_releases.sort(key=lambda r: r['publishedAt'], reverse=True) + + stable_releases = [r for r in gh_releases if not r['isPrerelease']] + stable = stable_releases[0] if stable_releases else None + + gh_versions = {safe_version(r['tagName']) for r in stable_releases} + + history_tree = parse('.github/version-history.xml') + history_entries = history_tree.getroot().findall('changeLog') + + root = Element('praktiKST') + + lv = SubElement(root, 'latestVersion') + latest_tag = stable['tagName'] if stable else 'unknown' + SubElement(lv, 'versionNumber').text = safe_version(latest_tag) + SubElement(lv, 'adminMessage').text = '' + latest_issues = get_issues_for_release('1970-01-01T00:00:00Z', stable['publishedAt']) if stable else {} + SubElement(lv, 'majorChanges').text = latest_issues.get('added', '')[:300] + win_filename = f'praktiKST-{latest_tag}-windows-x64.zip' + SubElement(lv, 'latestVersionPathOnWebserver').text = ( + f'https://github.com/{REPO}/releases/download/{latest_tag}/{win_filename}' + ) + + nuf = SubElement(root, 'needUpdateSinceLastVersion') + SubElement(nuf, 'filename').text = win_filename + + # Build changelog for each stable release using closed issues in its window + for i, rel in enumerate(stable_releases): + until = rel['publishedAt'] + since = stable_releases[i + 1]['publishedAt'] if i + 1 < len(stable_releases) else '1970-01-01T00:00:00Z' + s = get_issues_for_release(since, until) + cl = SubElement(root, 'changeLog') + SubElement(cl, 'changedVersionNumber').text = safe_version(rel['tagName']) + SubElement(cl, 'date').text = until[:10] + SubElement(cl, 'description').text = rel['name'] + SubElement(cl, 'added').text = s['added'] + SubElement(cl, 'changed').text = clean_body(rel['body']) + SubElement(cl, 'fixed').text = s['fixed'] + SubElement(cl, 'removed').text = '' + + for entry in history_entries: + ver_node = entry.find('changedVersionNumber') + if ver_node is not None and ver_node.text and ver_node.text.strip() in gh_versions: + continue + root.append(entry) + + indent(root, space=' ') + tree = ElementTree(root) + with open('kst4ContestVersionInfo.xml', 'w', encoding='utf-8') as f: + f.write('\n') + tree.write(f, encoding='unicode', xml_declaration=False) + + print('Generated kst4ContestVersionInfo.xml') + PYEOF + + - name: Show generated XML (first 40 lines) + run: head -40 kst4ContestVersionInfo.xml + + - name: Upload to WebDAV + run: | + curl --fail --silent --show-error \ + --user "${{ secrets.WEBDAV_USER }}:${{ secrets.WEBDAV_PASSWORD }}" \ + --upload-file kst4ContestVersionInfo.xml \ + "${{ secrets.WEBDAV_URL }}/kst4ContestVersionInfo.xml" diff --git a/.gitignore b/.gitignore index e9632171..fec6ff69 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,9 @@ website/_site/ # Apple notarization private keys - never commit these *.p8 + +# Gradle +.gradle/ +build/ +core/build/ +app-desktop/build/ diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar deleted file mode 100644 index cb28b0e3..00000000 Binary files a/.mvn/wrapper/maven-wrapper.jar and /dev/null differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties deleted file mode 100644 index 3c6fda8c..00000000 --- a/.mvn/wrapper/maven-wrapper.properties +++ /dev/null @@ -1,18 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.2/apache-maven-3.9.2-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/AGENTS.md b/AGENTS.md index 54da6081..fcf92217 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,14 @@ KST4Contest is a Java/JavaFX desktop client for ON4KST chat with contest-oriente Primary repository areas: -- `src/main/java/kst4contest/` -- `src/test/` where present; +- `core/src/main/java/kst4contest/` and `core/src/test/` +- `app-desktop/src/main/java/kst4contest/` and `app-desktop/src/test/` - `github_docs/` - `website/` - `docs/` - `packaging/` - `.github/` -- `pom.xml` +- `build.gradle.kts`, `settings.gradle.kts`, `gradle.properties`, `gradle/` Inspect the current tree before assuming an exact class/path still exists. @@ -43,13 +43,13 @@ If an answer is uncertain, do not interpolate it. Check current code/tests/docs/ - Commit messages are concise English when a commit is explicitly requested. - User-facing DE/EN documentation follows `$praktimarc-writing-style`. -## Java and JavaFX architecture +## Java and user-interface architecture - Preserve or improve separation between network/parsing/service/controller/UI responsibilities. - Do not solve architecture problems by letting worker/model code directly manipulate JavaFX UI collections. -- Active chat-member domain state is conceptually thread-safe state; JavaFX `ObservableList` data is a UI projection, not the canonical worker-thread store. +- Active chat-member domain state is conceptually thread-safe state and lives in an `ObservableRoster` in `core`; the `ObservableList` a `TableView` is bound to is a mirror of it, not the store. - `MessageBusManagementThread` must not directly read or mutate the JavaFX `ObservableList` used by the UI. -- Route UI-visible mutations through the controller and the JavaFX Application Thread (`Platform.runLater` or the project's equivalent helper). +- Route UI-visible mutations through the controller and the `UiDispatcher` (`JavaFxUiDispatcher` hands them to the JavaFX Application Thread). Do not reach for `Platform.runLater` in `core`; it has no JavaFX dependency. - Prefer explicit DTOs over records when introducing transport/parser DTOs in this codebase unless the approved concept says otherwise. - Handle incomplete external/historical data defensively. - `qrb`, QTF and related external values can be absent. `null` means unavailable, not zero. @@ -111,22 +111,22 @@ Never change CR/LF, XML framing, callsign normalization, frequency formatting or ## Build and verification -Use the Maven wrapper. +Use the Gradle wrapper. Windows: ```text -.\mvnw.cmd ... +.\gradlew.bat ... ``` -Read the current `pom.xml` before relying on version numbers. +Read the current `gradle.properties` and `gradle/libs.versions.toml` before relying on version numbers. At the package creation snapshot the project uses Java 21 / JavaFX 21.x and JUnit 5/Mockito, with PMD and SpotBugs integrated. -Important: Maven/Surefire configuration has historically allowed test failures to be ignored, and static-analysis findings may not fail the build. Therefore: +Important: the build configuration has historically allowed test failures to be ignored, and static-analysis findings do not fail the build. Therefore: -- inspect the Maven test summary; -- inspect Surefire results when needed; +- inspect the Gradle test summary; +- inspect the test-results XML under `*/build/test-results/` when needed; - do not infer "all tests passed" from exit code 0; - report PMD/SpotBugs findings that are visible in the relevant build. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..46ffc7e0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,33 @@ +# CLAUDE.md + +Diese Datei gilt für Claude Code in diesem Repository. + +## Projektregeln + +Die fachlichen und stilistischen Regeln stehen in **[AGENTS.md](AGENTS.md)** und +**[docs/PROJECT_CONTEXT.md](docs/PROJECT_CONTEXT.md)**. Beide sind verbindlich und +werden hier nicht wiederholt. Besonders wichtig: Kommunikation auf Deutsch, +Quelltextkommentare und Javadoc auf Englisch, und das Konzept-vor-Umsetzung-Vorgehen +aus dem Abschnitt „Mandatory interaction rule" in `AGENTS.md`. + +## Subagenten + +Subagenten (Agent-Tool) dürfen ohne Rückfrage genutzt werden, wenn eine Aufgabe +davon profitiert — insbesondere für Abschlussprüfungen ganzer Zweige und für +umfangreiche Umbauten, bei denen ein frischer Blick pro Teilaufgabe etwas wert ist. + +Nicht sinnvoll bei kleinen, aufeinander aufbauenden Schritten: dort liest jeder +neue Subagent die Codebasis von null und kostet mehr, als er beiträgt. + +## Build + +Gradle, nicht Maven. `./gradlew clean build`, Java 21. Das Projekt besteht aus +zwei Modulen: `core` (Fachlichkeit) und `app-desktop` (Oberfläche). Paketnamen +sind über beide Module hinweg dieselben. + +## Laufende Arbeit + +Spezifikationen liegen unter `docs/superpowers/specs/`, Umsetzungspläne unter +`docs/superpowers/plans/`. Der aktuelle Umbau ist die Ablösung von JavaFX durch +Compose Multiplatform in sechs Etappen; Etappe 1 (Gradle, zwei Module, JPMS raus) +ist abgeschlossen. diff --git a/README.md b/README.md index 62812d86..3cf35b97 100644 --- a/README.md +++ b/README.md @@ -57,26 +57,24 @@ Changes to operating behaviour should be documented together with their purpose ## Building from source -Building KST4Contest requires JDK 21. The Maven Wrapper included in the repository should be used, so a separate Maven installation is not required. +Building KST4Contest requires JDK 21. The Gradle Wrapper included in the repository should be used, so a separate Gradle installation is not required. Linux and macOS: ```bash -./mvnw clean test -./mvnw -B -DskipTests compile +./gradlew clean build ``` Windows: ```powershell -mvnw.cmd clean test -mvnw.cmd -B -DskipTests compile +.\gradlew.bat clean build ``` ## Repository structure -- `src/main/java/` – application source code -- `src/test/` – automated tests +- `core/` – domain, controller and network code, free of user-interface technology +- `app-desktop/` – the JavaFX user interface and the packaging tasks - `github_docs/` – German and English manual sources - `website/` – project website sources - `packaging/` – platform-specific packaging files diff --git a/app-desktop/build.gradle.kts b/app-desktop/build.gradle.kts new file mode 100644 index 00000000..4e898281 --- /dev/null +++ b/app-desktop/build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + java + application + alias(libs.plugins.javafx) +} + +javafx { + version = libs.versions.javafx.get() + modules = listOf("javafx.controls", "javafx.fxml", "javafx.web", "javafx.media") +} + +dependencies { + // Mirrors the compile-scope entries in pom.xml: 11 test classes live under + // src/main/java and need JUnit and Mockito on the main compile classpath. + implementation(libs.junit.jupiter.api) + implementation(libs.mockito.core.compile) + implementation(libs.jetbrains.annotations) + implementation(project(":core")) + implementation(libs.sqlite.jdbc) + implementation(libs.jlayer) + testImplementation(libs.junit.jupiter) + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.junit.jupiter) + // Gradle 9 no longer adds the launcher implicitly. + testRuntimeOnly(libs.junit.platform.launcher) +} + +application { + mainClass.set("kst4contest.view.Kst4ContestApplication") +} + +// Collects the application jar and its runtime dependencies into the layout the +// packaging scripts expect: a flat directory with the main jar named app.jar. +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) + // jpackage must come from the project toolchain, not from PATH: jdk.jsobject + // was removed after JDK 21, and jlink then cannot resolve the module list. + val launcher = javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) + }.get() + val jpackageTool = launcher.metadata.installationPath.file("bin/jpackage").asFile.absolutePath + 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( + jpackageTool, + "--icon", rootProject.file(icon).absolutePath, + "--type", "app-image", + "--name", "praktiKST", + "--input", input.absolutePath, + "--dest", output.absolutePath, + "--main-jar", "app.jar", + "--add-modules", providers.gradleProperty("jpackageAddModules").get(), + "--main-class", "kst4contest.view.Main", + "--java-options", "-Dfile.encoding=UTF-8" + ) +} diff --git a/src/main/java/kst4contest/controller/AppRunner.java b/app-desktop/src/main/java/kst4contest/controller/AppRunner.java similarity index 100% rename from src/main/java/kst4contest/controller/AppRunner.java rename to app-desktop/src/main/java/kst4contest/controller/AppRunner.java diff --git a/src/main/java/kst4contest/test/ChatPreferencesStationMapVisibilityTest.java b/app-desktop/src/main/java/kst4contest/test/ChatPreferencesStationMapVisibilityTest.java similarity index 100% rename from src/main/java/kst4contest/test/ChatPreferencesStationMapVisibilityTest.java rename to app-desktop/src/main/java/kst4contest/test/ChatPreferencesStationMapVisibilityTest.java diff --git a/src/main/java/kst4contest/test/FrequencyTextParserTest.java b/app-desktop/src/main/java/kst4contest/test/FrequencyTextParserTest.java similarity index 100% rename from src/main/java/kst4contest/test/FrequencyTextParserTest.java rename to app-desktop/src/main/java/kst4contest/test/FrequencyTextParserTest.java diff --git a/src/main/java/kst4contest/test/MessageVariableResolverTest.java b/app-desktop/src/main/java/kst4contest/test/MessageVariableResolverTest.java similarity index 94% rename from src/main/java/kst4contest/test/MessageVariableResolverTest.java rename to app-desktop/src/main/java/kst4contest/test/MessageVariableResolverTest.java index 92442ed7..6cf4cf94 100644 --- a/src/main/java/kst4contest/test/MessageVariableResolverTest.java +++ b/app-desktop/src/main/java/kst4contest/test/MessageVariableResolverTest.java @@ -7,8 +7,7 @@ import static org.mockito.Mockito.when; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import javafx.beans.property.SimpleDoubleProperty; -import javafx.beans.property.SimpleStringProperty; +import kst4contest.observe.SimpleValue; import javafx.collections.FXCollections; import kst4contest.controller.MessageVariableResolver; import kst4contest.model.AirPlane; @@ -25,11 +24,11 @@ class MessageVariableResolverTest { void setUp() { chatPreferences = mock(ChatPreferences.class); - when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleStringProperty("144.388.03")); - when(chatPreferences.getMYQRGSecondCat()).thenReturn(new SimpleStringProperty("432.088.00")); + when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144.388.03")); + when(chatPreferences.getMYQRGSecondCat()).thenReturn(new SimpleValue<>("432.088.00")); when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO51IJ"); when(chatPreferences.getStn_loginCallSign()).thenReturn("DO5AMF"); - when(chatPreferences.getActualQTF()).thenReturn(new SimpleDoubleProperty(135.0)); + when(chatPreferences.getActualQTF()).thenReturn(new SimpleValue<>(135.0)); resolver = new MessageVariableResolver(chatPreferences); } @@ -46,7 +45,7 @@ class MessageVariableResolverTest { @Test void shortValuesRemainSafeWhenTheConfiguredValueIsShorterThanExpected() { - when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleStringProperty("144")); + when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144")); when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO5"); assertEquals("144 JO5", resolver.resolveGlobalVariables("MYQRGSHORT MYLOCATORSHORT")); diff --git a/src/main/java/kst4contest/test/PatternMatcherTest.java b/app-desktop/src/main/java/kst4contest/test/PatternMatcherTest.java similarity index 84% rename from src/main/java/kst4contest/test/PatternMatcherTest.java rename to app-desktop/src/main/java/kst4contest/test/PatternMatcherTest.java index da8ce0cc..3511295b 100644 --- a/src/main/java/kst4contest/test/PatternMatcherTest.java +++ b/app-desktop/src/main/java/kst4contest/test/PatternMatcherTest.java @@ -1,6 +1,6 @@ package kst4contest.test; -import javafx.beans.property.SimpleStringProperty; +import kst4contest.observe.SimpleValue; import kst4contest.controller.Utils4KST; import java.util.regex.Matcher; @@ -107,25 +107,25 @@ public class PatternMatcherTest { int i = 0; - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.775", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144,300.2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300.2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300,2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.20", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("300", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.20", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.20", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,20", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300,2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300.2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("q305", new SimpleStringProperty("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.775", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144,300.2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300.2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300,2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.20", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("300", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.20", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.20", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,20", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300,2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300.2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("q305", new SimpleValue<>("144"))); } } diff --git a/src/main/java/kst4contest/test/Utils4KSTFrequencyTest.java b/app-desktop/src/main/java/kst4contest/test/Utils4KSTFrequencyTest.java similarity index 92% rename from src/main/java/kst4contest/test/Utils4KSTFrequencyTest.java rename to app-desktop/src/main/java/kst4contest/test/Utils4KSTFrequencyTest.java index f42ff657..3029f6c1 100644 --- a/src/main/java/kst4contest/test/Utils4KSTFrequencyTest.java +++ b/app-desktop/src/main/java/kst4contest/test/Utils4KSTFrequencyTest.java @@ -1,6 +1,6 @@ package kst4contest.test; -import javafx.beans.property.SimpleStringProperty; +import kst4contest.observe.SimpleValue; import kst4contest.controller.Utils4KST; import org.junit.jupiter.api.Test; @@ -51,9 +51,9 @@ class Utils4KSTFrequencyTest { } private String convert(String frequency, String fallbackPrefix) { - SimpleStringProperty fallback = fallbackPrefix == null + SimpleValue fallback = fallbackPrefix == null ? null - : new SimpleStringProperty(fallbackPrefix); + : new SimpleValue<>(fallbackPrefix); return Utils4KST.normalizeFrequencyString( frequency, diff --git a/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java b/app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java similarity index 100% rename from src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java rename to app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java diff --git a/src/main/java/kst4contest/view/CommandLineOptions.java b/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java similarity index 100% rename from src/main/java/kst4contest/view/CommandLineOptions.java rename to app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java diff --git a/app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java b/app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java new file mode 100644 index 00000000..224b414a --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java @@ -0,0 +1,129 @@ +package kst4contest.view; + +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import kst4contest.observe.ObservableRoster; +import kst4contest.observe.UiDispatcher; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * 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. + * + *

Besides the plain one-to-one mirror there is a derived form: a supplier + * computes the visible content (filtered, ordered) and one or more rosters act + * as the change sources. That is what replaces the former + * {@code FilteredList}/{@code SortedList} chains, which recomputed themselves + * whenever their source list changed. + */ +public final class FxRosterBinding { + + private final ObservableList mirror = FXCollections.observableArrayList(); + private final List unsubscribes = new ArrayList<>(); + private final Supplier> derivation; + private final UiDispatcher dispatcher; + private volatile boolean disposed; + + private FxRosterBinding(Supplier> derivation, UiDispatcher dispatcher) { + this.derivation = derivation; + this.dispatcher = dispatcher; + } + + /** Mirrors the roster one to one. */ + public static FxRosterBinding mirror(ObservableRoster source, UiDispatcher dispatcher) { + FxRosterBinding binding = new FxRosterBinding<>(source::snapshot, dispatcher); + binding.subscribe(source); + binding.applyNow(); + return binding; + } + + /** + * Mirrors whatever {@code derivation} computes and recomputes it whenever one + * of the given rosters changes. + * + * @param dispatcher hands the mirror update to the user-interface thread + * @param derivation computes the visible content; called on the UI thread + * @param sources rosters whose changes trigger a recomputation + */ + public static FxRosterBinding derived(UiDispatcher dispatcher, + Supplier> derivation, + ObservableRoster... sources) { + FxRosterBinding binding = new FxRosterBinding<>(derivation, dispatcher); + for (ObservableRoster source : sources) { + binding.subscribe(source); + } + binding.applyNow(); + return binding; + } + + /** + * Recomputes the mirror. Needed when something other than a source roster + * changed the result, for example a newly set filter predicate. + */ + public void refresh() { + if (disposed) { + return; + } + dispatcher.runOnUi(this::applyNow); + } + + /** The list to hand to a {@code TableView}. Always the same instance. */ + public ObservableList list() { + return mirror; + } + + /** Unregisters from every source and empties the mirror. Idempotent. */ + public void dispose() { + if (disposed) { + return; + } + disposed = true; + for (Runnable unsubscribe : unsubscribes) { + unsubscribe.run(); + } + unsubscribes.clear(); + mirror.clear(); + } + + private void subscribe(ObservableRoster source) { + Consumer> listener = snapshot -> refresh(); + source.addListener(listener); + unsubscribes.add(() -> source.removeListener(listener)); + } + + private void applyNow() { + if (disposed) { + return; + } + List computed = derivation.get(); + /* + * An unchanged result must not produce a list change event. A TableView + * reacts to setAll with selection and scroll churn, and the periodic + * station-list refresh would otherwise fight the operator for the + * selected row on every tick. + * + * This comparison also breaks a cycle: the table's sort policy calls + * refresh(), which lands here, and setAll would ask the table to sort + * again. Once the content is equal the chain stops. + * + * It rests on element identity. ChatMember declares + * equals(ChatMember) — an overload, not an override of + * equals(Object) — so List.equals falls back to reference comparison. + * Turning that overload into a real override would make two different + * rows with the same callsign compare equal here, and a genuine change + * could be dropped. See ChatMember. + */ + if (mirror.equals(computed)) { + return; + } + mirror.setAll(computed); + } +} diff --git a/src/main/java/kst4contest/view/GuiUtils.java b/app-desktop/src/main/java/kst4contest/view/GuiUtils.java similarity index 100% rename from src/main/java/kst4contest/view/GuiUtils.java rename to app-desktop/src/main/java/kst4contest/view/GuiUtils.java diff --git a/app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java b/app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java new file mode 100644 index 00000000..4e921ab0 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java @@ -0,0 +1,31 @@ +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); + } + } +} diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java similarity index 96% rename from src/main/java/kst4contest/view/Kst4ContestApplication.java rename to app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java index bf899c11..28d3cf3c 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -33,6 +33,10 @@ import javafx.scene.media.MediaPlayer; import javafx.util.Duration; import javafx.util.StringConverter; import kst4contest.ApplicationConstants; +import kst4contest.observe.MutableValue; +import kst4contest.observe.ObservableRoster; +import kst4contest.observe.SimpleValue; +import kst4contest.observe.UiDispatcher; import kst4contest.controller.ChatController; import kst4contest.controller.MessageVariableResolver; import kst4contest.controller.StatusUpdateListener; @@ -183,6 +187,125 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL public static final String STYLE_DEFAULTCSSEVENING_RESOURCE = "/KST4ContestDefaultEvening.css"; String chatState; + /** Hands worker-thread results over to the JavaFX application thread. */ + private final UiDispatcher uiDispatcher = new JavaFxUiDispatcher(); + + /** + * Follows the own QRG in the text field. Replaces the former + * {@code textProperty().bind(...)}: a MutableValue is not a JavaFX + * property, so the field is updated through a listener instead. Attaching + * twice is harmless; detaching releases the listener. + */ + private java.util.function.Consumer ownQrgFollower; + + private void attachOwnQrgFollower() { + detachOwnQrgFollower(); + MutableValue ownQrg = chatcontroller.getChatPreferences().getMYQRGFirstCat(); + ownQrgFollower = value -> uiDispatcher.runOnUi(() -> txt_ownqrgMainCategory.setText(value)); + ownQrg.addListener(ownQrgFollower); + txt_ownqrgMainCategory.setText(ownQrg.get()); + } + + private void detachOwnQrgFollower() { + if (ownQrgFollower != null) { + chatcontroller.getChatPreferences().getMYQRGFirstCat().removeListener(ownQrgFollower); + ownQrgFollower = null; + } + } + + /** Live mirrors of core rosters, handed to TableViews. Released on shutdown. */ + private final java.util.List> rosterBindings = new java.util.ArrayList<>(); + + private javafx.collections.ObservableList mirrorOf(ObservableRoster roster) { + FxRosterBinding binding = FxRosterBinding.mirror(roster, uiDispatcher); + rosterBindings.add(binding); + return binding.list(); + } + + /** + * Registers a mirror whose content is computed rather than copied. Used for the + * station list and the message tabs, which used to be FilteredList/SortedList + * views on a core collection. + */ + private FxRosterBinding derivedBinding( + java.util.function.Supplier> derivation, + ObservableRoster... sources) { + FxRosterBinding binding = FxRosterBinding.derived(uiDispatcher, derivation, sources); + rosterBindings.add(binding); + return binding; + } + + /** The visible station list, replacing the former SortedList on the TableView. */ + private FxRosterBinding chatMemberListBinding; + + /** + * The message view of the selected-station info window. Its filter is switched + * by the radio buttons of that window, so the binding has to be refreshed + * explicitly. The other message views keep the filter they were given in the + * controller and follow their source rosters on their own. + */ + private FxRosterBinding selectedCallSignInfoMessageBinding; + + /** + * Sets the message filter of the selected-station info window and shows the + * result immediately, the way {@code FilteredList.setPredicate} did. + */ + private void applySelectedCallSignInfoFilter(Predicate filter) { + chatcontroller.setSelectedCallSignInfoFilter(filter); + if (selectedCallSignInfoMessageBinding != null) { + selectedCallSignInfoMessageBinding.refresh(); + } + } + + /** Follows the PSTRotator QTF in the text field; replaces a JavaFX string binding. */ + private java.util.function.Consumer qtfFollower; + + private void attachQtfFollower() { + detachQtfFollower(); + MutableValue qtf = chatcontroller.getChatPreferences().getActualQTF(); + qtfFollower = value -> uiDispatcher.runOnUi( + () -> txt_myQTF.setText(value == null ? "" : Double.toString(value))); + qtf.addListener(qtfFollower); + txt_myQTF.setText(qtf.get() == null ? "" : Double.toString(qtf.get())); + } + + private void detachQtfFollower() { + if (qtfFollower != null) { + chatcontroller.getChatPreferences().getActualQTF().removeListener(qtfFollower); + qtfFollower = null; + } + } + + /** + * Follows the priority score of the selected station in a label. Replaces a + * JavaFX string binding: the score is no longer a JavaFX property. The listener + * is owned by the label, which is discarded together with the info pane. + */ + private void attachPriorityScoreFollower(Label label) { + /* + * Fully qualified on purpose: javafx.beans.value.ObservableValue is imported + * in this class, so the core type cannot be imported under the same name. + */ + kst4contest.observe.ObservableValue score = + chatcontroller.getScoreService().selectedCallPriorityScore(); + java.util.function.Consumer follower = + value -> uiDispatcher.runOnUi(() -> label.setText(formatPriorityScore(value))); + score.addListener(follower); + label.setText(formatPriorityScore(score.get())); + label.sceneProperty().addListener((observable, oldScene, newScene) -> { + if (newScene == null) { + score.removeListener(follower); + } + }); + } + + private static String formatPriorityScore(Double value) { + if (value == null || Double.isNaN(value)) { + return "Priority score: -"; + } + return String.format(java.util.Locale.US, "Priority score: %.0f", value); + } + ChatController chatcontroller; MessageVariableResolver messageVariableResolver; @@ -744,19 +867,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL Label selectedCallSignInfoLblPriorityScore = new Label(); - selectedCallSignInfoLblPriorityScore.textProperty().bind(Bindings.createStringBinding( - () -> { - double s = chatcontroller.getScoreService().selectedCallPriorityScoreProperty().get(); - if (Double.isNaN(s)) return "Priority score: -"; - return String.format(java.util.Locale.US, "Priority score: %.0f", s); - }, - chatcontroller.getScoreService().selectedCallPriorityScoreProperty())); + attachPriorityScoreFollower(selectedCallSignInfoLblPriorityScore); // selectedCallSignDownerSiteGridPane.add(selectedCallSignInfoLblPriorityScore, 0,5,1,1); Button btnSkedFail = new Button("Sked fail"); btnSkedFail.setTooltip(new Tooltip("Marks the path as failed (permanent until reset). Strongly reduces priority score.")); btnSkedFail.setOnAction(e -> { - ChatMember sel = chatcontroller.getScoreService().selectedChatMemberProperty().get(); + ChatMember sel = chatcontroller.getScoreService().selectedChatMember().get(); if (sel == null) return; chatcontroller.getStationMetricsService().markManualSkedFail(sel.getCallSignRaw()); chatcontroller.getScoreService().requestRecompute("manual-sked-fail"); @@ -765,7 +882,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL Button btnSkedFailReset = new Button("Reset fail"); btnSkedFailReset.setTooltip(new Tooltip("Resets the manual sked-fail flag for this station.")); btnSkedFailReset.setOnAction(e -> { - ChatMember sel = chatcontroller.getScoreService().selectedChatMemberProperty().get(); + ChatMember sel = chatcontroller.getScoreService().selectedChatMember().get(); if (sel == null) return; chatcontroller.getStationMetricsService().resetManualSkedFail(sel.getCallSignRaw()); chatcontroller.getScoreService().requestRecompute("manual-sked-fail-reset"); @@ -871,7 +988,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL btnCreateSked.setOnAction(e -> { ChatMember selectedMember = chatcontroller.getScoreService() - .selectedChatMemberProperty() + .selectedChatMember() .get(); if (selectedMember == null) { @@ -1423,7 +1540,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL RadioButton radioButton = (RadioButton) selectedCallSignInfoFilterMessagesRadioGrp.getSelectedToggle(); if (radioButton.equals(selectedCallSignFilterToMeMsgRB)) { - chatcontroller.getLst_selectedCallSignInfofilteredMessageList().setPredicate(new Predicate() { + applySelectedCallSignInfoFilter(new Predicate() { @Override public boolean test(ChatMessage chatMessage) { @@ -1457,7 +1574,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // System.out.println(t1 + " filter to me was selected <<<<<<<<<<<<<<<<<<<"); } else if (radioButton.equals(selectedCallSignFilterMsgToOtherRB)) { - chatcontroller.getLst_selectedCallSignInfofilteredMessageList().setPredicate(new Predicate() { + applySelectedCallSignInfoFilter(new Predicate() { @Override public boolean test(ChatMessage chatMessage) { @@ -1480,7 +1597,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL System.out.println(t1 + " filter to other was selected <<<<<<<<<<<<<<<<<<<"); } else if (radioButton.equals(selectedCallSignFilterMsgpublic)) { - chatcontroller.getLst_selectedCallSignInfofilteredMessageList().setPredicate(new Predicate() { + applySelectedCallSignInfoFilter(new Predicate() { @Override public boolean test(ChatMessage chatMessage) { @@ -1504,7 +1621,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL System.out.println(t1 + " filter to public was selected <<<<<<<<<<<<<<<<<<<"); } else { System.out.println(t1 + " no filter was selected <<<<<<<<<<<<<<<<<<<"); - chatcontroller.getLst_selectedCallSignInfofilteredMessageList().setPredicate(new Predicate() { + applySelectedCallSignInfoFilter(new Predicate() { @Override public boolean test(ChatMessage chatMessage) { @@ -1537,7 +1654,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // selectedCallSignInfoBottomControlsBox.getChildren().add(new CheckBox("Filter messages to Other")); selectedCallSignInfoBorderPane.setTop(selectedCallSignInfoBottomControlsBox); - chatcontroller.getLst_selectedCallSignInfofilteredMessageList().setPredicate(new Predicate() { + applySelectedCallSignInfoFilter(new Predicate() { /** * This is the filter "nothing" option. It will get all communication of a callsign to all directions * @@ -1593,17 +1710,18 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL */ private void applyChatMemberFilterPredicates() { if (chatcontroller == null - || chatcontroller.getLst_chatMemberListFiltered() == null + || chatMemberListBinding == null || chatcontroller.getLst_chatMemberListFilterPredicates() == null) { return; } Predicate combinedPredicate; - if (chatcontroller.getLst_chatMemberListFilterPredicates().isEmpty()) { + if (chatcontroller.getLst_chatMemberListFilterPredicates().snapshot().isEmpty()) { combinedPredicate = SHOW_ALL_CHAT_MEMBER_PREDICATE; } else { combinedPredicate = chatcontroller.getLst_chatMemberListFilterPredicates() + .snapshot() .stream() .reduce(SHOW_ALL_CHAT_MEMBER_PREDICATE, Predicate::and); } @@ -1612,7 +1730,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * Do not reset the same show-all predicate over and over again. * * The periodic priority/user-list refresh calls this method regularly. If the - * FilteredList is invalidated without real filter changes, the TableView + * visible station list is rebuilt without real filter changes, the TableView * selection model may emit another selection event and the send-text field can * be prepared again. */ @@ -1625,7 +1743,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL boolean previousProgrammaticFlag = programmaticChatMemberSelectionChange; programmaticChatMemberSelectionChange = true; try { - chatcontroller.getLst_chatMemberListFiltered().setPredicate(combinedPredicate); + chatMemberListBinding.refresh(); } finally { programmaticChatMemberSelectionChange = previousProgrammaticFlag; } @@ -1895,7 +2013,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public ObservableValue call(CellDataFeatures cellDataFeatures) { - return cellDataFeatures.getValue().getFrequency(); + return new SimpleStringProperty(cellDataFeatures.getValue().getFrequency().get()); } }); @@ -2334,8 +2452,31 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // tbl_chatMemberTable.setItems(chatcontroller.getLst_chatMemberListFiltered()); - tbl_chatMemberTable.setItems(chatcontroller.getLst_chatMemberSortedFilteredList()); - chatcontroller.getLst_chatMemberSortedFilteredList().comparatorProperty().bind(tbl_chatMemberTable.comparatorProperty()); + /* + * The station list is derived in core now. The TableView column headers feed + * the comparator that the SortedList used to receive through a binding. + */ + chatcontroller.setChatMemberOrder(tbl_chatMemberTable.getComparator()); + chatMemberListBinding = derivedBinding( + chatcontroller::visibleChatMembers, + chatcontroller.getLst_chatMemberList(), + chatcontroller.getLst_chatMemberListFilterPredicates()); + tbl_chatMemberTable.setItems(chatMemberListBinding.list()); + tbl_chatMemberTable.comparatorProperty().addListener((observable, oldComparator, newComparator) -> { + chatcontroller.setChatMemberOrder(newComparator); + chatMemberListBinding.refresh(); + }); + /* + * The default sort policy would sort the mirror in place. A SortedList used to + * absorb that, so the visible order was always derived from the unsorted + * source. Keep it that way: the derived list is already ordered, and sorting + * an already sorted mirror again could reorder equally ranked stations. + */ + tbl_chatMemberTable.setSortPolicy(table -> { + chatcontroller.setChatMemberOrder(table.getComparator()); + chatMemberListBinding.refresh(); + return true; + }); // chatcontroller.getLst_chatMemberList().addListener(new ListChangeListener() { //// ObservableStringValue chatState = new SimpleStringProperty(); // @@ -2486,25 +2627,26 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (callRaw == null) return null; // 1) Prefer exact (callRaw + category) - synchronized (chatcontroller.getLst_chatMemberList()) { - for (ChatMember m : chatcontroller.getLst_chatMemberList()) { - if (m == null) continue; - if (m.getCallSignRaw() == null) continue; - if (!m.getCallSignRaw().equalsIgnoreCase(callRaw)) continue; + // One snapshot for both passes: the roster is consistent inside it. + List currentMembers = chatcontroller.getLst_chatMemberList().snapshot(); - if (preferredCategory != null && preferredCategory.equals(m.getChatCategory())) { - return m; - } - } + for (ChatMember m : currentMembers) { + if (m == null) continue; + if (m.getCallSignRaw() == null) continue; + if (!m.getCallSignRaw().equalsIgnoreCase(callRaw)) continue; - // 2) Fallback: any variant with same callsignRaw - for (ChatMember m : chatcontroller.getLst_chatMemberList()) { - if (m == null) continue; - if (m.getCallSignRaw() == null) continue; - if (m.getCallSignRaw().equalsIgnoreCase(callRaw)) return m; + if (preferredCategory != null && preferredCategory.equals(m.getChatCategory())) { + return m; } } + // 2) Fallback: any variant with same callsignRaw + for (ChatMember m : currentMembers) { + if (m == null) continue; + if (m.getCallSignRaw() == null) continue; + if (m.getCallSignRaw().equalsIgnoreCase(callRaw)) return m; + } + return null; } @@ -2555,7 +2697,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL programmaticChatMemberSelectionChange = false; } - handleChatMemberSelectionChanged(member, previousSelectedMember, forcePrepareCq); + handleChatMemberSelectionChanged(member, forcePrepareCq); } /** @@ -2572,12 +2714,26 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * Even then, the send field is protected by prepareCqTextForSelectedChatMember() * so manually typed operator text is not destroyed. */ - private void handleChatMemberSelectionChanged(ChatMember selectedMember, ChatMember previousMember, boolean forcePrepareCq) { + private void handleChatMemberSelectionChanged(ChatMember selectedMember, boolean forcePrepareCq) { if (selectedMember == null) { return; } - boolean logicalSelectionChanged = !isSameLogicalChatMember(previousMember, selectedMember); + /* + * Whether the info pane already shows this station. Compared against the + * member the pane was last built for, not against the selection model's + * previous value: a station entering or leaving the chat replaces the whole + * table mirror, which makes the model emit [null, sameMember]. Its previous + * value is then null and looks like a change, while the operator never left + * the station. + * + * Rebuilding the pane on such an event resets the message filter radio + * buttons to their configured defaults, which takes the operator's view away + * while they are still reading it. On a busy server that happens every few + * seconds. + */ + boolean infoPaneAlreadyShowsMember = + isSameLogicalChatMember(selectedCallSignInfoStageChatMember, selectedMember); selectedCallSignInfoStageChatMember = selectedMember; @@ -2585,13 +2741,23 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL chatcontroller.getScoreService().setSelectedChatMember(selectedMember); } - try { - selectedCallSignFurtherInfoPane.getChildren().setAll(generateFurtherInfoAbtSelectedCallsignBP(selectedMember)); - } catch (Exception exception) { - System.out.println("KST4CApp: ERROR, selected member disappeared: " + exception.getMessage()); + if (!infoPaneAlreadyShowsMember) { + try { + selectedCallSignFurtherInfoPane.getChildren().setAll(generateFurtherInfoAbtSelectedCallsignBP(selectedMember)); + } catch (Exception exception) { + System.out.println("KST4CApp: ERROR, selected member disappeared: " + exception.getMessage()); + } } - if (forcePrepareCq || logicalSelectionChanged) { + /* + * prepareCqTextForSelectedChatMember() ends in requestFocus() on the send + * field. It must therefore only run when the station really changed, or when + * the operator selected a row themselves: the [null, sameMember] burst a + * mirror replacement produces would otherwise pull the keyboard focus away + * from whatever field the operator is typing in, every time a station enters + * or leaves the chat. + */ + if (forcePrepareCq || !infoPaneAlreadyShowsMember) { prepareCqTextForSelectedChatMember(selectedMember, forcePrepareCq); } } @@ -2655,8 +2821,19 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL lastAutoPreparedCqTargetCallsign = normalizeCallsignForCategoryResolution(callSign); lastAutoPreparedCqTargetCategory = normalizeToActiveChatCategory(preparedCategory); - txt_chatMessageUserInput.requestFocus(); - txt_chatMessageUserInput.selectEnd(); + /* + * Deferred on purpose. When the operator clicks a table row, this method runs + * from inside the selection listener, which fires during the mouse event. The + * TableView claims the focus afterwards as part of its own default handling, + * so an immediate requestFocus() here is silently undone. Platform.runLater + * on purpose and not uiDispatcher: the dispatcher runs the task inline when + * the caller is already on the UI thread, which is exactly the case here and + * would defer nothing. + */ + Platform.runLater(() -> { + txt_chatMessageUserInput.requestFocus(); + txt_chatMessageUserInput.selectEnd(); + }); } /** @@ -2860,7 +3037,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL ChatCategory foundCategory = null; - for (ChatMember chatMember : chatcontroller.getLst_chatMemberList()) { + for (ChatMember chatMember : chatcontroller.getLst_chatMemberList().snapshot()) { if (chatMember == null || !chatMemberMatchesCallsign(chatMember, normalizedCallsign)) { continue; } @@ -3070,7 +3247,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().getSender() != null) { // qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - qrg = cellDataFeatures.getValue().getSender().getFrequency(); + qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency().get()); } else { qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default @@ -3089,7 +3266,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().getReceiver() != null) { // qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency()); - qrg = cellDataFeatures.getValue().getReceiver().getFrequency(); + qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency().get()); } else { @@ -3159,8 +3336,18 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL tbl_furtherInfoAbtCallsignMSGTable.getColumns().addAll(timeCol, callSignTRCVCol, callSignRCVRCol, msgCol); - ObservableList toOtherMSGList = chatcontroller.getLst_toOtherMessageList(); - tbl_furtherInfoAbtCallsignMSGTable.setItems(chatcontroller.getLst_selectedCallSignInfofilteredMessageList()); + /* + * A new info window replaces the old one, and with it its table. Release the + * previous mirror instead of leaving it listening on the message roster. + */ + if (selectedCallSignInfoMessageBinding != null) { + selectedCallSignInfoMessageBinding.dispose(); + rosterBindings.remove(selectedCallSignInfoMessageBinding); + } + selectedCallSignInfoMessageBinding = derivedBinding( + chatcontroller::selectedCallSignInfoMessages, + chatcontroller.getLst_globalChatMessageList()); + tbl_furtherInfoAbtCallsignMSGTable.setItems(selectedCallSignInfoMessageBinding.list()); applyTruncatedTextCells( timeCol, callSignTRCVCol, callSignRCVRCol, qrgTXerCol, qrgRXerCol, workedRXCol, workedTXCol @@ -3423,7 +3610,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().getSender() != null) { // qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - qrg = cellDataFeatures.getValue().getSender().getFrequency(); + qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency().get()); } else { qrg.setValue(""); @@ -3494,8 +3681,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL tbl_generalMSGTable.getColumns().addAll(timeCol, callSignCol, nameCol, msgCol, qrgCol, categoryCol); - ObservableList generalMSGList = chatcontroller.getLst_toAllMessageList(); - tbl_generalMSGTable.setItems(generalMSGList); + tbl_generalMSGTable.setItems(this.derivedBinding( + chatcontroller::toAllMessages, + chatcontroller.getLst_globalChatMessageList()).list()); applyTruncatedTextCells(timeCol, callSignCol, nameCol, categoryCol); TableLayoutManager.install( tbl_generalMSGTable, @@ -3634,7 +3822,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().getSender() != null) { // qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - qrg = cellDataFeatures.getValue().getSender().getFrequency(); + qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency().get()); } else { qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default @@ -3810,8 +3998,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL tbl_privateMSGTable.getColumns().addAll(timeCol, callSignCol, nameCol, qraCol, qrbCol, msgCol, qrgCol, airScoutCol, categoryCol); - ObservableList privateMSGList = chatcontroller.getLst_toMeMessageList(); - tbl_privateMSGTable.setItems(privateMSGList); + /* + * The monitoring list is a second source here: adding a monitored callsign + * changes which messages this tab shows, without a new message arriving. + */ + tbl_privateMSGTable.setItems(this.derivedBinding( + chatcontroller::toMeMessages, + chatcontroller.getLst_globalChatMessageList(), + chatcontroller.getLstNotify_QSOSniffer_sniffedCallSignList()).list()); applyTruncatedTextCells(timeCol, callSignCol, nameCol, qraCol, qrbCol, categoryCol); TableLayoutManager.install( tbl_privateMSGTable, @@ -4002,7 +4196,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().getReceiver() != null) { // qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency()); - qrg = cellDataFeatures.getValue().getReceiver().getFrequency(); + qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency().get()); } else { qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default @@ -4058,7 +4252,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL tbl_DXCTable.getColumns().addAll(timeCol, callSignCol, locTXCol, callSignRXCol, locRXCol, qrgCol, msgCol, workedCol); - ObservableList clusterMSGList = chatcontroller.getLst_clusterMemberList(); + ObservableList clusterMSGList = mirrorOf(chatcontroller.getLst_clusterMemberList()); tbl_DXCTable.setItems(clusterMSGList); applyTruncatedTextCells( timeCol, callSignCol, locTXCol, callSignRXCol, @@ -4167,7 +4361,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().getSender() != null) { // qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - qrg = cellDataFeatures.getValue().getSender().getFrequency(); + qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency().get()); } else { qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default @@ -4186,7 +4380,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().getReceiver() != null) { // qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency()); - qrg = cellDataFeatures.getValue().getReceiver().getFrequency(); + qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency().get()); } else { @@ -4278,8 +4472,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL tbl_toOtherMSGTable.getColumns().addAll(timeCol, callSignTRCVCol, qrgTXerCol, workedTXCol, callSignRCVRCol, qrgRXerCol, workedRXCol, msgCol, categoryCol); - ObservableList toOtherMSGList = chatcontroller.getLst_toOtherMessageList(); - tbl_toOtherMSGTable.setItems(toOtherMSGList); + tbl_toOtherMSGTable.setItems(this.derivedBinding( + chatcontroller::toOtherMessages, + chatcontroller.getLst_globalChatMessageList()).list()); applyTruncatedTextCells( timeCol, callSignTRCVCol, qrgTXerCol, workedTXCol, callSignRCVRCol, qrgRXerCol, workedRXCol, categoryCol @@ -4338,7 +4533,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL tbl_txtShorts.getColumns().addAll(ShortCol); tbl_txtShorts.setEditable(true); - tbl_txtShorts.setItems(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList()); + tbl_txtShorts.setItems(mirrorOf(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList())); return tbl_txtShorts; } // TODO: Textsnippets table @@ -4353,7 +4548,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // header.getStyleClass().add("label"); // // ListView listView = new ListView<>(); -// listView.setItems(chatcontroller.getScoreService().getTopCandidatesFx()); +// listView.setItems(mirrorOf(chatcontroller.getScoreService().topCandidates())); // // listView.setCellFactory(lv -> new ListCell<>() { // @Override @@ -4435,16 +4630,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL row.setAlignment(Pos.CENTER_LEFT); Runnable refreshButtons = () -> { - ObservableList items = - chatcontroller.getScoreService().getTopCandidatesFx(); + List items = + chatcontroller.getScoreService().topCandidates().snapshot(); updateTopCandidateButton(top1Button, items, 0); updateTopCandidateButton(top2Button, items, 1); }; - chatcontroller.getScoreService().getTopCandidatesFx().addListener( - (ListChangeListener) change -> refreshButtons.run() - ); + chatcontroller.getScoreService().topCandidates().addListener(candidates -> refreshButtons.run()); refreshButtons.run(); @@ -4467,7 +4660,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL */ private void updateTopCandidateButton( Button button, - ObservableList items, + List items, int index ) { if (items == null || items.size() <= index || items.get(index) == null) { @@ -4498,8 +4691,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL TableView tbl_chatMember, TextField txt_chatMessageUserInput ) { - ObservableList items = - chatcontroller.getScoreService().getTopCandidatesFx(); + List items = + chatcontroller.getScoreService().topCandidates().snapshot(); if (items == null || items.size() <= index) { return; @@ -4560,7 +4753,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL stage.setTitle("Top priority candidates"); ListView listView = new ListView<>(); - listView.setItems(chatcontroller.getScoreService().getTopCandidatesFx()); + listView.setItems(mirrorOf(chatcontroller.getScoreService().topCandidates())); listView.setCellFactory(lv -> new ListCell<>() { @Override @@ -4637,7 +4830,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return; } - List skedsSnapshot = new ArrayList<>(chatcontroller.getActiveSkeds()); + List skedsSnapshot = new ArrayList<>(chatcontroller.getActiveSkeds().snapshot()); List candidates = buildTimelinePriorityCandidateEvents(); timelineView.updateVisuals(skedsSnapshot, candidates); @@ -4658,7 +4851,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // Snapshot to avoid concurrent modifications (TopCandidates list is FX observable) List topSnapshot = - new ArrayList<>(chatcontroller.getScoreService().getTopCandidatesFx()); + new ArrayList<>(chatcontroller.getScoreService().topCandidates().snapshot()); Map> byMinute = new HashMap<>(); @@ -5010,7 +5203,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // tbl_txtSnips.set // ObservableList lst_textSnipList = ); - tbl_txtSnips.setItems(chatcontroller.getChatPreferences().getLst_txtSnipList()); + tbl_txtSnips.setItems(mirrorOf(chatcontroller.getChatPreferences().getLst_txtSnipList())); // tbl_txtSnips.bind return tbl_txtSnips; @@ -5025,7 +5218,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } flwPane_textSnippets.getChildren().setAll( - buttonFactory(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList()) + buttonFactory(mirrorOf(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList())) ); } @@ -5033,7 +5226,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * Rebuilds every context menu which exposes the configured text snippets. */ private void refreshTextSnippetContextMenus() { - ObservableList snippets = chatcontroller.getChatPreferences().getLst_txtSnipList(); + ObservableList snippets = mirrorOf(chatcontroller.getChatPreferences().getLst_txtSnipList()); chatMessageContextMenu = initChatMemberTableContextMenu(snippets); chatMemberContextMenu = initChatMemberTableContextMenu(snippets); } @@ -5388,7 +5581,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL tbl_chatMemberWkdDBTable.getColumns().addAll(callSignCol, workedCol); - tbl_chatMemberWkdDBTable.setItems(chatcontroller.getLst_DBBasedWkdCallSignList()); + tbl_chatMemberWkdDBTable.setItems(mirrorOf(chatcontroller.getLst_DBBasedWkdCallSignList())); applyTruncatedTextCells( callSignCol, wkdAny_subcol, sixMCol_subcol, fourMCol_subcol, vhfCol_subcol, uhfCol_subcol, shf23_subcol, shf13_subcol, @@ -5443,7 +5636,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // tbl_txtSnips.set // ObservableList lst_textSnipList = ); -// tbl_wkdStn.setItems(chatcontroller.getChatPreferences().getLst_txtSnipList()); +// tbl_wkdStn.setItems(mirrorOf(chatcontroller.getChatPreferences().getLst_txtSnipList())); // tbl_txtSnips.bind return tbl_chatMemberWkdDBTable; @@ -5545,7 +5738,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL ChatMessage sendMe = new ChatMessage(); sendMe.setMessageDirectedToServer(false); - sendMe.setMessageText("/SETNAME " + chatcontroller.getChatPreferences().getMYQRGFirstCat().getValue()); + sendMe.setMessageText("/SETNAME " + chatcontroller.getChatPreferences().getMYQRGFirstCat().get()); chatcontroller.getMessageTXBus().add(sendMe); @@ -6522,6 +6715,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL stationMapBridge = null; } + /* + * Every mirror holds a listener inside a core roster. Without releasing them + * a discarded runtime stays reachable across an operator profile switch. + */ + rosterBindings.forEach(FxRosterBinding::dispose); + rosterBindings.clear(); + chatMemberListBinding = null; + selectedCallSignInfoMessageBinding = null; + if (stationMapView != null) { stationMapView.dispose(); stationMapView = null; @@ -7055,7 +7257,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL this, activeOperatorProfile.getPreferencesRelativeFileName(), activeOperatorProfile.getWorkedDatabaseRelativeFileName(), - activeOperatorProfile.isSeedWorkedDatabaseFromResource()); + activeOperatorProfile.isSeedWorkedDatabaseFromResource(), + uiDispatcher); setDebugFileLoggingEnabled(chatcontroller.getChatPreferences() .isMessageHandling_debugModeToFileEnabled()); layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences()); @@ -7066,17 +7269,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual // 1. Timeline an die Sked-Liste binden - chatcontroller.getActiveSkeds().addListener((ListChangeListener) c -> { - updateTimelineVisuals(); - }); + chatcontroller.getActiveSkeds().addListener(skeds -> updateTimelineVisuals()); // 1. bind table to the sked list - chatcontroller.getScoreService().uiPulseProperty().addListener((obs, oldVal, newVal) -> { + chatcontroller.getScoreService().uiPulse().addListener(newVal -> { updateTimelineVisuals(); }); // Keep TimelineView antenna azimuth in sync with preferences (rotator / QTF) -// chatcontroller.getChatPreferences().getActualQTF().addListener((obs, oldV, newV) -> { +// chatcontroller.getChatPreferences().getActualQTF().addListener(newV -> { // timelineView.setCurrentAntennaAzimuth(newV.doubleValue()); // timelineView.updateVisuals(chatcontroller.getActiveSkeds()); // }); @@ -7086,7 +7287,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL timelineView.setBeamWidthDeg(chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg()); // Update visuals when rotor direction changes - chatcontroller.getChatPreferences().getActualQTF().addListener((obs, oldV, newV) -> { + chatcontroller.getChatPreferences().getActualQTF().addListener(newV -> { timelineView.setCurrentAntennaAzimuth(newV.doubleValue()); updateTimelineVisuals(); }); @@ -7138,9 +7339,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // Rotator sync on/off determines whether this field is bound (read-only, driven // by PSTRotator) or free for manual entry, mirroring the MYQRG sync behaviour above. if (chatcontroller.getChatPreferences().isStn_pstRotatorEnabled()) { - txt_myQTF.textProperty().bind(Bindings.createStringBinding( - () -> Double.toString(chatcontroller.getChatPreferences().getActualQTF().get()), - chatcontroller.getChatPreferences().getActualQTF())); + attachQtfFollower(); txt_myQTF.setTooltip(new Tooltip("This is your current QTF, read out at PSTRotator")); txt_myQTF.setFocusTraversable(false); } else { @@ -7255,8 +7454,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL initSkedWarnIndicatorButton(); - chatcontroller.lastUiReminderEventProperty().addListener( - (observable, oldValue, reminderEvent) -> { + chatcontroller.lastUiReminderEvent().addListener( + reminderEvent -> { if (reminderEvent == null) { return; } @@ -7575,7 +7774,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL txt_ownqrgMainCategory.setFocusTraversable(false); // txt_ownqrgSecondCategory.setText("SECONDQRG"); - txt_ownqrgSecondCategory.setText(chatcontroller.getChatPreferences().getMYQRGSecondCat().getValue()); + txt_ownqrgSecondCategory.setText(chatcontroller.getChatPreferences().getMYQRGSecondCat().get()); txt_ownqrgSecondCategory.setPrefSize(70, 0); txt_ownqrgSecondCategory.setAlignment(Pos.BASELINE_CENTER); txt_ownqrgSecondCategory.setFocusTraversable(false); @@ -7605,7 +7804,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL + chatcontroller.getChatPreferences().getStn_loginNameMainCat() + ")" + " in " + chatcontroller.getChatPreferences().getStn_loginLocatorMainCat() + " (" + chatcontroller.getLst_chatMemberList().size() + " users online, " - + chatcontroller.getLst_chatMemberSortedFilteredList().size() + " shown), " + + chatcontroller.visibleChatMemberCount() + " shown), " + (chatcontroller.getLst_globalChatMessageList().size()) + " messages total."; chatcontroller.getChatPreferences().setChatState(chatState); @@ -7655,12 +7854,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL flwPane_textSnippets = new FlowPane(); flwPane_textSnippets.getChildren() - .addAll(buttonFactory(this.chatcontroller.getChatPreferences().getLst_txtShortCutBtnList())); + .addAll(buttonFactory(mirrorOf(this.chatcontroller.getChatPreferences().getLst_txtShortCutBtnList()))); TableView privateMessageTable = initChatprivateMSGTable(); chatMessageContextMenu = initChatMemberTableContextMenu( - this.chatcontroller.getChatPreferences().getLst_txtSnipList()); // new mechanic + mirrorOf(this.chatcontroller.getChatPreferences().getLst_txtSnipList())); // new mechanic privateMessageTable.addEventHandler(MouseEvent.MOUSE_CLICKED, new EventHandler() { @@ -8006,7 +8205,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL try { handleChatMemberSelectionChanged( newSelectedMember, - oldSelectedMember, forcePrepareCqBecauseOperatorSelected ); } finally { @@ -8077,7 +8275,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // this.chatcontroller.getChatPreferences().getTextSnippets()); old mechanic chatMemberContextMenu = initChatMemberTableContextMenu( - this.chatcontroller.getChatPreferences().getLst_txtSnipList()); + mirrorOf(this.chatcontroller.getChatPreferences().getLst_txtSnipList())); tbl_chatMember.addEventHandler(MouseEvent.MOUSE_CLICKED, new EventHandler() { @@ -8149,9 +8347,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglNewLocator.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(newLocatorPredicate); + chatcontroller.addChatMemberListFilterPredicate(newLocatorPredicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(newLocatorPredicate); + chatcontroller.removeChatMemberListFilterPredicate(newLocatorPredicate); } } }); @@ -8187,9 +8385,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglReachableTropo.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(reachableTropoPredicate); + chatcontroller.addChatMemberListFilterPredicate(reachableTropoPredicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(reachableTropoPredicate); + chatcontroller.removeChatMemberListFilterPredicate(reachableTropoPredicate); } } }); @@ -8206,9 +8404,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglNewBands.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(newBandsPredicate); + chatcontroller.addChatMemberListFilterPredicate(newBandsPredicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(newBandsPredicate); + chatcontroller.removeChatMemberListFilterPredicate(newBandsPredicate); } } }); @@ -8229,9 +8427,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglAsNext5Min.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(asNext5MinPredicate); + chatcontroller.addChatMemberListFilterPredicate(asNext5MinPredicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(asNext5MinPredicate); + chatcontroller.removeChatMemberListFilterPredicate(asNext5MinPredicate); } } }); @@ -8260,8 +8458,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void changed(ObservableValue observableValue, Boolean aBoolean, Boolean t1) { if (tglBtnQRBEnable.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(maxQrbPredicate); - } else chatcontroller.getLst_chatMemberListFilterPredicates().remove(maxQrbPredicate); + chatcontroller.addChatMemberListFilterPredicate(maxQrbPredicate); + } else chatcontroller.removeChatMemberListFilterPredicate(maxQrbPredicate); } }); @@ -8376,9 +8574,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void changed(ObservableValue observableValue, Boolean aBoolean, Boolean t1) { if (chatMemberTableFilterQtfEnableChkbx.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(qtfCheckPredicate); + chatcontroller.addChatMemberListFilterPredicate(qtfCheckPredicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(qtfCheckPredicate); + chatcontroller.removeChatMemberListFilterPredicate(qtfCheckPredicate); // uiHelper_recolorQtfDirectionButtonsExceptThisOne(new Button("justADummy")); } } @@ -8541,7 +8739,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL applyChatMemberFilterPredicates(); chatcontroller.getLst_chatMemberListFilterPredicates().addListener( - (ListChangeListener>) change -> applyChatMemberFilterPredicates() + predicates -> applyChatMemberFilterPredicates() ); TextField chatMemberTableFilterTextField = new TextField(); @@ -8570,12 +8768,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void changed(ObservableValue observableValue, String oldText, String newText) { boolean filterActive = newText != null && !newText.isBlank(); - boolean predicatePresent = chatcontroller.getLst_chatMemberListFilterPredicates().contains(searchTextPredicate); + boolean predicatePresent = chatcontroller.getLst_chatMemberListFilterPredicates().snapshot().contains(searchTextPredicate); if (filterActive && !predicatePresent) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(searchTextPredicate); + chatcontroller.addChatMemberListFilterPredicate(searchTextPredicate); } else if (!filterActive && predicatePresent) { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(searchTextPredicate); + chatcontroller.removeChatMemberListFilterPredicate(searchTextPredicate); } else { /* * The predicate object captures the TextField. When the text changes from @@ -8706,9 +8904,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglwkd.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkdPredicate); + chatcontroller.addChatMemberListFilterPredicate(wkdPredicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkdPredicate); + chatcontroller.removeChatMemberListFilterPredicate(wkdPredicate); } } }); @@ -8730,9 +8928,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglwkd50.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd50Predicate); + chatcontroller.addChatMemberListFilterPredicate(wkd50Predicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd50Predicate); + chatcontroller.removeChatMemberListFilterPredicate(wkd50Predicate); } } }); @@ -8754,9 +8952,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglwkd70.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd70Predicate); + chatcontroller.addChatMemberListFilterPredicate(wkd70Predicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd70Predicate); + chatcontroller.removeChatMemberListFilterPredicate(wkd70Predicate); } } }); @@ -8778,9 +8976,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglwkd144.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd144Predicate); + chatcontroller.addChatMemberListFilterPredicate(wkd144Predicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd144Predicate); + chatcontroller.removeChatMemberListFilterPredicate(wkd144Predicate); } } }); @@ -8803,9 +9001,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglwkd432.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd432Predicate); + chatcontroller.addChatMemberListFilterPredicate(wkd432Predicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd432Predicate); + chatcontroller.removeChatMemberListFilterPredicate(wkd432Predicate); } } }); @@ -8829,9 +9027,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglwkd23.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd23Predicate); + chatcontroller.addChatMemberListFilterPredicate(wkd23Predicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd23Predicate); + chatcontroller.removeChatMemberListFilterPredicate(wkd23Predicate); } } }); @@ -8853,9 +9051,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglwkd13.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd13Predicate); + chatcontroller.addChatMemberListFilterPredicate(wkd13Predicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd13Predicate); + chatcontroller.removeChatMemberListFilterPredicate(wkd13Predicate); } } }); @@ -8877,9 +9075,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglwkd9.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd9Predicate); + chatcontroller.addChatMemberListFilterPredicate(wkd9Predicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd9Predicate); + chatcontroller.removeChatMemberListFilterPredicate(wkd9Predicate); } } }); @@ -8902,9 +9100,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglwkd6.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd6Predicate); + chatcontroller.addChatMemberListFilterPredicate(wkd6Predicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd6Predicate); + chatcontroller.removeChatMemberListFilterPredicate(wkd6Predicate); } } }); @@ -8927,9 +9125,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglwkd3.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd3Predicate); + chatcontroller.addChatMemberListFilterPredicate(wkd3Predicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd3Predicate); + chatcontroller.removeChatMemberListFilterPredicate(wkd3Predicate); } } }); @@ -8952,9 +9150,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void handle(ActionEvent actionEvent) { if (btnTglInactive.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(inactivePredicate); + chatcontroller.addChatMemberListFilterPredicate(inactivePredicate); } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(inactivePredicate); + chatcontroller.removeChatMemberListFilterPredicate(inactivePredicate); } } }); @@ -9053,7 +9251,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * handler. Clear the predicate list explicitly so no stale predicate * can remain active. */ - chatcontroller.getLst_chatMemberListFilterPredicates().clear(); + chatcontroller.clearChatMemberListFilterPredicates(); }); chatMemberTableFilterTextFieldBox.getChildren().addAll(chatMemberTableFilterTextField); @@ -9897,18 +10095,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL updatePstRotatorFields.run(); if (newValue) { - txt_myQTF.textProperty().bind(Bindings.createStringBinding( - () -> Double.toString( - chatcontroller.getChatPreferences().getActualQTF().get() - ), - chatcontroller.getChatPreferences().getActualQTF() - )); + attachQtfFollower(); txt_myQTF.setTooltip( new Tooltip("Current QTF reported by PSTRotator") ); txt_myQTF.setFocusTraversable(false); } else { - txt_myQTF.textProperty().unbind(); + detachQtfFollower(); txt_myQTF.setText( Double.toString( chatcontroller.getChatPreferences().getActualQTF().get() @@ -10463,10 +10656,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL System.out.println("[Main.java, Info]: Win-Test QRG sync enabled: " + newVal); boolean anyActive = chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled() || newVal; if (!anyActive) { - txt_ownqrgMainCategory.textProperty().unbind(); + detachOwnQrgFollower(); txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by hand (watch prefs!)")); } else { - txt_ownqrgMainCategory.textProperty().bind(chatcontroller.getChatPreferences().getMYQRGFirstCat()); + attachOwnQrgFollower(); txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)")); } }); @@ -10582,11 +10775,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL chatcontroller.getChatPreferences().setTrxSynch_ucxLogUDPListenerEnabled(newValue); boolean anyActive = newValue || chatcontroller.getChatPreferences().isLogsynch_wintestQrgSyncEnabled(); if (!anyActive) { - txt_ownqrgMainCategory.textProperty().unbind(); + detachOwnQrgFollower(); txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by hand (watch prefs!)")); System.out.println("[Main.java, Info]: MYQRG will be changed only by User input"); } else { - txt_ownqrgMainCategory.textProperty().bind(chatcontroller.getChatPreferences().getMYQRGFirstCat()); + attachOwnQrgFollower(); txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)")); } } @@ -10608,7 +10801,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // That's the default behaviour of the myqrg textfield if (this.chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled() || this.chatcontroller.getChatPreferences().isLogsynch_wintestQrgSyncEnabled()) { txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)")); - txt_ownqrgMainCategory.textProperty().bind(this.chatcontroller.getChatPreferences().getMYQRGFirstCat()); + attachOwnQrgFollower(); } else { txt_ownqrgMainCategory.setTooltip(new Tooltip("enter your cq qrg here")); } @@ -11186,7 +11379,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL this.chatcontroller .getChatPreferences() .getNotify_DXCSrv_SpottersCallSign() - .getValue() + .get() ); txtFld_notify_DXclusterServerSpottersCallSign @@ -11227,7 +11420,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL chatcontroller .getChatPreferences() .getNotify_DXCSrv_SpottersCallSign() - .getValue() + .get() ); } } @@ -11277,7 +11470,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL ChatMember testSpot = new ChatMember(); testSpot.setFrequency( - new SimpleStringProperty("300") + new SimpleValue<>("300") ); testSpot.setQra("DXC test: You donated $100!"); testSpot.setCallSign("DO5AMF"); @@ -11438,10 +11631,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL TableView tblVw_notify_sniffCallSigns = initNotifyAtCallSignTable(); - tblVw_notify_sniffCallSigns.setItems( + tblVw_notify_sniffCallSigns.setItems(mirrorOf( this.chatcontroller - .getLstNotify_QSOSniffer_sniffedCallSignList() - ); + .getLstNotify_QSOSniffer_sniffedCallSignList())); Button btn_notifySniffCall_addLine = new Button("Add monitored callsign"); @@ -11484,7 +11676,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL boolean duplicate = chatcontroller - .getLstNotify_QSOSniffer_sniffedCallSignList() + .getLstNotify_QSOSniffer_sniffedCallSignList().snapshot() .stream() .anyMatch( existing -> @@ -11543,9 +11735,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL ); TableView tblVw_shortcuts = initShortcutTable(); - tblVw_shortcuts.setItems( - this.chatcontroller.getChatPreferences().getLst_txtShortCutBtnList() - ); + tblVw_shortcuts.setItems(mirrorOf( + this.chatcontroller.getChatPreferences().getLst_txtShortCutBtnList())); Button btn_Short_addLine = new Button("Add shortcut"); btn_Short_addLine.setOnAction(new EventHandler() { @@ -11555,7 +11746,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL "CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"; chatcontroller.getChatPreferences() .getLst_txtShortCutBtnList() - .add(0, newShortcut); + .mutate(list -> list.add(0, newShortcut)); tblVw_shortcuts.getSelectionModel().clearAndSelect(0); tblVw_shortcuts.scrollTo(0); tblVw_shortcuts.edit(0, tblVw_shortcuts.getColumns().get(0)); @@ -11598,9 +11789,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL ); TableView tblVw_textsnippets = initTextSnippetsTable(); - tblVw_textsnippets.setItems( - this.chatcontroller.getChatPreferences().getLst_txtSnipList() - ); + tblVw_textsnippets.setItems(mirrorOf( + this.chatcontroller.getChatPreferences().getLst_txtSnipList())); Button btn_Snip_addLine = new Button("Add new snippet"); btn_Snip_addLine.setOnAction(new EventHandler() { @@ -11610,7 +11800,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL "CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"; chatcontroller.getChatPreferences() .getLst_txtSnipList() - .add(0, newTextSnippet); + .mutate(list -> list.add(0, newTextSnippet)); tblVw_textsnippets.getSelectionModel().clearAndSelect(0); tblVw_textsnippets.scrollTo(0); tblVw_textsnippets.edit(0, tblVw_textsnippets.getColumns().get(0)); @@ -12442,7 +12632,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL String callRaw = sked.getTargetCallsign().trim().toUpperCase(); ChatMember member = null; - for (ChatMember cm : chatcontroller.getLst_chatMemberList()) { + for (ChatMember cm : chatcontroller.getLst_chatMemberList().snapshot()) { if (cm == null || cm.getCallSignRaw() == null) continue; if (callRaw.equals(cm.getCallSignRaw().trim().toUpperCase())) { member = cm; @@ -13064,7 +13254,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL */ private void insertTextSnippet(int snippetIndex) { ChatMember selectedStation = getEffectiveSelectedChatMember(); - ObservableList snippets = chatcontroller.getChatPreferences().getLst_txtSnipList(); + ObservableList snippets = mirrorOf(chatcontroller.getChatPreferences().getLst_txtSnipList()); if (selectedStation == null || selectedStation.getCallSign() == null) { System.out.println("[Main.java, Info]: Text snippet ignored because no station is selected."); diff --git a/src/main/java/kst4contest/view/LayoutAutosave.java b/app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java similarity index 100% rename from src/main/java/kst4contest/view/LayoutAutosave.java rename to app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java diff --git a/src/main/java/kst4contest/view/Main.java b/app-desktop/src/main/java/kst4contest/view/Main.java similarity index 100% rename from src/main/java/kst4contest/view/Main.java rename to app-desktop/src/main/java/kst4contest/view/Main.java diff --git a/src/main/java/kst4contest/view/MessageTextTableCell.java b/app-desktop/src/main/java/kst4contest/view/MessageTextTableCell.java similarity index 100% rename from src/main/java/kst4contest/view/MessageTextTableCell.java rename to app-desktop/src/main/java/kst4contest/view/MessageTextTableCell.java diff --git a/src/main/java/kst4contest/view/OperatorProfileBootstrap.java b/app-desktop/src/main/java/kst4contest/view/OperatorProfileBootstrap.java similarity index 100% rename from src/main/java/kst4contest/view/OperatorProfileBootstrap.java rename to app-desktop/src/main/java/kst4contest/view/OperatorProfileBootstrap.java diff --git a/src/main/java/kst4contest/view/OperatorProfileChoiceRequester.java b/app-desktop/src/main/java/kst4contest/view/OperatorProfileChoiceRequester.java similarity index 100% rename from src/main/java/kst4contest/view/OperatorProfileChoiceRequester.java rename to app-desktop/src/main/java/kst4contest/view/OperatorProfileChoiceRequester.java diff --git a/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java b/app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java similarity index 100% rename from src/main/java/kst4contest/view/OperatorProfilePickerDialog.java rename to app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java diff --git a/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java b/app-desktop/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java similarity index 100% rename from src/main/java/kst4contest/view/OperatorProfileSettingsPane.java rename to app-desktop/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java diff --git a/src/main/java/kst4contest/view/PrivateMessageRowStyleResolver.java b/app-desktop/src/main/java/kst4contest/view/PrivateMessageRowStyleResolver.java similarity index 100% rename from src/main/java/kst4contest/view/PrivateMessageRowStyleResolver.java rename to app-desktop/src/main/java/kst4contest/view/PrivateMessageRowStyleResolver.java diff --git a/src/main/java/kst4contest/view/TableLayoutManager.java b/app-desktop/src/main/java/kst4contest/view/TableLayoutManager.java similarity index 100% rename from src/main/java/kst4contest/view/TableLayoutManager.java rename to app-desktop/src/main/java/kst4contest/view/TableLayoutManager.java diff --git a/src/main/java/kst4contest/view/TimelineView.java b/app-desktop/src/main/java/kst4contest/view/TimelineView.java similarity index 100% rename from src/main/java/kst4contest/view/TimelineView.java rename to app-desktop/src/main/java/kst4contest/view/TimelineView.java diff --git a/src/main/java/kst4contest/view/TruncatedTextTableCell.java b/app-desktop/src/main/java/kst4contest/view/TruncatedTextTableCell.java similarity index 100% rename from src/main/java/kst4contest/view/TruncatedTextTableCell.java rename to app-desktop/src/main/java/kst4contest/view/TruncatedTextTableCell.java diff --git a/src/main/java/kst4contest/view/TruncatedTextTooltipSupport.java b/app-desktop/src/main/java/kst4contest/view/TruncatedTextTooltipSupport.java similarity index 100% rename from src/main/java/kst4contest/view/TruncatedTextTooltipSupport.java rename to app-desktop/src/main/java/kst4contest/view/TruncatedTextTooltipSupport.java diff --git a/src/main/java/kst4contest/view/map/MapHtmlResources.java b/app-desktop/src/main/java/kst4contest/view/map/MapHtmlResources.java similarity index 100% rename from src/main/java/kst4contest/view/map/MapHtmlResources.java rename to app-desktop/src/main/java/kst4contest/view/map/MapHtmlResources.java diff --git a/src/main/java/kst4contest/view/map/PathProfileChart.java b/app-desktop/src/main/java/kst4contest/view/map/PathProfileChart.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathProfileChart.java rename to app-desktop/src/main/java/kst4contest/view/map/PathProfileChart.java diff --git a/src/main/java/kst4contest/view/map/StationMapBridge.java b/app-desktop/src/main/java/kst4contest/view/map/StationMapBridge.java similarity index 79% rename from src/main/java/kst4contest/view/map/StationMapBridge.java rename to app-desktop/src/main/java/kst4contest/view/map/StationMapBridge.java index 89b072f6..c574502f 100644 --- a/src/main/java/kst4contest/view/map/StationMapBridge.java +++ b/app-desktop/src/main/java/kst4contest/view/map/StationMapBridge.java @@ -3,7 +3,6 @@ package kst4contest.view.map; import javafx.animation.PauseTransition; import javafx.application.Platform; import javafx.beans.value.ChangeListener; -import javafx.collections.ListChangeListener; import javafx.scene.control.TableView; import javafx.util.Duration; import kst4contest.controller.ChatController; @@ -20,8 +19,6 @@ import java.util.Objects; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; import java.util.function.Supplier; -import java.util.logging.Level; -import java.util.logging.Logger; import kst4contest.model.Band; import java.util.function.Predicate; @@ -37,9 +34,6 @@ import java.util.function.Predicate; */ public final class StationMapBridge { - /** Application logger for map refresh diagnostics. */ - private static final Logger LOGGER = - Logger.getLogger(StationMapBridge.class.getName()); private final AtomicLong pathAnalysisGeneration = new AtomicLong(0); @@ -54,9 +48,6 @@ public final class StationMapBridge { private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder(); - /** Tracks one deferred refresh while the map window is hidden. */ - private final StationMapPerformanceSupport.RefreshState refreshState = - new StationMapPerformanceSupport.RefreshState(); private String lastPathAnalysisRequestSignature = ""; @@ -68,10 +59,10 @@ public final class StationMapBridge { * animation and the registered listeners would keep a discarded runtime reachable * after an operator profile switch. */ - private ListChangeListener chatMemberListListener; - private ChangeListener selectedChatMemberListener; - private ChangeListener antennaDirectionListener; - private ListChangeListener> filterPredicateListener; + private Consumer> chatMemberListListener; + private java.util.function.Consumer selectedChatMemberListener; + private java.util.function.Consumer antennaDirectionListener; + private Consumer>> filterPredicateListener; public StationMapBridge(ChatController chatController, TableView chatMemberTable, @@ -100,17 +91,17 @@ public final class StationMapBridge { stationMapView.setOnResetView(this::handleMapReset); - chatMemberListListener = change -> scheduleRefresh(); - chatController.getLst_chatMemberSortedFilteredList().addListener(chatMemberListListener); + chatMemberListListener = members -> scheduleRefresh(); + chatController.getLst_chatMemberList().addListener(chatMemberListListener); - selectedChatMemberListener = (obs, oldValue, newValue) -> requestImmediateRefresh(); - chatController.getScoreService().selectedChatMemberProperty() + selectedChatMemberListener = newValue -> requestImmediateRefresh(); + chatController.getScoreService().selectedChatMember() .addListener(selectedChatMemberListener); - antennaDirectionListener = (obs, oldValue, newValue) -> scheduleRefresh(); + antennaDirectionListener = newValue -> scheduleRefresh(); chatController.getChatPreferences().getActualQTF().addListener(antennaDirectionListener); - filterPredicateListener = change -> requestImmediateRefresh(); + filterPredicateListener = predicates -> requestImmediateRefresh(); chatController.getLst_chatMemberListFilterPredicates().addListener(filterPredicateListener); requestImmediateRefresh(); @@ -132,12 +123,12 @@ public final class StationMapBridge { stationMapView.setOnResetView(null); if (chatMemberListListener != null) { - chatController.getLst_chatMemberSortedFilteredList().removeListener(chatMemberListListener); + chatController.getLst_chatMemberList().removeListener(chatMemberListListener); chatMemberListListener = null; } if (selectedChatMemberListener != null) { - chatController.getScoreService().selectedChatMemberProperty() + chatController.getScoreService().selectedChatMember() .removeListener(selectedChatMemberListener); selectedChatMemberListener = null; } @@ -184,14 +175,10 @@ public final class StationMapBridge { public void showWindow() { stationMapView.showWindow(); - if (refreshState.consumeDirtyOnShow()) { - requestImmediateRefresh(); - } + requestImmediateRefresh(); } public void hideWindow() { - refreshCoalescer.stop(); - refreshState.markDirty(); stationMapView.hideWindow(); } @@ -241,35 +228,19 @@ public final class StationMapBridge { } private void scheduleRefresh() { - final Runnable scheduleAction = () -> { - if (!stationMapView.isShowing()) { - refreshCoalescer.stop(); - refreshState.markDirty(); - return; - } - refreshCoalescer.playFromStart(); - }; - if (Platform.isFxApplicationThread()) { - scheduleAction.run(); + refreshCoalescer.playFromStart(); } else { - Platform.runLater(scheduleAction); + Platform.runLater(() -> refreshCoalescer.playFromStart()); } } private void refreshNow() { - if (!refreshState.shouldRefreshNow(stationMapView.isShowing())) { - refreshCoalescer.stop(); - return; - } + List visibleChatMembers = new ArrayList<>(chatController.visibleChatMembers()); + ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember(); + EnumSet selectedBands = chatController.getReachabilityService().getEnabledStationBands(); - final long refreshStart = System.nanoTime(); - final long snapshotStart = refreshStart; - final List visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList()); - final ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember(); - final EnumSet selectedBands = chatController.getReachabilityService().getEnabledStationBands(); - - final List snapshots = snapshotBuilder.buildSnapshots( + List snapshots = snapshotBuilder.buildSnapshots( visibleChatMembers, selectedChatMember, selectedBands @@ -277,19 +248,17 @@ public final class StationMapBridge { MapCallsignRawSnapshot selectedSnapshot = null; if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) { - final String selectedCallsignRaw = normalizeCallsignRaw(selectedChatMember.getCallSignRaw()); + String selectedCallsignRaw = normalizeCallsignRaw(selectedChatMember.getCallSignRaw()); selectedSnapshot = snapshots.stream() .filter(snapshot -> snapshot.callSignRaw().equals(selectedCallsignRaw)) .findFirst() .orElse(null); } - final long snapshotNanos = System.nanoTime() - snapshotStart; - final boolean filteredViewActive = visibleChatMembers.size() < chatController.getLst_chatMemberList().size(); + boolean filteredViewActive = visibleChatMembers.size() < chatController.getLst_chatMemberList().size(); - final ChatPreferences preferences = chatController.getChatPreferences(); + ChatPreferences preferences = chatController.getChatPreferences(); - final long renderStart = System.nanoTime(); stationMapView.refreshMap( snapshots, selectedSnapshot, @@ -299,22 +268,8 @@ public final class StationMapBridge { preferences.getStn_maxQRBDefault(), filteredViewActive ); - final long renderNanos = System.nanoTime() - renderStart; requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot); - - final long totalNanos = System.nanoTime() - refreshStart; - if (preferences.isMessageHandling_debugModeToFileEnabled() - && StationMapPerformanceSupport.isSlowRefresh(totalNanos) - && LOGGER.isLoggable(Level.FINE)) { - LOGGER.log(Level.FINE, - StationMapPerformanceSupport.formatSlowRefreshMessage( - totalNanos, - snapshotNanos, - renderNanos, - visibleChatMembers.size(), - snapshots.size())); - } } /** @@ -389,6 +344,8 @@ public final class StationMapBridge { } private void handleMapCallsignSelection(String callSignRaw) { + System.out.println("########################### map selected callsign " + callSignRaw); + ChatMember resolved = resolveBestChatMember(callSignRaw); if (resolved == null) { return; @@ -445,13 +402,11 @@ public final class StationMapBridge { return visibleBest; } - synchronized (chatController.getLst_chatMemberList()) { - return chatController.getLst_chatMemberList().stream() - .filter(chatMember -> chatMember != null) - .filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw()))) - .max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch)) - .orElse(null); - } + return chatController.getLst_chatMemberList().snapshot().stream() + .filter(chatMember -> chatMember != null) + .filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw()))) + .max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch)) + .orElse(null); } private String normalizeCallsignRaw(String callSignRaw) { @@ -523,4 +478,4 @@ public final class StationMapBridge { } -} +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/StationMapView.java b/app-desktop/src/main/java/kst4contest/view/map/StationMapView.java similarity index 100% rename from src/main/java/kst4contest/view/map/StationMapView.java rename to app-desktop/src/main/java/kst4contest/view/map/StationMapView.java diff --git a/src/main/resources/KST4ContestDefaultDay.css b/app-desktop/src/main/resources/KST4ContestDefaultDay.css similarity index 100% rename from src/main/resources/KST4ContestDefaultDay.css rename to app-desktop/src/main/resources/KST4ContestDefaultDay.css diff --git a/src/main/resources/KST4ContestDefaultEvening.css b/app-desktop/src/main/resources/KST4ContestDefaultEvening.css similarity index 100% rename from src/main/resources/KST4ContestDefaultEvening.css rename to app-desktop/src/main/resources/KST4ContestDefaultEvening.css diff --git a/src/main/resources/leaflet-1.9.4.css b/app-desktop/src/main/resources/leaflet-1.9.4.css similarity index 100% rename from src/main/resources/leaflet-1.9.4.css rename to app-desktop/src/main/resources/leaflet-1.9.4.css diff --git a/src/main/resources/leaflet-1.9.4.js b/app-desktop/src/main/resources/leaflet-1.9.4.js similarity index 100% rename from src/main/resources/leaflet-1.9.4.js rename to app-desktop/src/main/resources/leaflet-1.9.4.js diff --git a/src/main/resources/web/leaflet/leaflet.css b/app-desktop/src/main/resources/web/leaflet/leaflet.css similarity index 100% rename from src/main/resources/web/leaflet/leaflet.css rename to app-desktop/src/main/resources/web/leaflet/leaflet.css diff --git a/src/main/resources/web/leaflet/leaflet.js b/app-desktop/src/main/resources/web/leaflet/leaflet.js similarity index 100% rename from src/main/resources/web/leaflet/leaflet.js rename to app-desktop/src/main/resources/web/leaflet/leaflet.js diff --git a/src/test/java/kst4contest/test/MapHtmlResourcesContractTest.java b/app-desktop/src/test/java/kst4contest/test/MapHtmlResourcesContractTest.java similarity index 100% rename from src/test/java/kst4contest/test/MapHtmlResourcesContractTest.java rename to app-desktop/src/test/java/kst4contest/test/MapHtmlResourcesContractTest.java diff --git a/src/test/java/kst4contest/test/PrivateMessageRowStyleResolverTest.java b/app-desktop/src/test/java/kst4contest/test/PrivateMessageRowStyleResolverTest.java similarity index 100% rename from src/test/java/kst4contest/test/PrivateMessageRowStyleResolverTest.java rename to app-desktop/src/test/java/kst4contest/test/PrivateMessageRowStyleResolverTest.java diff --git a/src/test/java/kst4contest/view/CommandLineOptionsTest.java b/app-desktop/src/test/java/kst4contest/view/CommandLineOptionsTest.java similarity index 100% rename from src/test/java/kst4contest/view/CommandLineOptionsTest.java rename to app-desktop/src/test/java/kst4contest/view/CommandLineOptionsTest.java diff --git a/app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java b/app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java new file mode 100644 index 00000000..2ce5773a --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java @@ -0,0 +1,137 @@ +package kst4contest.view; + +import javafx.collections.ObservableList; +import kst4contest.observe.DirectUiDispatcher; +import kst4contest.observe.SimpleRoster; +import org.junit.jupiter.api.Test; + +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +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"); + } + + @Test + void anUnchangedResultProducesNoListChangeEvent() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add("DN9APW"); + FxRosterBinding binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); + + AtomicInteger changes = new AtomicInteger(); + binding.list().addListener((javafx.collections.ListChangeListener) change -> changes.incrementAndGet()); + + binding.refresh(); + + assertEquals(0, changes.get(), + "an unchanged station list must not make the TableView drop its selection"); + } + + @Test + void aDerivedBindingAppliesTheDerivationAndFollowsItsSources() { + SimpleRoster members = new SimpleRoster<>(); + SimpleRoster filters = new SimpleRoster<>(); + members.add("DO5AMF"); + members.add("DN9APW"); + + FxRosterBinding binding = FxRosterBinding.derived( + new DirectUiDispatcher(), + () -> members.snapshot().stream() + .filter(call -> filters.snapshot().isEmpty() || filters.snapshot().contains(call)) + .sorted(Comparator.naturalOrder()) + .toList(), + members, filters); + + assertEquals(List.of("DN9APW", "DO5AMF"), List.copyOf(binding.list())); + + filters.add("DO5AMF"); + assertEquals(List.of("DO5AMF"), List.copyOf(binding.list()), + "a change of a source roster must recompute the derived content"); + + members.add("DL1ABC"); + assertEquals(List.of("DO5AMF"), List.copyOf(binding.list())); + } + + @Test + void refreshRecomputesWithoutASourceChange() { + SimpleRoster members = new SimpleRoster<>(); + members.add("DN9APW"); + members.add("DO5AMF"); + String[] wanted = { "DN9APW" }; + + FxRosterBinding binding = FxRosterBinding.derived( + new DirectUiDispatcher(), + () -> members.snapshot().stream().filter(call -> call.equals(wanted[0])).toList(), + members); + + assertEquals(List.of("DN9APW"), List.copyOf(binding.list())); + + wanted[0] = "DO5AMF"; + binding.refresh(); + + assertEquals(List.of("DO5AMF"), List.copyOf(binding.list()), + "a newly set filter must be visible without waiting for the next roster change"); + } + + @Test + void aDisposedDerivedBindingDetachesFromEverySource() { + SimpleRoster members = new SimpleRoster<>(); + SimpleRoster filters = new SimpleRoster<>(); + + FxRosterBinding binding = FxRosterBinding.derived( + new DirectUiDispatcher(), members::snapshot, members, filters); + + binding.dispose(); + members.add("DN9APW"); + filters.add("DN9APW"); + + assertTrue(binding.list().isEmpty()); + } +} diff --git a/src/test/java/kst4contest/view/MacOsConnectionStateMenuTitleTest.java b/app-desktop/src/test/java/kst4contest/view/MacOsConnectionStateMenuTitleTest.java similarity index 100% rename from src/test/java/kst4contest/view/MacOsConnectionStateMenuTitleTest.java rename to app-desktop/src/test/java/kst4contest/view/MacOsConnectionStateMenuTitleTest.java diff --git a/src/test/java/kst4contest/view/OperatorProfileBootstrapTest.java b/app-desktop/src/test/java/kst4contest/view/OperatorProfileBootstrapTest.java similarity index 100% rename from src/test/java/kst4contest/view/OperatorProfileBootstrapTest.java rename to app-desktop/src/test/java/kst4contest/view/OperatorProfileBootstrapTest.java diff --git a/src/test/java/kst4contest/view/TableLayoutManagerTest.java b/app-desktop/src/test/java/kst4contest/view/TableLayoutManagerTest.java similarity index 100% rename from src/test/java/kst4contest/view/TableLayoutManagerTest.java rename to app-desktop/src/test/java/kst4contest/view/TableLayoutManagerTest.java diff --git a/src/test/java/kst4contest/view/TruncatedTextTableCellTest.java b/app-desktop/src/test/java/kst4contest/view/TruncatedTextTableCellTest.java similarity index 100% rename from src/test/java/kst4contest/view/TruncatedTextTableCellTest.java rename to app-desktop/src/test/java/kst4contest/view/TruncatedTextTableCellTest.java diff --git a/src/test/java/kst4contest/view/map/TerrainProfileCacheRepositoryTest.java b/app-desktop/src/test/java/kst4contest/view/map/TerrainProfileCacheRepositoryTest.java similarity index 100% rename from src/test/java/kst4contest/view/map/TerrainProfileCacheRepositoryTest.java rename to app-desktop/src/test/java/kst4contest/view/map/TerrainProfileCacheRepositoryTest.java diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..ed7ff064 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + java + alias(libs.plugins.spotbugs) apply false +} + +allprojects { + group = providers.gradleProperty("group").get() + version = providers.gradleProperty("version").get() + + repositories { + mavenCentral() + } +} + +val pmdVersion = libs.versions.pmd.get() +val spotbugsVersion = libs.versions.spotbugs.get() + +subprojects { + apply(plugin = "java") + apply(plugin = "com.github.spotbugs") + apply(plugin = "pmd") + + // Both tools report only; the Maven build had the failing goals commented out. + extensions.configure { + toolVersion = pmdVersion + ruleSetFiles = files(rootProject.file("pmd-ruleset.xml")) + ruleSets = emptyList() + isIgnoreFailures = true + } + + extensions.configure { + toolVersion.set(spotbugsVersion) + ignoreFailures.set(true) + } + + extensions.configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } + } + + tasks.withType().configureEach { + useJUnitPlatform() + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 00000000..180bd184 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + java +} + +// No user-interface technology here: the domain layer observes through +// kst4contest.observe and hands UI work over via UiDispatcher. + +dependencies { + // Test classes live under src/main/java in this project, so JUnit and Mockito + // are needed on the main compile classpath. This mirrored pom.xml before Gradle. + implementation(libs.junit.jupiter.api) + implementation(libs.mockito.core.compile) + implementation(libs.jetbrains.annotations) + implementation(libs.sqlite.jdbc) + implementation(libs.jlayer) + testImplementation(libs.junit.jupiter) + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.junit.jupiter) + // Gradle 9 no longer adds the launcher implicitly. + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/src/main/java/kst4contest/ApplicationConstants.java b/core/src/main/java/kst4contest/ApplicationConstants.java similarity index 100% rename from src/main/java/kst4contest/ApplicationConstants.java rename to core/src/main/java/kst4contest/ApplicationConstants.java diff --git a/src/main/java/kst4contest/controller/ActiveOperatorProfile.java b/core/src/main/java/kst4contest/controller/ActiveOperatorProfile.java similarity index 100% rename from src/main/java/kst4contest/controller/ActiveOperatorProfile.java rename to core/src/main/java/kst4contest/controller/ActiveOperatorProfile.java diff --git a/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java b/core/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java similarity index 100% rename from src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java rename to core/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java diff --git a/src/main/java/kst4contest/controller/BeaconTask.java b/core/src/main/java/kst4contest/controller/BeaconTask.java similarity index 100% rename from src/main/java/kst4contest/controller/BeaconTask.java rename to core/src/main/java/kst4contest/controller/BeaconTask.java diff --git a/src/main/java/kst4contest/controller/ChatController.java b/core/src/main/java/kst4contest/controller/ChatController.java similarity index 90% rename from src/main/java/kst4contest/controller/ChatController.java rename to core/src/main/java/kst4contest/controller/ChatController.java index 63d98087..d286c31a 100644 --- a/src/main/java/kst4contest/controller/ChatController.java +++ b/core/src/main/java/kst4contest/controller/ChatController.java @@ -11,13 +11,11 @@ import java.util.*; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; -import javafx.application.Platform; -import javafx.beans.property.*; -import javafx.collections.FXCollections; -import javafx.collections.ListChangeListener; -import javafx.collections.ObservableList; -import javafx.collections.transformation.FilteredList; -import javafx.collections.transformation.SortedList; +import kst4contest.observe.DirectUiDispatcher; +import kst4contest.observe.SimpleRoster; +import kst4contest.observe.UiDispatcher; +import kst4contest.observe.MutableValue; +import kst4contest.observe.ObservableRoster; import kst4contest.ApplicationConstants; import kst4contest.controller.interfaces.PstRotatorEventListener; import kst4contest.locatorUtils.DirectionUtils; @@ -28,7 +26,6 @@ import kst4contest.test.MockKstServer; import kst4contest.utils.ApplicationFileUtils; import kst4contest.utils.PlayAudioUtils; import kst4contest.locatorUtils.Location; -import kst4contest.view.Kst4ContestApplication; import java.io.*; import java.util.concurrent.ScheduledExecutorService; @@ -40,6 +37,7 @@ import kst4contest.logic.FrequencyTextParser; import java.util.logging.Level; import java.util.logging.Logger; import java.util.concurrent.ScheduledFuture; +import kst4contest.observe.SimpleValue; @@ -97,15 +95,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList */ private volatile double lastReportedRotatorAzimuth = Double.NaN; - private Kst4ContestApplication view; //effectively final, for recoupling of the controller to the view - private StatusUpdateListener statusListener; //update info interface for the threads - public void setView(Kst4ContestApplication view) { - this.view = view; - } - private UpdateInformation updateInformation; private ChatPreferences chatPreferences; @@ -604,12 +596,12 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList * must be updated on the JavaFX Application Thread. */ Runnable fxUpdate = () -> - chatPreferences.getActualQTF().setValue(normalizedAzimuth); + chatPreferences.getActualQTF().set(normalizedAzimuth); - if (Platform.isFxApplicationThread()) { + if (uiDispatcher.isUiThread()) { fxUpdate.run(); } else { - Platform.runLater(fxUpdate); + uiDispatcher.runOnUi(fxUpdate); } } @@ -1154,7 +1146,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList // } // public void addSked(ContestSked sked) { -// Platform.runLater(() -> { +// uiDispatcher.runOnUi(() -> { // this.activeSkeds.add(sked); // runContextLoopCycle(); // Trigger sofort // }); @@ -1164,10 +1156,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList // private ScheduledExecutorService contextLoopService; - private ObservableList activeSkeds = - FXCollections.synchronizedObservableList(FXCollections.observableArrayList()); + private final SimpleRoster activeSkeds = new SimpleRoster<>(); - public ObservableList getActiveSkeds() { + public ObservableRoster getActiveSkeds() { return activeSkeds; } @@ -1188,7 +1179,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList } public void addSked(ContestSked sked) { - Platform.runLater(() -> { + uiDispatcher.runOnUi(() -> { this.activeSkeds.add(sked); scoreService.requestRecompute("sked-added"); }); @@ -1649,16 +1640,22 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList } public List snapshotActiveSkeds() { - synchronized (activeSkeds) { - return new ArrayList<>(activeSkeds); - } + return new ArrayList<>(activeSkeds.snapshot()); } public void requestRemoveExpiredSkeds(long nowEpochMs) { - Platform.runLater(() -> { - synchronized (activeSkeds) { - activeSkeds.removeIf(sked -> (nowEpochMs - sked.getSkedTimeEpoch()) > 300_000); + uiDispatcher.runOnUi(() -> { + /* + * Only mutate when something really expired. An unconditional mutate would + * wake every roster listener on each cycle of the periodic score service. + */ + boolean anyExpired = activeSkeds.snapshot().stream() + .anyMatch(sked -> (nowEpochMs - sked.getSkedTimeEpoch()) > 300_000); + if (!anyExpired) { + return; } + activeSkeds.mutate(list -> + list.removeIf(sked -> (nowEpochMs - sked.getSkedTimeEpoch()) > 300_000)); }); } @@ -1742,24 +1739,21 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList // ******All abstract types below here are used by the messageprocessor! // *************** - private ObservableList lst_globalChatMessageList = FXCollections.observableArrayList(); //All chatmessages will be put in there, later create filtered message lists -// private ObservableList lst_toAllMessageList = FXCollections.observableArrayList(); // directed to all - // (beacon) - private FilteredList lst_toAllMessageList = new FilteredList<>(lst_globalChatMessageList); // directed to all + private final SimpleRoster lst_globalChatMessageList = new SimpleRoster<>(); //All chatmessages will be put in there, later create filtered message lists -// private ObservableList lst_toMeMessageList = FXCollections.observableArrayList(); // directed to my - // call - private FilteredList lst_toMeMessageList = new FilteredList<>(lst_globalChatMessageList); + /* + * The four message views used to be FilteredList views on the global message + * list. They are now stored predicates: the derived list is computed on demand + * from lst_globalChatMessageList.snapshot(). A null predicate lets everything + * through, exactly like FilteredList.setPredicate(null) did. + */ + private volatile Predicate toAllMessageFilter; // directed to all (beacon) + private volatile Predicate toMeMessageFilter; // directed to my call + private volatile Predicate toOtherMessageFilter; // directed to a call but not mine + private volatile Predicate selectedCallSignInfoFilter; - private FilteredList lst_selectedCallSignInfofilteredMessageList = new FilteredList<>(lst_globalChatMessageList); // directed to all - -// private ObservableList lst_toOtherMessageList = FXCollections.observableArrayList(); // directed to a - // call but not - // mine - private FilteredList lst_toOtherMessageList = new FilteredList<>(lst_globalChatMessageList); - -// private ObservableList lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList(); -private ObservableList +// private SimpleRoster lstNotify_QSOSniffer_sniffedCallSignList = new SimpleRoster<>(); +private SimpleRoster lstNotify_QSOSniffer_sniffedCallSignList; /** * we do some trick here with the chatmemberlist to not make it neccessary to change all boolean properties if the @@ -1768,27 +1762,34 @@ private ObservableList */ // private ObservableList chatMemberList = FXCollections.observableArrayList(workedInfoChange -> new Observable[] {workedInfoChange.workedInfoChangeFireListEventTriggerProperty()}); // List of active stations // in chat - private ObservableList chatMemberList = FXCollections.observableArrayList(); // List of active stations + /** + * The canonical station list. SimpleRoster serialises its own writes, so the + * former FXCollections.synchronizedObservableList wrapper is gone and + * lst_chatMemberList is this very field. + */ + private final SimpleRoster chatMemberList = new SimpleRoster<>(); // List of active stations + private final SimpleRoster> lst_chatMemberListFilterPredicates = new SimpleRoster<>(); + private final SimpleRoster lst_clusterMemberList = new SimpleRoster<>(); - // of active stn in chat - private FilteredList lst_chatMemberListFiltered = new FilteredList(chatMemberList); - private SortedList lst_chatMemberSortedFilteredList = new SortedList(lst_chatMemberListFiltered); - - private ObservableList lst_chatMemberList = FXCollections.synchronizedObservableList(chatMemberList); // List - private ObservableList> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList(); - private ObservableList lst_clusterMemberList = FXCollections.observableArrayList(); + /** + * The order the station list is presented in. Fed by the TableView column + * headers, which used to drive the SortedList comparator directly. A null + * comparator keeps the incoming order, exactly like an unset SortedList + * comparator did. + */ + private volatile Comparator chatMemberOrder; /* * Thread-safe active-member model. * - * MessageBusManagementThread must not use the JavaFX TableView backing list as - * its primary data model. Otherwise every add/remove fires FilteredList, - * SortedList and TableView selection listeners on the MessageBus thread and JavaFX - * can throw "Not on FX application thread" or internal listener NPEs. + * MessageBusManagementThread must not use the UI-bound station list as its + * primary data model. Otherwise every add/remove wakes the mirror and the + * TableView selection listeners on the MessageBus thread, and JavaFX can throw + * "Not on FX application thread" or internal listener NPEs. * - * This map is the worker-thread safe source of truth. The ObservableList below - * remains a UI mirror and is mutated only on the JavaFX application thread. + * This map is the worker-thread safe source of truth. The roster below remains + * a UI mirror and is mutated only through the UI dispatcher. */ private final java.util.concurrent.ConcurrentMap activeChatMembersByCallAndCategory = new java.util.concurrent.ConcurrentHashMap<>(); @@ -1799,8 +1800,8 @@ private ObservableList * Do not write directly to lst_globalChatMessageList from worker threads. * Use publishChatMessage(...) instead. * - * The actual ObservableList mutation is batched and executed on the JavaFX - * application thread. The visible list order remains newest-first. + * The actual roster mutation is batched and executed on the user-interface + * thread. The visible list order remains newest-first. */ private final Object pendingChatMessagesLock = new Object(); private final List pendingChatMessages = new ArrayList<>(); @@ -1817,7 +1818,7 @@ private ObservableList private final List pendingClusterMessages = new ArrayList<>(); private boolean clusterMessageFlushScheduled = false; - private ObservableList lst_DBBasedWkdCallSignList = FXCollections.observableArrayList(); + private final SimpleRoster lst_DBBasedWkdCallSignList = new SimpleRoster<>(); // private HashMap map_ucxLogInfoWorkedCalls = new HashMap(); //Destination of ucx-log worked-messages @@ -1835,10 +1836,10 @@ private ObservableList return; } - if (Platform.isFxApplicationThread()) { + if (uiDispatcher.isUiThread()) { action.run(); } else { - Platform.runLater(action); + uiDispatcher.runOnUi(action); } } @@ -1931,20 +1932,20 @@ private ObservableList activeChatMembersByCallAndCategory.put(key, member); - runOnFxThread(() -> { - int existingIndex = findChatMemberIndexInUiListByKey(key); + runOnFxThread(() -> chatMemberList.mutate(list -> { + int existingIndex = findChatMemberIndexInList(list, key); if (existingIndex >= 0) { - lst_chatMemberList.set(existingIndex, member); + list.set(existingIndex, member); } else { - lst_chatMemberList.add(member); + list.add(member); } - }); + })); } /** * Removes an active chat member from the worker-thread model and from the UI - * mirror. Removal from the ObservableList is always performed on the FX thread - * because the list is bound to FilteredList/SortedList/TableView. + * mirror. Removal from the roster is always performed on the user-interface + * thread because the roster is mirrored into the TableView. */ public boolean removeActiveChatMember(ChatMember member) { String key = buildActiveChatMemberKey(member); @@ -1953,17 +1954,18 @@ private ObservableList } ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key); - runOnFxThread(() -> lst_chatMemberList.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember)))); + runOnFxThread(() -> chatMemberList.mutate(list -> + list.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember))))); return removedMember != null; } /** * Clears the active-member model and the JavaFX UI mirror. Use this instead of - * getLst_chatMemberList().clear() from timers or worker threads. + * getLst_chatMemberList() mutations from timers or worker threads. */ public void clearActiveChatMembers() { activeChatMembersByCallAndCategory.clear(); - runOnFxThread(() -> lst_chatMemberList.clear()); + runOnFxThread(chatMemberList::clear); } /** @@ -2009,12 +2011,18 @@ private ObservableList if (!on4KstConnectionManager.isActiveSession(connectionSessionId)) { return; } - lst_chatMemberList.removeIf(member -> member != null - && member.getChatCategory() != null - && member.getChatCategory().getCategoryNumber() == categoryNumber); - lst_chatMemberList.addAll(safeMembers); + /* + * One batch: replacing a whole category must wake the mirror once, not + * once per station. An ON4KST user list can hold several thousand of them. + */ + chatMemberList.mutate(list -> { + list.removeIf(member -> member != null + && member.getChatCategory() != null + && member.getChatCategory().getCategoryNumber() == categoryNumber); + list.addAll(safeMembers); + }); if (workedDataFromDatabase != null) { - getLst_DBBasedWkdCallSignList().setAll( + setLst_DBBasedWkdCallSignList( workedDataFromDatabase.values()); } fireUserListUpdate("Complete ON4KST user list received"); @@ -2181,7 +2189,7 @@ private ObservableList int changedMembers = markSimpleLogWorkedMembers( activeChatMembersByCallAndCategory.values(), normalizedWorkedBaseCalls); int changedClusterMessages = markSimpleLogWorkedClusterMessages( - lst_clusterMemberList, normalizedWorkedBaseCalls); + lst_clusterMemberList.snapshot(), normalizedWorkedBaseCalls); if (changedMembers > 0) { fireUserListUpdate("Simplelogfile Worked status updated"); @@ -2410,22 +2418,22 @@ private ObservableList continue; } member.addKnownFrequency(detectedBand, detectedFrequencyMhz); - member.setFrequency(new SimpleStringProperty(displayFrequency)); + member.setFrequency(new SimpleValue<>(displayFrequency)); } // Also cover dummy senders that are not in the active model, e.g. [n/a] senders. sender.addKnownFrequency(detectedBand, detectedFrequencyMhz); - sender.setFrequency(new SimpleStringProperty(displayFrequency)); + sender.setFrequency(new SimpleValue<>(displayFrequency)); fireUserListUpdate("Frequency detected"); } - private int findChatMemberIndexInUiListByKey(String key) { + private int findChatMemberIndexInList(List list, String key) { if (key == null) { return -1; } - for (int i = 0; i < lst_chatMemberList.size(); i++) { - if (key.equals(buildActiveChatMemberKey(lst_chatMemberList.get(i)))) { + for (int i = 0; i < list.size(); i++) { + if (key.equals(buildActiveChatMemberKey(list.get(i)))) { return i; } } @@ -2459,13 +2467,15 @@ private ObservableList return resultingIndexes; } - for (Iterator iterator = lst_chatMemberList.iterator(); iterator.hasNext();) { + List currentMembers = chatMemberList.snapshot(); + + for (Iterator iterator = currentMembers.iterator(); iterator.hasNext();) { ChatMember chatMember = (ChatMember) iterator.next(); if (chatMember.getCallSignRaw().equals(lookForThis.getCallSignRaw())) { //Change for stations with -2 or -70 in logincallsign // System.out // .println("chtctrlr: Found raw " + chatMember.getCallSignRaw() + " // " + lookForThis.getCallSign()); - resultingIndexes.add(lst_chatMemberList.indexOf(chatMember)); + resultingIndexes.add(currentMembers.indexOf(chatMember)); } else { @@ -2508,10 +2518,10 @@ private ObservableList return; } - if (Platform.isFxApplicationThread()) { + if (uiDispatcher.isUiThread()) { statusListener.onUserListUpdated(reason); } else { - Platform.runLater(() -> statusListener.onUserListUpdated(reason)); + uiDispatcher.runOnUi(() -> statusListener.onUserListUpdated(reason)); } } @@ -2560,24 +2570,65 @@ private ObservableList // } - public FilteredList getLst_selectedCallSignInfofilteredMessageList() { - return lst_selectedCallSignInfofilteredMessageList; - } - - public void setLst_selectedCallSignInfofilteredMessageList(FilteredList lst_selectedCallSignInfofilteredMessageList) { - this.lst_selectedCallSignInfofilteredMessageList = lst_selectedCallSignInfofilteredMessageList; - } - - public ObservableList getLst_globalChatMessageList() { + public ObservableRoster getLst_globalChatMessageList() { return lst_globalChatMessageList; } + /** + * Applies the stored filter to the current message store. + * + * @param filter {@code null} lets every message through, exactly like + * {@code FilteredList.setPredicate(null)} did + */ + private List filtered(Predicate filter) { + if (filter == null) { + return lst_globalChatMessageList.snapshot(); + } + return lst_globalChatMessageList.snapshot().stream().filter(filter).toList(); + } + + public void setToAllMessageFilter(Predicate filter) { + this.toAllMessageFilter = filter; + } + + /** The public messages, newest first. */ + public List toAllMessages() { + return filtered(toAllMessageFilter); + } + + public void setToMeMessageFilter(Predicate filter) { + this.toMeMessageFilter = filter; + } + + /** The messages addressed to the operator, newest first. */ + public List toMeMessages() { + return filtered(toMeMessageFilter); + } + + public void setToOtherMessageFilter(Predicate filter) { + this.toOtherMessageFilter = filter; + } + + /** The messages between other stations, newest first. */ + public List toOtherMessages() { + return filtered(toOtherMessageFilter); + } + + public void setSelectedCallSignInfoFilter(Predicate filter) { + this.selectedCallSignInfoFilter = filter; + } + + /** The messages shown in the info window of the selected station. */ + public List selectedCallSignInfoMessages() { + return filtered(selectedCallSignInfoFilter); + } + /** * Adds a chat message to the UI message store. * * Important: * - This method may be called from worker threads. - * - The ObservableList is modified only on the JavaFX application thread. + * - The roster is modified only on the user-interface thread. * - The backing list remains newest-first. * - Old messages are trimmed to avoid unlimited memory growth. */ @@ -2599,7 +2650,7 @@ private ObservableList chatMessageFlushScheduled = true; } - Platform.runLater(this::flushPendingChatMessagesToUi); + uiDispatcher.runOnUi(this::flushPendingChatMessagesToUi); } /** @@ -2665,30 +2716,40 @@ private ObservableList */ Collections.reverse(batch); - lst_globalChatMessageList.addAll(0, batch); - - trimGlobalChatMessageListIfNeeded(); + /* + * Insertion and trimming happen inside one mutate, so a whole batch wakes + * the listeners exactly once instead of once per message. + */ + lst_globalChatMessageList.mutate(list -> { + list.addAll(0, batch); + trimIfNeeded( + list, + ApplicationConstants.CHAT_MESSAGE_STORE_MAX_SIZE, + ApplicationConstants.CHAT_MESSAGE_STORE_TRIM_TO_SIZE); + }); } - private void trimGlobalChatMessageListIfNeeded() { - int maxSize = ApplicationConstants.CHAT_MESSAGE_STORE_MAX_SIZE; - int trimToSize = ApplicationConstants.CHAT_MESSAGE_STORE_TRIM_TO_SIZE; - + /** + * Drops the oldest entries once the store grew past its limit. + * + *

List order is newest-first, so the old entries sit at the end. + * + * @param list the list being mutated + * @param maxSize size above which trimming starts + * @param trimToSize size the list is cut back to + */ + private static void trimIfNeeded(List list, int maxSize, int trimToSize) { if (maxSize <= 0 || trimToSize <= 0 || trimToSize >= maxSize) { return; } - int currentSize = lst_globalChatMessageList.size(); + int currentSize = list.size(); if (currentSize <= maxSize) { return; } - /* - * List order is newest-first. - * Therefore old messages are at the end of the list. - */ - lst_globalChatMessageList.remove(trimToSize, currentSize); + list.subList(trimToSize, currentSize).clear(); } /** @@ -2696,7 +2757,7 @@ private ObservableList * * Same policy as for chat messages: * - batched UI update - * - JavaFX thread only for ObservableList mutation + * - user-interface thread only for roster mutation * - newest-first visible order * - bounded list size */ @@ -2715,7 +2776,7 @@ private ObservableList clusterMessageFlushScheduled = true; } - Platform.runLater(this::flushPendingClusterMessagesToUi); + uiDispatcher.runOnUi(this::flushPendingClusterMessagesToUi); } private void flushPendingClusterMessagesToUi() { @@ -2733,30 +2794,17 @@ private ObservableList Collections.reverse(batch); - lst_clusterMemberList.addAll(0, batch); - - trimClusterMessageListIfNeeded(); + lst_clusterMemberList.mutate(list -> { + list.addAll(0, batch); + trimIfNeeded( + list, + ApplicationConstants.CLUSTER_MESSAGE_STORE_MAX_SIZE, + ApplicationConstants.CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE); + }); } - private void trimClusterMessageListIfNeeded() { - int maxSize = ApplicationConstants.CLUSTER_MESSAGE_STORE_MAX_SIZE; - int trimToSize = ApplicationConstants.CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE; - - if (maxSize <= 0 || trimToSize <= 0 || trimToSize >= maxSize) { - return; - } - - int currentSize = lst_clusterMemberList.size(); - - if (currentSize <= maxSize) { - return; - } - - lst_clusterMemberList.remove(trimToSize, currentSize); - } - - public void setLst_globalChatMessageList(ObservableList lst_globalChatMessageList) { - this.lst_globalChatMessageList = lst_globalChatMessageList; + public void setLst_globalChatMessageList(Collection lst_globalChatMessageList) { + this.lst_globalChatMessageList.setAll(lst_globalChatMessageList); } public String getHostname() { @@ -2811,84 +2859,109 @@ private ObservableList // this.map_ucxLogInfoWorkedCalls = map_ucxLogInfoWorkedCalls; // } - public ObservableList getLst_chatMemberList() { - return lst_chatMemberList; + public ObservableRoster getLst_chatMemberList() { + return chatMemberList; } - public ObservableList getLst_DBBasedWkdCallSignList() { + public ObservableRoster getLst_DBBasedWkdCallSignList() { return lst_DBBasedWkdCallSignList; } - public void setLst_DBBasedWkdCallSignList(ObservableList lst_DBBasedWkdCallSignList) { - this.lst_DBBasedWkdCallSignList = lst_DBBasedWkdCallSignList; + public void setLst_DBBasedWkdCallSignList(Collection lst_DBBasedWkdCallSignList) { + this.lst_DBBasedWkdCallSignList.setAll(lst_DBBasedWkdCallSignList); } - public void setLst_chatMemberList(ObservableList lst_chatMemberList) { - this.lst_chatMemberList = lst_chatMemberList; + public void setLst_chatMemberList(Collection lst_chatMemberList) { + this.chatMemberList.setAll(lst_chatMemberList); } - public FilteredList getLst_chatMemberListFiltered() { - return lst_chatMemberListFiltered; - } - - public SortedList getLst_chatMemberSortedFilteredList() { - return lst_chatMemberSortedFilteredList; - } - - public ObservableList> getLst_chatMemberListFilterPredicates() { + public ObservableRoster> getLst_chatMemberListFilterPredicates() { return lst_chatMemberListFilterPredicates; } - public void setLst_chatMemberListFilterPredicates(ObservableList> lst_chatMemberListFilterPredicates) { - this.lst_chatMemberListFilterPredicates = lst_chatMemberListFilterPredicates; + public void setLst_chatMemberListFilterPredicates(Collection> lst_chatMemberListFilterPredicates) { + this.lst_chatMemberListFilterPredicates.setAll(lst_chatMemberListFilterPredicates); } - public ObservableList getLst_clusterMemberList() { + /** Adds one station-list filter. Nothing happens if it is already active. */ + public void addChatMemberListFilterPredicate(Predicate predicate) { + if (predicate == null || lst_chatMemberListFilterPredicates.snapshot().contains(predicate)) { + return; + } + lst_chatMemberListFilterPredicates.add(predicate); + } + + /** Removes one station-list filter. Unknown predicates are ignored. */ + public void removeChatMemberListFilterPredicate(Predicate predicate) { + if (predicate == null || !lst_chatMemberListFilterPredicates.snapshot().contains(predicate)) { + return; + } + lst_chatMemberListFilterPredicates.remove(predicate); + } + + /** Drops every station-list filter. Nothing happens if none is active. */ + public void clearChatMemberListFilterPredicates() { + if (lst_chatMemberListFilterPredicates.isEmpty()) { + return; + } + lst_chatMemberListFilterPredicates.clear(); + } + + /** The order the station list is presented in; {@code null} keeps the incoming order. */ + public Comparator getChatMemberOrder() { + return chatMemberOrder; + } + + public void setChatMemberOrder(Comparator chatMemberOrder) { + this.chatMemberOrder = chatMemberOrder; + } + + /** + * The visible station list, derived on demand from the canonical list, the + * active filters and the current order. This replaces the former + * FilteredList/SortedList chain. + */ + /** + * How many members are currently visible. Deliberately not + * {@code visibleChatMembers().size()}: that would sort several thousand rows to + * produce one number for the status line. + */ + public int visibleChatMemberCount() { + return ChatMemberListView.countMatching( + chatMemberList.snapshot(), + lst_chatMemberListFilterPredicates.snapshot()); + } + + public List visibleChatMembers() { + return ChatMemberListView.compute( + chatMemberList.snapshot(), + lst_chatMemberListFilterPredicates.snapshot(), + chatMemberOrder); + } + + public ObservableRoster getLst_clusterMemberList() { return lst_clusterMemberList; } - public void setLst_clusterMemberList(ObservableList lst_clusterMemberList) { - this.lst_clusterMemberList = lst_clusterMemberList; - } - - public ObservableList getLst_toAllMessageList() { - return lst_toAllMessageList; - } - -// public void setLst_toAllMessageList(ObservableList lst_toAllMessageList) { -// this.lst_toAllMessageList = lst_toAllMessageList; -// } - public void setLst_toAllMessageList(FilteredList lst_toAllMessageList) { - this.lst_toAllMessageList = lst_toAllMessageList; - } - - public ObservableList getLst_toMeMessageList() { - return lst_toMeMessageList; - } - -// public void setLst_toMeMessageList(ObservableList lst_toMeMessageList) { -// this.lst_toMeMessageList = lst_toMeMessageList; -// } - public void setLst_toMeMessageList(FilteredList lst_toMeMessageList) { - this.lst_toMeMessageList = lst_toMeMessageList; - } - - public ObservableList getLst_toOtherMessageList() { - return lst_toOtherMessageList; - } - -// public void setLst_toOtherMessageList(ObservableList lst_toOtherMessageList) { -// this.lst_toOtherMessageList = lst_toOtherMessageList; -// } - - public void setLst_toOtherMessageList(FilteredList lst_toOtherMessageList) { - this.lst_toOtherMessageList = lst_toOtherMessageList; + public void setLst_clusterMemberList(Collection lst_clusterMemberList) { + this.lst_clusterMemberList.setAll(lst_clusterMemberList); } public LinkedBlockingQueue getMessageTXBus() { return messageTXBus; } + /** + * Hands UI-visible changes over to the thread that owns the user interface. + * Headless construction defaults to running them on the calling thread. + */ + private UiDispatcher uiDispatcher = new DirectUiDispatcher(); + + /** The boundary worker threads use instead of touching the user interface. */ + public UiDispatcher uiDispatcher() { + return uiDispatcher; + } + public ChatController() { super(); @@ -2917,7 +2990,10 @@ private ObservableList listener, ChatPreferences.PREFERENCES_FILE, DBController.DATABASE_FILE, - true); + true, + // Headless construction: callers with a user interface use the + // constructor that takes the dispatcher explicitly. + new DirectUiDispatcher()); } /** @@ -2938,8 +3014,10 @@ private ObservableList StatusUpdateListener listener, String preferencesRelativeFileName, String workedDatabaseRelativeFileName, - boolean seedWorkedDatabaseFromResource) { + boolean seedWorkedDatabaseFromResource, + UiDispatcher uiDispatcher) { super(); + this.uiDispatcher = java.util.Objects.requireNonNull(uiDispatcher, "uiDispatcher"); chatPreferences = new ChatPreferences(preferencesRelativeFileName); chatPreferences.readPreferencesFromXmlFile(); @@ -2972,7 +3050,7 @@ private ObservableList - lst_toAllMessageList.setPredicate(new Predicate() { + setToAllMessageFilter(new Predicate() { @Override public boolean test(ChatMessage chatMessage) { @@ -2991,7 +3069,7 @@ private ObservableList } }); - lst_toOtherMessageList.setPredicate(new Predicate() { + setToOtherMessageFilter(new Predicate() { @Override public boolean test(ChatMessage chatMessage) { try { @@ -3084,20 +3162,16 @@ private ObservableList return false; }; - lstNotify_QSOSniffer_sniffedCallSignList.addListener((ListChangeListener) c -> { - -// System.out.println(c.toString()); - - // Wir zwingen die FilteredList zum Neuscannen, indem wir das Prädikat neu setzen. - lst_toMeMessageList.setPredicate(null); // kurz resetten (manchmal nötig in älteren JavaFX Versionen) - lst_toMeMessageList.setPredicate(chatFilterPredicate); - - }); + /* + * The former FilteredList had to be told to rescan when the monitoring list + * changed. The derived view is recomputed on every read instead, so the + * user interface only has to watch the monitoring roster as a second source. + */ // lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB"); - lst_toMeMessageList.setPredicate(chatFilterPredicate); //sniffed callsign filter predicate is here! + setToMeMessageFilter(chatFilterPredicate); //sniffed callsign filter predicate is here! // lst_toMeMessageList.setPredicate(new Predicate() { // @Override @@ -3176,11 +3250,11 @@ private ObservableList - public ObservableList getLstNotify_QSOSniffer_sniffedCallSignList() { + public SimpleRoster getLstNotify_QSOSniffer_sniffedCallSignList() { return lstNotify_QSOSniffer_sniffedCallSignList; } - public void setLstNotify_QSOSniffer_sniffedCallSignList(ObservableList lstNotify_QSOSniffer_sniffedCallSignList) { + public void setLstNotify_QSOSniffer_sniffedCallSignList(SimpleRoster lstNotify_QSOSniffer_sniffedCallSignList) { this.lstNotify_QSOSniffer_sniffedCallSignList = lstNotify_QSOSniffer_sniffedCallSignList; } @@ -4101,11 +4175,11 @@ private ObservableList HashMap finalWorkedDataFromDatabase = workedDataFromDatabase; - Platform.runLater(() -> { + uiDispatcher.runOnUi(() -> { applyWorkedAndQrvStateFromDatabase( activeChatMembersByCallAndCategory.values(), finalWorkedDataFromDatabase); - getLst_DBBasedWkdCallSignList().setAll(finalWorkedDataFromDatabase.values()); + setLst_DBBasedWkdCallSignList(finalWorkedDataFromDatabase.values()); fireUserListUpdate("Worked database state refreshed"); }); } @@ -4163,7 +4237,7 @@ private ObservableList */ public void resetWorkedInfoInGuiLists() { - this.chatController.getLst_chatMemberList().forEach( + this.chatController.getLst_chatMemberList().snapshot().forEach( chatMember -> chatMember.resetWorkedInformationAtAllBands()); } @@ -4172,7 +4246,7 @@ private ObservableList */ public void resetQRVInfoInGuiLists() { - this.chatController.getLst_chatMemberList().forEach( + this.chatController.getLst_chatMemberList().snapshot().forEach( chatMember -> chatMember.resetQRVInformationAtAllBands()); } @@ -4269,17 +4343,18 @@ private ObservableList final long now = System.currentTimeMillis(); // Ensure property updates happen on FX thread - if (Platform.isFxApplicationThread()) { + if (uiDispatcher.isUiThread()) { lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now)); } else { - Platform.runLater(() -> lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now))); + uiDispatcher.runOnUi(() -> lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now))); } } - private final ObjectProperty lastUiReminderEvent = new SimpleObjectProperty<>(null); + private final MutableValue lastUiReminderEvent = new SimpleValue<>(null); - public ReadOnlyObjectProperty lastUiReminderEventProperty() { + /** The most recent sked reminder; {@code null} until the first one fires. */ + public kst4contest.observe.ObservableValue lastUiReminderEvent() { return lastUiReminderEvent; } @@ -4328,7 +4403,7 @@ private ObservableList } if (lstNotify_QSOSniffer_sniffedCallSignList == null - || lstNotify_QSOSniffer_sniffedCallSignList.isEmpty()) { + || lstNotify_QSOSniffer_sniffedCallSignList.size() == 0) { return false; } @@ -4342,7 +4417,7 @@ private ObservableList ); boolean observedCall = - lstNotify_QSOSniffer_sniffedCallSignList + lstNotify_QSOSniffer_sniffedCallSignList.snapshot() .stream() .anyMatch( monitoredCall -> { diff --git a/core/src/main/java/kst4contest/controller/ChatMemberListView.java b/core/src/main/java/kst4contest/controller/ChatMemberListView.java new file mode 100644 index 00000000..64aa991f --- /dev/null +++ b/core/src/main/java/kst4contest/controller/ChatMemberListView.java @@ -0,0 +1,77 @@ +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: within a tie the arrival order is kept. + result.sort(order); + } + /* + * Collections.unmodifiableList and not List.copyOf: the JavaFX list this + * replaces carried null entries, and List.copyOf rejects them. A + * NullPointerException raised here would be swallowed by the listener guard + * and the station list would stop updating without a word. + */ + return java.util.Collections.unmodifiableList(result); + } + + /** + * How many members pass the filters. Same rules as {@link #compute}, but it + * neither sorts nor builds a list: the status line only wants the number. + */ + public static int countMatching(List members, List> filters) { + int count = 0; + for (ChatMember member : members) { + if (matches(member, filters)) { + count++; + } + } + return count; + } + + 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; + } +} diff --git a/src/main/java/kst4contest/controller/DBController.java b/core/src/main/java/kst4contest/controller/DBController.java similarity index 100% rename from src/main/java/kst4contest/controller/DBController.java rename to core/src/main/java/kst4contest/controller/DBController.java diff --git a/src/main/java/kst4contest/controller/DXClusterController.java b/core/src/main/java/kst4contest/controller/DXClusterController.java similarity index 98% rename from src/main/java/kst4contest/controller/DXClusterController.java rename to core/src/main/java/kst4contest/controller/DXClusterController.java index 521f7c87..771045eb 100644 --- a/src/main/java/kst4contest/controller/DXClusterController.java +++ b/core/src/main/java/kst4contest/controller/DXClusterController.java @@ -184,7 +184,7 @@ // String singleDXClusterMessage = "DX de "; // // singleDXClusterMessage += client.getChatPreferences().getLoginCallSign() + " "; -// singleDXClusterMessage += aChatMember.getFrequency().getValue() + " "; +// singleDXClusterMessage += aChatMember.getFrequency().get() + " "; // singleDXClusterMessage += aChatMember.getCallSign().toUpperCase() + " "; // singleDXClusterMessage += aChatMember.getQra().toUpperCase() + " "; // singleDXClusterMessage += new Utils4KST().time_generateCurrenthhmmZTimeStringForClusterMessage() + "\n"; diff --git a/src/main/java/kst4contest/controller/DXClusterSpotFormatter.java b/core/src/main/java/kst4contest/controller/DXClusterSpotFormatter.java similarity index 100% rename from src/main/java/kst4contest/controller/DXClusterSpotFormatter.java rename to core/src/main/java/kst4contest/controller/DXClusterSpotFormatter.java diff --git a/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java b/core/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java similarity index 96% rename from src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java rename to core/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java index 699d58d5..2f27cfe4 100644 --- a/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java +++ b/core/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java @@ -18,6 +18,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; +import kst4contest.observe.SimpleValue; public class DXClusterThreadPooledServer implements Runnable { @@ -157,17 +158,20 @@ public class DXClusterThreadPooledServer implements Runnable { try { String frequency = Utils4KST.normalizeFrequencyString( - chatMember.getFrequency().getValue(), - chatController + chatMember.getFrequency().get(), + // Bridge until ChatPreferences moves to the observe types: + // Utils4KST only reads the prefix, so a snapshot is enough. + new SimpleValue<>(chatController .getChatPreferences() .getNotify_optionalFrequencyPrefix() + .get()) ); clusterPayload = DXClusterSpotFormatter.formatPayload( chatController .getChatPreferences() .getNotify_DXCSrv_SpottersCallSign() - .getValue(), + .get(), frequency, chatMember.getCallSign(), chatMember.getQra(), diff --git a/src/main/java/kst4contest/controller/ExternalLoggedQso.java b/core/src/main/java/kst4contest/controller/ExternalLoggedQso.java similarity index 100% rename from src/main/java/kst4contest/controller/ExternalLoggedQso.java rename to core/src/main/java/kst4contest/controller/ExternalLoggedQso.java diff --git a/src/main/java/kst4contest/controller/InputReaderThread.java b/core/src/main/java/kst4contest/controller/InputReaderThread.java similarity index 100% rename from src/main/java/kst4contest/controller/InputReaderThread.java rename to core/src/main/java/kst4contest/controller/InputReaderThread.java diff --git a/src/main/java/kst4contest/controller/LoggedQsoBand.java b/core/src/main/java/kst4contest/controller/LoggedQsoBand.java similarity index 100% rename from src/main/java/kst4contest/controller/LoggedQsoBand.java rename to core/src/main/java/kst4contest/controller/LoggedQsoBand.java diff --git a/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java similarity index 99% rename from src/main/java/kst4contest/controller/MessageBusManagementThread.java rename to core/src/main/java/kst4contest/controller/MessageBusManagementThread.java index c629cd74..4ad58b5c 100644 --- a/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -7,6 +7,7 @@ import java.sql.SQLException; //import java.util.ArrayList; import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Hashtable; import java.util.Locale; import java.util.regex.Matcher; @@ -14,8 +15,6 @@ import java.util.regex.Pattern; import java.util.logging.Level; import java.util.logging.Logger; -import javafx.beans.property.SimpleStringProperty; -import javafx.collections.ObservableList; import kst4contest.ApplicationConstants; import kst4contest.locatorUtils.DirectionUtils; import kst4contest.locatorUtils.Location; @@ -23,6 +22,7 @@ import kst4contest.model.*; import kst4contest.logic.FrequencyTextParser; import java.util.concurrent.LinkedBlockingQueue; import java.util.function.LongPredicate; +import kst4contest.observe.SimpleValue; /** * @@ -578,7 +578,7 @@ public class MessageBusManagementThread extends Thread { * @param lookForThis * @return Integer (index), -1 for not found */ - private int checkListForChatMemberIndexByCallSign(ObservableList list, ChatMember lookForThis) { + private int checkListForChatMemberIndexByCallSign(List list, ChatMember lookForThis) { if (lookForThis == null) { @@ -1168,12 +1168,12 @@ public class MessageBusManagementThread extends Thread { // // automaticAnswer.setSender(itsMe); // automaticAnswer.setReceiver(newMessageArrived.getSender()); -// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue()); +// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().get()); // // if (this.client.getChatPreferences().isLoginToSecondChatEnabled()) { -// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRGs: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue() + " / " + this.client.getChatPreferences().getMYQRGSecondCat().getValue()); +// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRGs: " + this.client.getChatPreferences().getMYQRGFirstCat().get() + " / " + this.client.getChatPreferences().getMYQRGSecondCat().get()); // } else { -// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue()); +// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().get()); // } // // this.client.getMessageTXBus().add(automaticAnswer); @@ -1308,7 +1308,7 @@ public class MessageBusManagementThread extends Thread { String detectedFrequency = sender.getFrequency() == null ? null - : sender.getFrequency().getValue(); + : sender.getFrequency().get(); if (detectedFrequency != null && !detectedFrequency.isBlank()) { /* @@ -1447,7 +1447,7 @@ public class MessageBusManagementThread extends Thread { newDXCListSender.setQra(splittedMessageLine[8]); ChatMember newDXCListReceiver = new ChatMember(); - newDXCListReceiver.setFrequency(new SimpleStringProperty(splittedMessageLine[5])); + newDXCListReceiver.setFrequency(new SimpleValue<>(splittedMessageLine[5])); newDXCListReceiver.setCallSign(splittedMessageLine[6]); newDXCListReceiver.setQra(splittedMessageLine[9]); @@ -1487,7 +1487,7 @@ public class MessageBusManagementThread extends Thread { newDXCListSender2.setQra(splittedMessageLine[7]); ChatMember newDXCListReceiver2 = new ChatMember(); - newDXCListReceiver2.setFrequency(new SimpleStringProperty(splittedMessageLine[4])); + newDXCListReceiver2.setFrequency(new SimpleValue<>(splittedMessageLine[4])); newDXCListReceiver2.setCallSign(splittedMessageLine[5]); newDXCListReceiver2.setQra(splittedMessageLine[8]); @@ -2058,10 +2058,10 @@ public class MessageBusManagementThread extends Thread { if (incomingCategory != null && secondCategory != null && incomingCategory.getCategoryNumber() == secondCategory.getCategoryNumber()) { - return this.client.getChatPreferences().getMYQRGSecondCat().getValue(); + return this.client.getChatPreferences().getMYQRGSecondCat().get(); } - return this.client.getChatPreferences().getMYQRGFirstCat().getValue(); + return this.client.getChatPreferences().getMYQRGFirstCat().get(); } private String autoAnswerCooldownKey(ChatMessage incoming) { diff --git a/src/main/java/kst4contest/controller/MessageBusManagementThreadFrequencyContextTest.java b/core/src/main/java/kst4contest/controller/MessageBusManagementThreadFrequencyContextTest.java similarity index 100% rename from src/main/java/kst4contest/controller/MessageBusManagementThreadFrequencyContextTest.java rename to core/src/main/java/kst4contest/controller/MessageBusManagementThreadFrequencyContextTest.java diff --git a/src/main/java/kst4contest/controller/MessageHistoryRecorder.java b/core/src/main/java/kst4contest/controller/MessageHistoryRecorder.java similarity index 100% rename from src/main/java/kst4contest/controller/MessageHistoryRecorder.java rename to core/src/main/java/kst4contest/controller/MessageHistoryRecorder.java diff --git a/src/main/java/kst4contest/controller/MessageVariableResolver.java b/core/src/main/java/kst4contest/controller/MessageVariableResolver.java similarity index 97% rename from src/main/java/kst4contest/controller/MessageVariableResolver.java rename to core/src/main/java/kst4contest/controller/MessageVariableResolver.java index 9a0033c5..abe8b36f 100644 --- a/src/main/java/kst4contest/controller/MessageVariableResolver.java +++ b/core/src/main/java/kst4contest/controller/MessageVariableResolver.java @@ -49,8 +49,8 @@ public final class MessageVariableResolver { return null; } - String primaryFrequency = valueOrEmpty(chatPreferences.getMYQRGFirstCat().getValue()); - String secondaryFrequency = valueOrEmpty(chatPreferences.getMYQRGSecondCat().getValue()); + String primaryFrequency = valueOrEmpty(chatPreferences.getMYQRGFirstCat().get()); + String secondaryFrequency = valueOrEmpty(chatPreferences.getMYQRGSecondCat().get()); String ownLocator = valueOrEmpty(chatPreferences.getStn_loginLocatorMainCat()); String resolvedText = template; @@ -60,7 +60,7 @@ public final class MessageVariableResolver { resolvedText = resolvedText.replace("MYLOCATORSHORT", abbreviateLocator(ownLocator)); resolvedText = resolvedText.replace("MYLOCATOR", ownLocator); resolvedText = resolvedText.replace("MYCALL", valueOrEmpty(chatPreferences.getStn_loginCallSign())); - resolvedText = resolvedText.replace("MYQTF", formatHeading(chatPreferences.getActualQTF().getValue().doubleValue())); + resolvedText = resolvedText.replace("MYQTF", formatHeading(chatPreferences.getActualQTF().get().doubleValue())); return resolvedText; } diff --git a/src/main/java/kst4contest/controller/On4KstConnectionManager.java b/core/src/main/java/kst4contest/controller/On4KstConnectionManager.java similarity index 100% rename from src/main/java/kst4contest/controller/On4KstConnectionManager.java rename to core/src/main/java/kst4contest/controller/On4KstConnectionManager.java diff --git a/src/main/java/kst4contest/controller/On4KstConnectionState.java b/core/src/main/java/kst4contest/controller/On4KstConnectionState.java similarity index 100% rename from src/main/java/kst4contest/controller/On4KstConnectionState.java rename to core/src/main/java/kst4contest/controller/On4KstConnectionState.java diff --git a/src/main/java/kst4contest/controller/On4KstProtocol.java b/core/src/main/java/kst4contest/controller/On4KstProtocol.java similarity index 100% rename from src/main/java/kst4contest/controller/On4KstProtocol.java rename to core/src/main/java/kst4contest/controller/On4KstProtocol.java diff --git a/src/main/java/kst4contest/controller/On4KstProtocolTest.java b/core/src/main/java/kst4contest/controller/On4KstProtocolTest.java similarity index 100% rename from src/main/java/kst4contest/controller/On4KstProtocolTest.java rename to core/src/main/java/kst4contest/controller/On4KstProtocolTest.java diff --git a/src/main/java/kst4contest/controller/On4KstSocketThreadTest.java b/core/src/main/java/kst4contest/controller/On4KstSocketThreadTest.java similarity index 100% rename from src/main/java/kst4contest/controller/On4KstSocketThreadTest.java rename to core/src/main/java/kst4contest/controller/On4KstSocketThreadTest.java diff --git a/src/main/java/kst4contest/controller/OperatorProfileManagementService.java b/core/src/main/java/kst4contest/controller/OperatorProfileManagementService.java similarity index 100% rename from src/main/java/kst4contest/controller/OperatorProfileManagementService.java rename to core/src/main/java/kst4contest/controller/OperatorProfileManagementService.java diff --git a/src/main/java/kst4contest/controller/OperatorProfilePaths.java b/core/src/main/java/kst4contest/controller/OperatorProfilePaths.java similarity index 100% rename from src/main/java/kst4contest/controller/OperatorProfilePaths.java rename to core/src/main/java/kst4contest/controller/OperatorProfilePaths.java diff --git a/src/main/java/kst4contest/controller/OperatorProfileStore.java b/core/src/main/java/kst4contest/controller/OperatorProfileStore.java similarity index 100% rename from src/main/java/kst4contest/controller/OperatorProfileStore.java rename to core/src/main/java/kst4contest/controller/OperatorProfileStore.java diff --git a/src/main/java/kst4contest/controller/PstRotatorClient.java b/core/src/main/java/kst4contest/controller/PstRotatorClient.java similarity index 100% rename from src/main/java/kst4contest/controller/PstRotatorClient.java rename to core/src/main/java/kst4contest/controller/PstRotatorClient.java diff --git a/src/main/java/kst4contest/controller/ReachabilityService.java b/core/src/main/java/kst4contest/controller/ReachabilityService.java similarity index 98% rename from src/main/java/kst4contest/controller/ReachabilityService.java rename to core/src/main/java/kst4contest/controller/ReachabilityService.java index a12f07d7..42dda3d5 100644 --- a/src/main/java/kst4contest/controller/ReachabilityService.java +++ b/core/src/main/java/kst4contest/controller/ReachabilityService.java @@ -10,7 +10,6 @@ import java.util.Locale; import java.util.Map; import java.util.function.Consumer; -import javafx.application.Platform; import kst4contest.locatorUtils.Location; import kst4contest.model.Band; import kst4contest.model.ChatMember; @@ -377,10 +376,10 @@ public final class ReachabilityService { chatController.fireUserListUpdate("Reachability calculated"); }; - if (Platform.isFxApplicationThread()) { + if (chatController.uiDispatcher().isUiThread()) { updateTask.run(); } else { - Platform.runLater(updateTask); + chatController.uiDispatcher().runOnUi(updateTask); } }); } @@ -518,8 +517,8 @@ public final class ReachabilityService { return latestFrequencyInfo.frequency; } - if (member != null && member.getFrequency() != null && member.getFrequency().getValue() != null) { - double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().getValue()); + if (member != null && member.getFrequency() != null && member.getFrequency().get() != null) { + double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().get()); if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) { Band parsedBand = Band.fromFrequency(parsedFrequencyMHz); if (parsedBand == null || parsedBand == band) { @@ -583,10 +582,10 @@ public final class ReachabilityService { return; } - if (Platform.isFxApplicationThread()) { + if (chatController.uiDispatcher().isUiThread()) { fxCallback.accept(result); } else { - Platform.runLater(() -> fxCallback.accept(result)); + chatController.uiDispatcher().runOnUi(() -> fxCallback.accept(result)); } } diff --git a/src/main/java/kst4contest/controller/ReadThread.java b/core/src/main/java/kst4contest/controller/ReadThread.java similarity index 100% rename from src/main/java/kst4contest/controller/ReadThread.java rename to core/src/main/java/kst4contest/controller/ReadThread.java diff --git a/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java b/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java similarity index 99% rename from src/main/java/kst4contest/controller/ReadUDPByWintestThread.java rename to core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java index 6b276499..b74358dd 100644 --- a/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java @@ -1,6 +1,5 @@ package kst4contest.controller; -import javafx.application.Platform; import kst4contest.ApplicationConstants; import kst4contest.model.ChatMember; import kst4contest.model.ThreadStateMessage; @@ -568,7 +567,7 @@ public class ReadUDPByWintestThread extends Thread { if (client .getChatPreferences() .isLogsynch_wintestQrgSyncEnabled()) { - Platform.runLater( + client.uiDispatcher().runOnUi( () -> client .getChatPreferences() .getMYQRGFirstCat() diff --git a/src/main/java/kst4contest/controller/ReadUDPByWintestThreadTest.java b/core/src/main/java/kst4contest/controller/ReadUDPByWintestThreadTest.java similarity index 100% rename from src/main/java/kst4contest/controller/ReadUDPByWintestThreadTest.java rename to core/src/main/java/kst4contest/controller/ReadUDPByWintestThreadTest.java diff --git a/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java b/core/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java similarity index 98% rename from src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java rename to core/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java index adef1261..2187b290 100644 --- a/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java @@ -9,9 +9,8 @@ import java.net.SocketTimeoutException; import java.util.Comparator; import java.util.List; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; import kst4contest.ApplicationConstants; +import java.util.ArrayList; import kst4contest.model.AirPlane; import kst4contest.model.AirPlaneReflectionInfo; import kst4contest.model.ChatMember; @@ -275,7 +274,7 @@ public class ReadUDPbyAirScoutMessageThread extends Thread { String[] fullStringSplitter; String[] apStringSplitter; AirPlaneReflectionInfo apInfo = new AirPlaneReflectionInfo(); - ObservableList airplaneList = FXCollections.observableArrayList(); + List airplaneList = new ArrayList<>(); if (udpStringToProcess.contains("ASNEAREST: ")) { udpStringToProcess = udpStringToProcess.replace("ASNEAREST: ", ""); diff --git a/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java b/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java similarity index 99% rename from src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java rename to core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java index b09970f3..05208720 100644 --- a/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java @@ -22,7 +22,6 @@ import org.xml.sax.SAXException; import kst4contest.model.ChatMember; import kst4contest.model.Band; -import javafx.application.Platform; /** * This thread is responsible for reading server's input and printing it to the @@ -584,10 +583,10 @@ public class ReadUDPbyUCXMessageThread extends Thread { return; } - if (Platform.isFxApplicationThread()) { + if (client.uiDispatcher().isUiThread()) { runnable.run(); } else { - Platform.runLater(runnable); + client.uiDispatcher().runOnUi(runnable); } } diff --git a/src/main/java/kst4contest/controller/RegexTester.java b/core/src/main/java/kst4contest/controller/RegexTester.java similarity index 100% rename from src/main/java/kst4contest/controller/RegexTester.java rename to core/src/main/java/kst4contest/controller/RegexTester.java diff --git a/src/main/java/kst4contest/controller/ScoreService.java b/core/src/main/java/kst4contest/controller/ScoreService.java similarity index 88% rename from src/main/java/kst4contest/controller/ScoreService.java rename to core/src/main/java/kst4contest/controller/ScoreService.java index 20fcfe37..8b72f5be 100644 --- a/src/main/java/kst4contest/controller/ScoreService.java +++ b/core/src/main/java/kst4contest/controller/ScoreService.java @@ -1,13 +1,11 @@ package kst4contest.controller; -import javafx.application.Platform; -import javafx.beans.property.LongProperty; -import javafx.beans.property.ReadOnlyDoubleProperty; -import javafx.beans.property.ReadOnlyDoubleWrapper; -import javafx.beans.property.SimpleLongProperty; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; import kst4contest.logic.PriorityCalculator; +import kst4contest.observe.MutableValue; +import kst4contest.observe.ObservableRoster; +import kst4contest.observe.ObservableValue; +import kst4contest.observe.SimpleRoster; +import kst4contest.observe.SimpleValue; import kst4contest.model.ChatCategory; import kst4contest.model.ChatMember; import kst4contest.model.ChatPreferences; @@ -17,14 +15,11 @@ import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.ReadOnlyObjectProperty; -import javafx.beans.property.SimpleObjectProperty; /** * Calculates priority scores off the JavaFX thread and publishes a small UI model. * * Design goals: - * - No per-member Platform.runLater flooding. + * - No per-member UI hand-off flooding. * - Score is computed once per callsignRaw (e.g. "SM6VTZ"), even if it exists in multiple chat categories. * - A routing hint (preferred ChatCategory) is kept using "last inbound category" if available. */ @@ -42,15 +37,15 @@ public final class ScoreService { private final AtomicReference latestSnapshot = new AtomicReference<>(ScoreSnapshot.empty()); // UI outputs - private final ObservableList topCandidatesFx = FXCollections.observableArrayList(); - private final ReadOnlyDoubleWrapper selectedCallPriorityScore = new ReadOnlyDoubleWrapper(Double.NaN); - private final LongProperty uiPulse = new SimpleLongProperty(0); + private final SimpleRoster topCandidates = new SimpleRoster<>(); + private final MutableValue selectedCallPriorityScore = new SimpleValue<>(Double.NaN); + private final MutableValue uiPulse = new SimpleValue<>(0L); private volatile String selectedCallSignRaw; private volatile long lastComputedEpochMs = 0L; private final int topN; - private final ObjectProperty selectedChatMember = new SimpleObjectProperty<>(null); + private final MutableValue selectedChatMember = new SimpleValue<>(null); public ScoreService(ChatController controller, PriorityCalculator priorityCalculator, int topN) { @@ -59,19 +54,19 @@ public final class ScoreService { this.topN = topN > 0 ? topN : DEFAULT_TOP_N; } - public ObservableList getTopCandidatesFx() { - return topCandidatesFx; + public ObservableRoster topCandidates() { + return topCandidates; } - public ReadOnlyDoubleProperty selectedCallPriorityScoreProperty() { - return selectedCallPriorityScore.getReadOnlyProperty(); + public ObservableValue selectedCallPriorityScore() { + return selectedCallPriorityScore; } /** * A lightweight UI invalidation signal that increments after every published snapshot. * Consumers can refresh small panels (timeline/toplist), but should avoid refreshing huge tables. */ - public LongProperty uiPulseProperty() { + public ObservableValue uiPulse() { return uiPulse; } @@ -88,19 +83,19 @@ public final class ScoreService { public void setSelectedChatMember(ChatMember member) { // keep a central selection for UI actions (FurtherInfo buttons, timeline clicks, etc.) - if (Platform.isFxApplicationThread()) { + if (controller.uiDispatcher().isUiThread()) { selectedChatMember.set(member); } else { - Platform.runLater(() -> selectedChatMember.set(member)); + controller.uiDispatcher().runOnUi(() -> selectedChatMember.set(member)); } selectedCallSignRaw = member == null ? null : normalizeCallRaw(member.getCallSignRaw()); // Update score immediately from the latest snapshot - if (Platform.isFxApplicationThread()) { + if (controller.uiDispatcher().isUiThread()) { updateSelectedScoreFromSnapshot(latestSnapshot.get()); } else { - Platform.runLater(() -> updateSelectedScoreFromSnapshot(latestSnapshot.get())); + controller.uiDispatcher().runOnUi(() -> updateSelectedScoreFromSnapshot(latestSnapshot.get())); } } @@ -191,12 +186,12 @@ public final class ScoreService { latestSnapshot.set(snap); lastComputedEpochMs = nowEpochMs; - // 4) Publish to UI in ONE batched runLater - Platform.runLater(() -> { + // 4) Publish to UI in ONE batched hand-off + controller.uiDispatcher().runOnUi(() -> { applyScoreSnapshotToChatMembers(snap); - topCandidatesFx.setAll(snap.getTopCandidates()); + topCandidates.setAll(snap.getTopCandidates()); updateSelectedScoreFromSnapshot(snap); - uiPulse.set(uiPulse.get() + 1); + uiPulse.set(uiPulse.get() + 1L); }); } @@ -349,7 +344,7 @@ public final class ScoreService { public List getTopCandidates() { return topCandidates; } } - public ReadOnlyObjectProperty selectedChatMemberProperty() { + public ObservableValue selectedChatMember() { return selectedChatMember; } diff --git a/src/main/java/kst4contest/controller/ScoreboardUpdateTask.java b/core/src/main/java/kst4contest/controller/ScoreboardUpdateTask.java similarity index 100% rename from src/main/java/kst4contest/controller/ScoreboardUpdateTask.java rename to core/src/main/java/kst4contest/controller/ScoreboardUpdateTask.java diff --git a/src/main/java/kst4contest/controller/SkedReminderService.java b/core/src/main/java/kst4contest/controller/SkedReminderService.java similarity index 99% rename from src/main/java/kst4contest/controller/SkedReminderService.java rename to core/src/main/java/kst4contest/controller/SkedReminderService.java index aaa4f83d..5bb9ad5e 100644 --- a/src/main/java/kst4contest/controller/SkedReminderService.java +++ b/core/src/main/java/kst4contest/controller/SkedReminderService.java @@ -1,6 +1,5 @@ package kst4contest.controller; -import javafx.application.Platform; import kst4contest.model.ChatCategory; import kst4contest.model.ThreadStateMessage; diff --git a/src/main/java/kst4contest/controller/StartChat.java b/core/src/main/java/kst4contest/controller/StartChat.java similarity index 84% rename from src/main/java/kst4contest/controller/StartChat.java rename to core/src/main/java/kst4contest/controller/StartChat.java index 37bf4350..e16a7305 100644 --- a/src/main/java/kst4contest/controller/StartChat.java +++ b/core/src/main/java/kst4contest/controller/StartChat.java @@ -3,8 +3,6 @@ */ package kst4contest.controller; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.value.ObservableStringValue; import java.io.IOException; diff --git a/src/main/java/kst4contest/controller/StationMetricsService.java b/core/src/main/java/kst4contest/controller/StationMetricsService.java similarity index 100% rename from src/main/java/kst4contest/controller/StationMetricsService.java rename to core/src/main/java/kst4contest/controller/StationMetricsService.java diff --git a/src/main/java/kst4contest/controller/StatusUpdateListener.java b/core/src/main/java/kst4contest/controller/StatusUpdateListener.java similarity index 100% rename from src/main/java/kst4contest/controller/StatusUpdateListener.java rename to core/src/main/java/kst4contest/controller/StatusUpdateListener.java diff --git a/src/main/java/kst4contest/controller/ThreadStatusCallback.java b/core/src/main/java/kst4contest/controller/ThreadStatusCallback.java similarity index 100% rename from src/main/java/kst4contest/controller/ThreadStatusCallback.java rename to core/src/main/java/kst4contest/controller/ThreadStatusCallback.java diff --git a/src/main/java/kst4contest/controller/UCXLogFileToHashsetParser.java b/core/src/main/java/kst4contest/controller/UCXLogFileToHashsetParser.java similarity index 100% rename from src/main/java/kst4contest/controller/UCXLogFileToHashsetParser.java rename to core/src/main/java/kst4contest/controller/UCXLogFileToHashsetParser.java diff --git a/src/main/java/kst4contest/controller/UpdateChecker.java b/core/src/main/java/kst4contest/controller/UpdateChecker.java similarity index 100% rename from src/main/java/kst4contest/controller/UpdateChecker.java rename to core/src/main/java/kst4contest/controller/UpdateChecker.java diff --git a/src/main/java/kst4contest/controller/UserActualizationTask.java b/core/src/main/java/kst4contest/controller/UserActualizationTask.java similarity index 100% rename from src/main/java/kst4contest/controller/UserActualizationTask.java rename to core/src/main/java/kst4contest/controller/UserActualizationTask.java diff --git a/src/main/java/kst4contest/controller/Utils4KST.java b/core/src/main/java/kst4contest/controller/Utils4KST.java similarity index 98% rename from src/main/java/kst4contest/controller/Utils4KST.java rename to core/src/main/java/kst4contest/controller/Utils4KST.java index aeaad7f9..4429ce7e 100644 --- a/src/main/java/kst4contest/controller/Utils4KST.java +++ b/core/src/main/java/kst4contest/controller/Utils4KST.java @@ -1,6 +1,6 @@ package kst4contest.controller; -import javafx.beans.property.SimpleStringProperty; +import kst4contest.observe.ObservableValue; import java.text.DateFormat; import java.text.SimpleDateFormat; @@ -119,7 +119,7 @@ public class Utils4KST { */ public static String normalizeFrequencyString( String qrgString, - SimpleStringProperty optionalPrefix + ObservableValue optionalPrefix ) { if (qrgString == null || qrgString.isBlank()) { return ""; @@ -190,7 +190,7 @@ public class Utils4KST { String fallbackPrefix = optionalPrefix == null ? null - : optionalPrefix.getValue(); + : optionalPrefix.get(); if (fallbackPrefix == null) { return ""; diff --git a/src/main/java/kst4contest/controller/WinTestIhaveInventory.java b/core/src/main/java/kst4contest/controller/WinTestIhaveInventory.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestIhaveInventory.java rename to core/src/main/java/kst4contest/controller/WinTestIhaveInventory.java diff --git a/src/main/java/kst4contest/controller/WinTestLogSegment.java b/core/src/main/java/kst4contest/controller/WinTestLogSegment.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestLogSegment.java rename to core/src/main/java/kst4contest/controller/WinTestLogSegment.java diff --git a/src/main/java/kst4contest/controller/WinTestLogSyncService.java b/core/src/main/java/kst4contest/controller/WinTestLogSyncService.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestLogSyncService.java rename to core/src/main/java/kst4contest/controller/WinTestLogSyncService.java diff --git a/src/main/java/kst4contest/controller/WinTestMessage.java b/core/src/main/java/kst4contest/controller/WinTestMessage.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestMessage.java rename to core/src/main/java/kst4contest/controller/WinTestMessage.java diff --git a/src/main/java/kst4contest/controller/WinTestNetworkAddressResolver.java b/core/src/main/java/kst4contest/controller/WinTestNetworkAddressResolver.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestNetworkAddressResolver.java rename to core/src/main/java/kst4contest/controller/WinTestNetworkAddressResolver.java diff --git a/src/main/java/kst4contest/controller/WinTestPacket.java b/core/src/main/java/kst4contest/controller/WinTestPacket.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestPacket.java rename to core/src/main/java/kst4contest/controller/WinTestPacket.java diff --git a/src/main/java/kst4contest/controller/WinTestSkedSender.java b/core/src/main/java/kst4contest/controller/WinTestSkedSender.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestSkedSender.java rename to core/src/main/java/kst4contest/controller/WinTestSkedSender.java diff --git a/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java b/core/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java similarity index 100% rename from src/main/java/kst4contest/controller/WorkedGrossFieldCache.java rename to core/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java diff --git a/src/main/java/kst4contest/controller/WriteThread.java b/core/src/main/java/kst4contest/controller/WriteThread.java similarity index 100% rename from src/main/java/kst4contest/controller/WriteThread.java rename to core/src/main/java/kst4contest/controller/WriteThread.java diff --git a/src/main/java/kst4contest/controller/interfaces/PstRotatorEventListener.java b/core/src/main/java/kst4contest/controller/interfaces/PstRotatorEventListener.java similarity index 100% rename from src/main/java/kst4contest/controller/interfaces/PstRotatorEventListener.java rename to core/src/main/java/kst4contest/controller/interfaces/PstRotatorEventListener.java diff --git a/src/main/java/kst4contest/controller/keepAliveMessageSenderTask.java b/core/src/main/java/kst4contest/controller/keepAliveMessageSenderTask.java similarity index 100% rename from src/main/java/kst4contest/controller/keepAliveMessageSenderTask.java rename to core/src/main/java/kst4contest/controller/keepAliveMessageSenderTask.java diff --git a/src/main/java/kst4contest/locatorUtils/Angle.java b/core/src/main/java/kst4contest/locatorUtils/Angle.java similarity index 100% rename from src/main/java/kst4contest/locatorUtils/Angle.java rename to core/src/main/java/kst4contest/locatorUtils/Angle.java diff --git a/src/main/java/kst4contest/locatorUtils/DirectionUtils.java b/core/src/main/java/kst4contest/locatorUtils/DirectionUtils.java similarity index 100% rename from src/main/java/kst4contest/locatorUtils/DirectionUtils.java rename to core/src/main/java/kst4contest/locatorUtils/DirectionUtils.java diff --git a/src/main/java/kst4contest/locatorUtils/Latitude.java b/core/src/main/java/kst4contest/locatorUtils/Latitude.java similarity index 100% rename from src/main/java/kst4contest/locatorUtils/Latitude.java rename to core/src/main/java/kst4contest/locatorUtils/Latitude.java diff --git a/src/main/java/kst4contest/locatorUtils/Location.java b/core/src/main/java/kst4contest/locatorUtils/Location.java similarity index 100% rename from src/main/java/kst4contest/locatorUtils/Location.java rename to core/src/main/java/kst4contest/locatorUtils/Location.java diff --git a/src/main/java/kst4contest/locatorUtils/Longitude.java b/core/src/main/java/kst4contest/locatorUtils/Longitude.java similarity index 100% rename from src/main/java/kst4contest/locatorUtils/Longitude.java rename to core/src/main/java/kst4contest/locatorUtils/Longitude.java diff --git a/src/main/java/kst4contest/locatorUtils/TestLocatorUtils.java b/core/src/main/java/kst4contest/locatorUtils/TestLocatorUtils.java similarity index 100% rename from src/main/java/kst4contest/locatorUtils/TestLocatorUtils.java rename to core/src/main/java/kst4contest/locatorUtils/TestLocatorUtils.java diff --git a/src/main/java/kst4contest/logic/BandOpportunityResolver.java b/core/src/main/java/kst4contest/logic/BandOpportunityResolver.java similarity index 100% rename from src/main/java/kst4contest/logic/BandOpportunityResolver.java rename to core/src/main/java/kst4contest/logic/BandOpportunityResolver.java diff --git a/src/main/java/kst4contest/logic/FrequencyTextParser.java b/core/src/main/java/kst4contest/logic/FrequencyTextParser.java similarity index 100% rename from src/main/java/kst4contest/logic/FrequencyTextParser.java rename to core/src/main/java/kst4contest/logic/FrequencyTextParser.java diff --git a/src/main/java/kst4contest/logic/PriorityCalculator.java b/core/src/main/java/kst4contest/logic/PriorityCalculator.java similarity index 99% rename from src/main/java/kst4contest/logic/PriorityCalculator.java rename to core/src/main/java/kst4contest/logic/PriorityCalculator.java index 0a3d341c..0c936e07 100644 --- a/src/main/java/kst4contest/logic/PriorityCalculator.java +++ b/core/src/main/java/kst4contest/logic/PriorityCalculator.java @@ -145,7 +145,7 @@ public class PriorityCalculator { // 5) BOOST IDEA #1: Beam direction match (within beamwidth) // -------------------------------------------------------------------- if (member.getQTFdirection() != null) { - double myAz = prefs.getActualQTF().getValue(); + double myAz = prefs.getActualQTF().get(); double targetAz = member.getQTFdirection(); double diff = minimalAngleDiffDeg(myAz, targetAz); diff --git a/src/main/java/kst4contest/logic/PropagationFrequencyResolver.java b/core/src/main/java/kst4contest/logic/PropagationFrequencyResolver.java similarity index 100% rename from src/main/java/kst4contest/logic/PropagationFrequencyResolver.java rename to core/src/main/java/kst4contest/logic/PropagationFrequencyResolver.java diff --git a/src/main/java/kst4contest/logic/SignalDetector.java b/core/src/main/java/kst4contest/logic/SignalDetector.java similarity index 100% rename from src/main/java/kst4contest/logic/SignalDetector.java rename to core/src/main/java/kst4contest/logic/SignalDetector.java diff --git a/src/main/java/kst4contest/model/AirPlane.java b/core/src/main/java/kst4contest/model/AirPlane.java similarity index 100% rename from src/main/java/kst4contest/model/AirPlane.java rename to core/src/main/java/kst4contest/model/AirPlane.java diff --git a/src/main/java/kst4contest/model/AirPlaneReflectionInfo.java b/core/src/main/java/kst4contest/model/AirPlaneReflectionInfo.java similarity index 83% rename from src/main/java/kst4contest/model/AirPlaneReflectionInfo.java rename to core/src/main/java/kst4contest/model/AirPlaneReflectionInfo.java index fbb75380..eaa155af 100644 --- a/src/main/java/kst4contest/model/AirPlaneReflectionInfo.java +++ b/core/src/main/java/kst4contest/model/AirPlaneReflectionInfo.java @@ -1,13 +1,13 @@ package kst4contest.model; -import javafx.collections.ObservableList; +import java.util.List; public class AirPlaneReflectionInfo { String date; ChatMember sender, receiver; int airPlanesReachableCntr; - ObservableList risingAirplanes; + List risingAirplanes; public String getDate() { return date; @@ -41,11 +41,11 @@ public class AirPlaneReflectionInfo { this.airPlanesReachableCntr = airPlanesReachableCntr; } - public ObservableList getRisingAirplanes() { + public List getRisingAirplanes() { return risingAirplanes; } - public void setRisingAirplanes(ObservableList risingAirplanes) { + public void setRisingAirplanes(List risingAirplanes) { this.risingAirplanes = risingAirplanes; } diff --git a/src/main/java/kst4contest/model/Band.java b/core/src/main/java/kst4contest/model/Band.java similarity index 100% rename from src/main/java/kst4contest/model/Band.java rename to core/src/main/java/kst4contest/model/Band.java diff --git a/src/main/java/kst4contest/model/ChatCategory.java b/core/src/main/java/kst4contest/model/ChatCategory.java similarity index 100% rename from src/main/java/kst4contest/model/ChatCategory.java rename to core/src/main/java/kst4contest/model/ChatCategory.java diff --git a/src/main/java/kst4contest/model/ChatMember.java b/core/src/main/java/kst4contest/model/ChatMember.java similarity index 96% rename from src/main/java/kst4contest/model/ChatMember.java rename to core/src/main/java/kst4contest/model/ChatMember.java index c2686a27..84b1d640 100644 --- a/src/main/java/kst4contest/model/ChatMember.java +++ b/core/src/main/java/kst4contest/model/ChatMember.java @@ -6,11 +6,9 @@ import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; import java.util.OptionalDouble; +import kst4contest.observe.MutableValue; +import kst4contest.observe.SimpleValue; public class ChatMember { @@ -39,7 +37,7 @@ public class ChatMember { // String frequency; // last known qrg of the station - StringProperty frequency = new SimpleStringProperty(); + MutableValue frequency = new SimpleValue<>(); String password; // only used by own instance of the chatmember instance to login to the chat ChatCategory chatCategory; //Source category @@ -507,13 +505,13 @@ public class ChatMember { this.name = name; } - public StringProperty getFrequency() { + public MutableValue getFrequency() { return frequency; // return frequency; } - public void setFrequency(StringProperty frequency) { + public void setFrequency(MutableValue frequency) { this.frequency = frequency; } @@ -613,7 +611,7 @@ public class ChatMember { } if (frequency == null) { - frequency = new SimpleStringProperty(); + frequency = new SimpleValue<>(); } String currentFrequency = frequency.get(); @@ -689,6 +687,13 @@ public class ChatMember { * @param anotherChatMember * @return */ + /* + * Deliberately an overload of equals, not an override of equals(Object). + * FxRosterBinding compares a mirror against a freshly derived list and relies + * on List.equals falling back to reference comparison; with a real override two + * different rows sharing a callsign would compare equal and a genuine change + * could be dropped. Do not "fix" the signature without looking there first. + */ public boolean equals(ChatMember anotherChatMember) { if (this.getCallSign().toUpperCase().equals(anotherChatMember.getCallSign().toUpperCase())) { return true; diff --git a/src/main/java/kst4contest/model/ChatMemberDirectionOpportunityTest.java b/core/src/main/java/kst4contest/model/ChatMemberDirectionOpportunityTest.java similarity index 100% rename from src/main/java/kst4contest/model/ChatMemberDirectionOpportunityTest.java rename to core/src/main/java/kst4contest/model/ChatMemberDirectionOpportunityTest.java diff --git a/src/main/java/kst4contest/model/ChatMessage.java b/core/src/main/java/kst4contest/model/ChatMessage.java similarity index 100% rename from src/main/java/kst4contest/model/ChatMessage.java rename to core/src/main/java/kst4contest/model/ChatMessage.java diff --git a/src/main/java/kst4contest/model/ChatPreferences.java b/core/src/main/java/kst4contest/model/ChatPreferences.java similarity index 97% rename from src/main/java/kst4contest/model/ChatPreferences.java rename to core/src/main/java/kst4contest/model/ChatPreferences.java index 6e270147..62a9dd93 100644 --- a/src/main/java/kst4contest/model/ChatPreferences.java +++ b/core/src/main/java/kst4contest/model/ChatPreferences.java @@ -23,7 +23,6 @@ import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; -import javafx.beans.property.*; import kst4contest.ApplicationConstants; import kst4contest.utils.ApplicationFileUtils; import org.w3c.dom.Document; @@ -32,8 +31,9 @@ import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.SAXException; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; +import kst4contest.observe.MutableValue; +import kst4contest.observe.SimpleRoster; +import kst4contest.observe.SimpleValue; /** * refactored version if ChatPreferences @@ -238,7 +238,7 @@ public class ChatPreferences { ChatCategory loginChatCategoryMain = new ChatCategory(2); ChatCategory loginChatCategorySecond = new ChatCategory(3); boolean loginToSecondChatEnabled; - DoubleProperty actualQTF = new SimpleDoubleProperty(360); // will be updated by user at runtime! + MutableValue actualQTF = new SimpleValue<>(360.0); // will be updated by user at runtime! boolean stn_bandActive50; boolean stn_bandActive70; @@ -276,9 +276,9 @@ public class ChatPreferences { /** * TRX Synch prefs */ - StringProperty MYQRGFirstCat = new SimpleStringProperty(); // own qrg will be set by user entry or ucxlog if trx Synch is + MutableValue MYQRGFirstCat = new SimpleValue<>(); // own qrg will be set by user entry or ucxlog if trx Synch is // activated - StringProperty MYQRGSecondCat = new SimpleStringProperty(); // own qrg will be set by user entry or ucxlog if trx Synch is activated + MutableValue MYQRGSecondCat = new SimpleValue<>(); // own qrg will be set by user entry or ucxlog if trx Synch is activated boolean trxSynch_ucxLogUDPListenerEnabled = false; @@ -306,19 +306,19 @@ public class ChatPreferences { //DXCluster section boolean notify_dxClusterServerEnabled = true; int notify_dxclusterServerPort = 8000; //default 8000 like db0sue.de - SimpleStringProperty notify_optionalFrequencyPrefix = new SimpleStringProperty("144"); + MutableValue notify_optionalFrequencyPrefix = new SimpleValue<>("144"); - SimpleStringProperty notify_DXCSrv_SpottersCallSign = new SimpleStringProperty("DO5AMF"); + MutableValue notify_DXCSrv_SpottersCallSign = new SimpleValue<>("DO5AMF"); boolean notify_DXClusterServerTriggerBearing; boolean notify_DXClusterServerTriggerOnQRGDetect; - ObservableList + SimpleRoster lstNotify_QSOSniffer_sniffedCallSignList = - FXCollections.observableArrayList(); - // ObservableList lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList(); - ObservableList lstNotify_QSOSniffer_sniffedWordsList = FXCollections.observableArrayList(); - ObservableList lstNotify_QSOSniffer_sniffedPrefixLocList = FXCollections.observableArrayList(); + new SimpleRoster<>(); + // SimpleRoster lstNotify_QSOSniffer_sniffedCallSignList = new SimpleRoster<>(); + SimpleRoster lstNotify_QSOSniffer_sniffedWordsList = new SimpleRoster<>(); + SimpleRoster lstNotify_QSOSniffer_sniffedPrefixLocList = new SimpleRoster<>(); // Scoring / interaction metrics int notify_noReplyPenaltyMinutes = 13; // if we ping via /cq and no response arrives within X minutes => penalty strike @@ -334,8 +334,8 @@ public class ChatPreferences { */ // String[] shortcuts = new String[37]; // String[] textSnippets = new String[16]; - ObservableList lst_txtSnipList = FXCollections.observableArrayList(); - ObservableList lst_txtShortCutBtnList = FXCollections.observableArrayList(); + SimpleRoster lst_txtSnipList = new SimpleRoster<>(); + SimpleRoster lst_txtShortCutBtnList = new SimpleRoster<>(); /** * Beacon prefs @@ -433,7 +433,7 @@ public class ChatPreferences { this.messageHandling_autoAnswerEnabledSecondCat = messageHandling_autoAnswerEnabledSecondCat; } - public StringProperty getMYQRGSecondCat() { + public MutableValue getMYQRGSecondCat() { return MYQRGSecondCat; } @@ -457,7 +457,7 @@ public class ChatPreferences { return messageHandling_beaconUnworkedstationsPrefix; } - public StringProperty MYQRGFirstCatProperty() { + public MutableValue MYQRGFirstCatProperty() { return MYQRGFirstCat; } @@ -525,25 +525,27 @@ public class ChatPreferences { this.stn_on4kstServersDns = stn_on4kstServersDns; } - public ObservableList + public SimpleRoster getLstNotify_QSOSniffer_sniffedCallSignList() { return lstNotify_QSOSniffer_sniffedCallSignList; } - public ObservableList getLstNotify_QSOSniffer_sniffedWordsList() { + public SimpleRoster getLstNotify_QSOSniffer_sniffedWordsList() { return lstNotify_QSOSniffer_sniffedWordsList; } - public void setLstNotify_QSOSniffer_sniffedWordsList(ObservableList lstNotify_QSOSniffer_sniffedWordsList) { - this.lstNotify_QSOSniffer_sniffedWordsList = lstNotify_QSOSniffer_sniffedWordsList; + public void setLstNotify_QSOSniffer_sniffedWordsList(java.util.Collection lstNotify_QSOSniffer_sniffedWordsList) { + // Fill the existing roster: replacing the field would orphan its listeners. + this.lstNotify_QSOSniffer_sniffedWordsList.setAll(lstNotify_QSOSniffer_sniffedWordsList); } - public ObservableList getLstNotify_QSOSniffer_sniffedPrefixLocList() { + public SimpleRoster getLstNotify_QSOSniffer_sniffedPrefixLocList() { return lstNotify_QSOSniffer_sniffedPrefixLocList; } - public void setLstNotify_QSOSniffer_sniffedPrefixLocList(ObservableList lstNotify_QSOSniffer_sniffedPrefixLocList) { - this.lstNotify_QSOSniffer_sniffedPrefixLocList = lstNotify_QSOSniffer_sniffedPrefixLocList; + public void setLstNotify_QSOSniffer_sniffedPrefixLocList(java.util.Collection lstNotify_QSOSniffer_sniffedPrefixLocList) { + // Fill the existing roster: replacing the field would orphan its listeners. + this.lstNotify_QSOSniffer_sniffedPrefixLocList.setAll(lstNotify_QSOSniffer_sniffedPrefixLocList); } public String getLogsynch_wintestNetworkStationNameOfKST() { @@ -925,14 +927,10 @@ public class ChatPreferences { } } - public SimpleStringProperty getNotify_optionalFrequencyPrefix() { + public MutableValue getNotify_optionalFrequencyPrefix() { return notify_optionalFrequencyPrefix; } - public void setNotify_optionalFrequencyPrefix(SimpleStringProperty notify_optionalFrequencyPrefix) { - this.notify_optionalFrequencyPrefix = notify_optionalFrequencyPrefix; - } - public int getNotify_dxclusterServerPort() { return notify_dxclusterServerPort; } @@ -1239,22 +1237,18 @@ public class ChatPreferences { * */ - public StringProperty getMYQRGFirstCat() { + public MutableValue getMYQRGFirstCat() { return MYQRGFirstCat; } - public DoubleProperty getActualQTF() { + public MutableValue getActualQTF() { return actualQTF; } - public void setActualQTF(DoubleProperty actualQTF) { + public void setActualQTF(MutableValue actualQTF) { this.actualQTF = actualQTF; } - public void setMYQRGFirstCat(StringProperty mYQRG) { - MYQRGFirstCat = mYQRG; - } - public void setStn_loginCallSign(String stn_loginCallSign) { this.stn_loginCallSign = stn_loginCallSign; @@ -1349,26 +1343,14 @@ public class ChatPreferences { this.trxSynch_ucxLogUDPListenerEnabled = trxSynch_ucxLogUDPListenerEnabled; } - public SimpleStringProperty notify_optionalFrequencyPrefixProperty() { - return notify_optionalFrequencyPrefix; - } - public void setNotify_optionalFrequencyPrefix(String notify_optionalFrequencyPrefix) { this.notify_optionalFrequencyPrefix.set(notify_optionalFrequencyPrefix); } - public SimpleStringProperty getNotify_DXCSrv_SpottersCallSign() { + public MutableValue getNotify_DXCSrv_SpottersCallSign() { return notify_DXCSrv_SpottersCallSign; } - public SimpleStringProperty notify_DXCSrv_SpottersCallSignProperty() { - return notify_DXCSrv_SpottersCallSign; - } - - public void setNotify_DXCSrv_SpottersCallSign(SimpleStringProperty notify_DXCSrv_SpottersCallSign) { - this.notify_DXCSrv_SpottersCallSign.setValue(notify_DXCSrv_SpottersCallSign.getValue()); - } - // public String[] getShortcuts() { // return shortcuts; // } @@ -1389,20 +1371,22 @@ public class ChatPreferences { return bcn_beaconsEnabledMainCat; } - public ObservableList getLst_txtShortCutBtnList() { + public SimpleRoster getLst_txtShortCutBtnList() { return lst_txtShortCutBtnList; } - public void setLst_txtShortCutBtnList(ObservableList lst_txtShortCutBtnList) { - this.lst_txtShortCutBtnList = lst_txtShortCutBtnList; + public void setLst_txtShortCutBtnList(java.util.Collection lst_txtShortCutBtnList) { + // Fill the existing roster: replacing the field would orphan its listeners. + this.lst_txtShortCutBtnList.setAll(lst_txtShortCutBtnList); } - public ObservableList getLst_txtSnipList() { + public SimpleRoster getLst_txtSnipList() { return lst_txtSnipList; } - public void setLst_txtSnipList(ObservableList lst_txtSnipList) { - this.lst_txtSnipList = lst_txtSnipList; + public void setLst_txtSnipList(java.util.Collection lst_txtSnipList) { + // Fill the existing roster: replacing the field would orphan its listeners. + this.lst_txtSnipList.setAll(lst_txtSnipList); } public void setBcn_beaconsEnabledMainCat(boolean bcn_beaconsEnabledMainCat) { @@ -1771,12 +1755,12 @@ public class ChatPreferences { trxSynchUCX.appendChild(trxSynch_ucxLogUDPListenerEnabled); Element trxSynch_defaultMYQRGValue = doc.createElement("trxSynch_defaultMYQRGValue"); - trxSynch_defaultMYQRGValue.setTextContent(this.getMYQRGFirstCat().getValue()); + trxSynch_defaultMYQRGValue.setTextContent(this.getMYQRGFirstCat().get()); trxSynchUCX.appendChild(trxSynch_defaultMYQRGValue); Element trxSynch_defaultMYQRG2Value = doc.createElement("trxSynch_defaultMYQRG2Value"); // Safe null check falls Property noch nicht initialisiert ist - trxSynch_defaultMYQRG2Value.setTextContent(this.getMYQRGSecondCat().getValue() != null ? this.getMYQRGSecondCat().getValue() : "1296.200.00"); + trxSynch_defaultMYQRG2Value.setTextContent(this.getMYQRGSecondCat().get() != null ? this.getMYQRGSecondCat().get() : "1296.200.00"); trxSynchUCX.appendChild(trxSynch_defaultMYQRG2Value); @@ -1887,7 +1871,7 @@ public class ChatPreferences { Element shortCuts = doc.createElement("shortCuts"); rootElement.appendChild(shortCuts); - for (Iterator iterator = lst_txtShortCutBtnList.iterator(); iterator.hasNext();) { + for (Iterator iterator = lst_txtShortCutBtnList.snapshot().iterator(); iterator.hasNext();) { String string = (String) iterator.next(); Element temp = doc.createElement("t"); temp.setTextContent(string); @@ -1900,7 +1884,7 @@ public class ChatPreferences { Element snifferWords = doc.createElement("snifferWords"); rootElement.appendChild(snifferWords); - for (String word : lstNotify_QSOSniffer_sniffedWordsList) { + for (String word : lstNotify_QSOSniffer_sniffedWordsList.snapshot()) { Element temp = doc.createElement("w"); temp.setTextContent(word); snifferWords.appendChild(temp); @@ -1911,7 +1895,7 @@ public class ChatPreferences { rootElement.appendChild(snifferCallSigns); for (String callSign - : lstNotify_QSOSniffer_sniffedCallSignList) { + : lstNotify_QSOSniffer_sniffedCallSignList.snapshot()) { Element temp = doc.createElement("callSign"); temp.setTextContent(callSign); snifferCallSigns.appendChild(temp); @@ -1921,7 +1905,7 @@ public class ChatPreferences { Element snifferPrefixes = doc.createElement("snifferPrefixes"); rootElement.appendChild(snifferPrefixes); - for (String prefix : lstNotify_QSOSniffer_sniffedPrefixLocList) { + for (String prefix : lstNotify_QSOSniffer_sniffedPrefixLocList.snapshot()) { Element temp = doc.createElement("p"); temp.setTextContent(prefix); snifferPrefixes.appendChild(temp); @@ -1934,7 +1918,7 @@ public class ChatPreferences { Element textSnippets = doc.createElement("textSnippets"); rootElement.appendChild(textSnippets); - for (Iterator iterator = lst_txtSnipList.iterator(); iterator.hasNext();) { + for (Iterator iterator = lst_txtSnipList.snapshot().iterator(); iterator.hasNext();) { String string = (String) iterator.next(); Element temp = doc.createElement("t"); temp.setTextContent(string); @@ -2619,14 +2603,14 @@ public class ChatPreferences { String qrg1 = getText(trxSynchEl, null, "trxSynch_defaultMYQRGValue"); if (qrg1 != null) { - this.getMYQRGFirstCat().setValue(qrg1); + this.getMYQRGFirstCat().set(qrg1); } String qrg2 = getText(trxSynchEl, "1296.123.00", "trxSynch_defaultMYQRG2Value"); - this.getMYQRGSecondCat().setValue(qrg2); + this.getMYQRGSecondCat().set(qrg2); System.out.println( "[ChatPreferences, info]: trx qrg synch=" + trxSynch_ucxLogUDPListenerEnabled - + ", default=" + this.getMYQRGFirstCat().getValue() + " // " + this.getMYQRGSecondCat().getValue()); + + ", default=" + this.getMYQRGFirstCat().get() + " // " + this.getMYQRGSecondCat().get()); } /** @@ -2846,7 +2830,7 @@ public class ChatPreferences { callSign ); - if (!lstNotify_QSOSniffer_sniffedCallSignList + if (!lstNotify_QSOSniffer_sniffedCallSignList.snapshot() .contains(normalizedCallSign)) { lstNotify_QSOSniffer_sniffedCallSignList .add(normalizedCallSign); @@ -2887,7 +2871,7 @@ public class ChatPreferences { if (normalizedCallSign != null && !normalizedCallSign.isBlank() - && !lstNotify_QSOSniffer_sniffedCallSignList + && !lstNotify_QSOSniffer_sniffedCallSignList.snapshot() .contains(normalizedCallSign)) { lstNotify_QSOSniffer_sniffedCallSignList .add(normalizedCallSign); @@ -3528,7 +3512,7 @@ public class ChatPreferences { } // public ObservableList getTxtSnippetsAsObservableList(){ -// ObservableList lst_txtSnipList = FXCollections.observableArrayList(); +// SimpleRoster lst_txtSnipList = new SimpleRoster<>(); // // for (int i = 0; i < textSnippets.length; i++) { // lst_txtSnipList.add(textSnippets[i]); diff --git a/src/main/java/kst4contest/model/ClusterMessage.java b/core/src/main/java/kst4contest/model/ClusterMessage.java similarity index 100% rename from src/main/java/kst4contest/model/ClusterMessage.java rename to core/src/main/java/kst4contest/model/ClusterMessage.java diff --git a/src/main/java/kst4contest/model/ContestSked.java b/core/src/main/java/kst4contest/model/ContestSked.java similarity index 100% rename from src/main/java/kst4contest/model/ContestSked.java rename to core/src/main/java/kst4contest/model/ContestSked.java diff --git a/src/main/java/kst4contest/model/OperatorProfile.java b/core/src/main/java/kst4contest/model/OperatorProfile.java similarity index 100% rename from src/main/java/kst4contest/model/OperatorProfile.java rename to core/src/main/java/kst4contest/model/OperatorProfile.java diff --git a/src/main/java/kst4contest/model/OperatorProfileSelection.java b/core/src/main/java/kst4contest/model/OperatorProfileSelection.java similarity index 100% rename from src/main/java/kst4contest/model/OperatorProfileSelection.java rename to core/src/main/java/kst4contest/model/OperatorProfileSelection.java diff --git a/src/main/java/kst4contest/model/ThreadStateMessage.java b/core/src/main/java/kst4contest/model/ThreadStateMessage.java similarity index 100% rename from src/main/java/kst4contest/model/ThreadStateMessage.java rename to core/src/main/java/kst4contest/model/ThreadStateMessage.java diff --git a/src/main/java/kst4contest/model/UpdateInformation.java b/core/src/main/java/kst4contest/model/UpdateInformation.java similarity index 100% rename from src/main/java/kst4contest/model/UpdateInformation.java rename to core/src/main/java/kst4contest/model/UpdateInformation.java diff --git a/core/src/main/java/kst4contest/observe/DirectUiDispatcher.java b/core/src/main/java/kst4contest/observe/DirectUiDispatcher.java new file mode 100644 index 00000000..af1ceb2a --- /dev/null +++ b/core/src/main/java/kst4contest/observe/DirectUiDispatcher.java @@ -0,0 +1,22 @@ +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; + } +} diff --git a/core/src/main/java/kst4contest/observe/MutableValue.java b/core/src/main/java/kst4contest/observe/MutableValue.java new file mode 100644 index 00000000..965d50b8 --- /dev/null +++ b/core/src/main/java/kst4contest/observe/MutableValue.java @@ -0,0 +1,11 @@ +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); +} diff --git a/core/src/main/java/kst4contest/observe/ObservableRoster.java b/core/src/main/java/kst4contest/observe/ObservableRoster.java new file mode 100644 index 00000000..e43af500 --- /dev/null +++ b/core/src/main/java/kst4contest/observe/ObservableRoster.java @@ -0,0 +1,33 @@ +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(); + + default boolean isEmpty() { + return size() == 0; + } + + /** 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); +} diff --git a/core/src/main/java/kst4contest/observe/ObservableValue.java b/core/src/main/java/kst4contest/observe/ObservableValue.java new file mode 100644 index 00000000..a0678736 --- /dev/null +++ b/core/src/main/java/kst4contest/observe/ObservableValue.java @@ -0,0 +1,22 @@ +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); +} diff --git a/core/src/main/java/kst4contest/observe/SimpleRoster.java b/core/src/main/java/kst4contest/observe/SimpleRoster.java new file mode 100644 index 00000000..34d0a765 --- /dev/null +++ b/core/src/main/java/kst4contest/observe/SimpleRoster.java @@ -0,0 +1,119 @@ +package kst4contest.observe; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +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<>(); + + /** + * The last handed-out snapshot, reused until the content changes. Several + * consumers read the same roster per batch — four message tabs, for one — and + * copying the whole store for each of them is wasted work. + */ + private volatile List snapshotCache; + + @Override + public List snapshot() { + List cached = snapshotCache; + if (cached != null) { + return cached; + } + synchronized (lock) { + if (snapshotCache == null) { + snapshotCache = newSnapshot(); + } + return snapshotCache; + } + } + + /* + * Collections.unmodifiableList and not List.copyOf: the JavaFX list this + * replaces carried null entries, and List.copyOf rejects them. A NullPointerException + * raised here would be swallowed by the listener guard and the mirror would stop + * updating without a word. + */ + private List newSnapshot() { + return Collections.unmodifiableList(new ArrayList<>(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 = newSnapshot(); + snapshotCache = snapshot; + } + 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); + } +} diff --git a/core/src/main/java/kst4contest/observe/SimpleValue.java b/core/src/main/java/kst4contest/observe/SimpleValue.java new file mode 100644 index 00000000..f8979fd6 --- /dev/null +++ b/core/src/main/java/kst4contest/observe/SimpleValue.java @@ -0,0 +1,57 @@ +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); + } +} diff --git a/core/src/main/java/kst4contest/observe/UiDispatcher.java b/core/src/main/java/kst4contest/observe/UiDispatcher.java new file mode 100644 index 00000000..5938d40b --- /dev/null +++ b/core/src/main/java/kst4contest/observe/UiDispatcher.java @@ -0,0 +1,18 @@ +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(); +} diff --git a/src/main/java/kst4contest/service/path/FresnelMathUtils.java b/core/src/main/java/kst4contest/service/path/FresnelMathUtils.java similarity index 100% rename from src/main/java/kst4contest/service/path/FresnelMathUtils.java rename to core/src/main/java/kst4contest/service/path/FresnelMathUtils.java diff --git a/src/main/java/kst4contest/test/MockKstServer.java b/core/src/main/java/kst4contest/test/MockKstServer.java similarity index 100% rename from src/main/java/kst4contest/test/MockKstServer.java rename to core/src/main/java/kst4contest/test/MockKstServer.java diff --git a/src/main/java/kst4contest/utils/ApplicationFileLogging.java b/core/src/main/java/kst4contest/utils/ApplicationFileLogging.java similarity index 100% rename from src/main/java/kst4contest/utils/ApplicationFileLogging.java rename to core/src/main/java/kst4contest/utils/ApplicationFileLogging.java diff --git a/src/main/java/kst4contest/utils/ApplicationFileUtils.java b/core/src/main/java/kst4contest/utils/ApplicationFileUtils.java similarity index 100% rename from src/main/java/kst4contest/utils/ApplicationFileUtils.java rename to core/src/main/java/kst4contest/utils/ApplicationFileUtils.java diff --git a/src/main/java/kst4contest/utils/PlatformUtils.java b/core/src/main/java/kst4contest/utils/PlatformUtils.java similarity index 100% rename from src/main/java/kst4contest/utils/PlatformUtils.java rename to core/src/main/java/kst4contest/utils/PlatformUtils.java diff --git a/src/main/java/kst4contest/utils/PlayAudioUtils.java b/core/src/main/java/kst4contest/utils/PlayAudioUtils.java similarity index 100% rename from src/main/java/kst4contest/utils/PlayAudioUtils.java rename to core/src/main/java/kst4contest/utils/PlayAudioUtils.java diff --git a/src/main/java/kst4contest/utils/VersionUtils.java b/core/src/main/java/kst4contest/utils/VersionUtils.java similarity index 100% rename from src/main/java/kst4contest/utils/VersionUtils.java rename to core/src/main/java/kst4contest/utils/VersionUtils.java diff --git a/src/main/java/kst4contest/view/map/ChainedTerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/ChainedTerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/ChainedTerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/ChainedTerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/CopernicusGlo30TerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/CopernicusGlo30TerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/CopernicusGlo30TerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/CopernicusGlo30TerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/DemDataset.java b/core/src/main/java/kst4contest/view/map/DemDataset.java similarity index 100% rename from src/main/java/kst4contest/view/map/DemDataset.java rename to core/src/main/java/kst4contest/view/map/DemDataset.java diff --git a/src/main/java/kst4contest/view/map/FallbackTerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/FallbackTerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/FallbackTerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/FallbackTerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/GeometryOnlyPathAnalysisService.java b/core/src/main/java/kst4contest/view/map/GeometryOnlyPathAnalysisService.java similarity index 100% rename from src/main/java/kst4contest/view/map/GeometryOnlyPathAnalysisService.java rename to core/src/main/java/kst4contest/view/map/GeometryOnlyPathAnalysisService.java diff --git a/src/main/java/kst4contest/view/map/MaidenheadGridRenderPlanner.java b/core/src/main/java/kst4contest/view/map/MaidenheadGridRenderPlanner.java similarity index 100% rename from src/main/java/kst4contest/view/map/MaidenheadGridRenderPlanner.java rename to core/src/main/java/kst4contest/view/map/MaidenheadGridRenderPlanner.java diff --git a/src/main/java/kst4contest/view/map/MaidenheadGridUtils.java b/core/src/main/java/kst4contest/view/map/MaidenheadGridUtils.java similarity index 100% rename from src/main/java/kst4contest/view/map/MaidenheadGridUtils.java rename to core/src/main/java/kst4contest/view/map/MaidenheadGridUtils.java diff --git a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java b/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java similarity index 100% rename from src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java rename to core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java diff --git a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java b/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java similarity index 100% rename from src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java rename to core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java diff --git a/src/main/java/kst4contest/view/map/NoOpTerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/NoOpTerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/NoOpTerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/NoOpTerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/OfflineDemImportService.java b/core/src/main/java/kst4contest/view/map/OfflineDemImportService.java similarity index 100% rename from src/main/java/kst4contest/view/map/OfflineDemImportService.java rename to core/src/main/java/kst4contest/view/map/OfflineDemImportService.java diff --git a/src/main/java/kst4contest/view/map/OfflineDemManager.java b/core/src/main/java/kst4contest/view/map/OfflineDemManager.java similarity index 100% rename from src/main/java/kst4contest/view/map/OfflineDemManager.java rename to core/src/main/java/kst4contest/view/map/OfflineDemManager.java diff --git a/src/main/java/kst4contest/view/map/OfflineDemTerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/OfflineDemTerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/OfflineDemTerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/OfflineDemTerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/OpenMeteoTerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/OpenMeteoTerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/OpenMeteoTerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/OpenMeteoTerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/PathAnalysisRequest.java b/core/src/main/java/kst4contest/view/map/PathAnalysisRequest.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathAnalysisRequest.java rename to core/src/main/java/kst4contest/view/map/PathAnalysisRequest.java diff --git a/src/main/java/kst4contest/view/map/PathAnalysisResult.java b/core/src/main/java/kst4contest/view/map/PathAnalysisResult.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathAnalysisResult.java rename to core/src/main/java/kst4contest/view/map/PathAnalysisResult.java diff --git a/src/main/java/kst4contest/view/map/PathAnalysisService.java b/core/src/main/java/kst4contest/view/map/PathAnalysisService.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathAnalysisService.java rename to core/src/main/java/kst4contest/view/map/PathAnalysisService.java diff --git a/src/main/java/kst4contest/view/map/PathGeometryUtils.java b/core/src/main/java/kst4contest/view/map/PathGeometryUtils.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathGeometryUtils.java rename to core/src/main/java/kst4contest/view/map/PathGeometryUtils.java diff --git a/src/main/java/kst4contest/view/map/PathHorizonSummary.java b/core/src/main/java/kst4contest/view/map/PathHorizonSummary.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathHorizonSummary.java rename to core/src/main/java/kst4contest/view/map/PathHorizonSummary.java diff --git a/src/main/java/kst4contest/view/map/PathLinkBudgetSettings.java b/core/src/main/java/kst4contest/view/map/PathLinkBudgetSettings.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathLinkBudgetSettings.java rename to core/src/main/java/kst4contest/view/map/PathLinkBudgetSettings.java diff --git a/src/main/java/kst4contest/view/map/PathLinkBudgetSummary.java b/core/src/main/java/kst4contest/view/map/PathLinkBudgetSummary.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathLinkBudgetSummary.java rename to core/src/main/java/kst4contest/view/map/PathLinkBudgetSummary.java diff --git a/src/main/java/kst4contest/view/map/PathObstructionSummary.java b/core/src/main/java/kst4contest/view/map/PathObstructionSummary.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathObstructionSummary.java rename to core/src/main/java/kst4contest/view/map/PathObstructionSummary.java diff --git a/src/main/java/kst4contest/view/map/PathProfilePoint.java b/core/src/main/java/kst4contest/view/map/PathProfilePoint.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathProfilePoint.java rename to core/src/main/java/kst4contest/view/map/PathProfilePoint.java diff --git a/src/main/java/kst4contest/view/map/PathPropagationAssessment.java b/core/src/main/java/kst4contest/view/map/PathPropagationAssessment.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathPropagationAssessment.java rename to core/src/main/java/kst4contest/view/map/PathPropagationAssessment.java diff --git a/src/main/java/kst4contest/view/map/SyntheticTerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/SyntheticTerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/SyntheticTerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/SyntheticTerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/TerrainCatalog.java b/core/src/main/java/kst4contest/view/map/TerrainCatalog.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainCatalog.java rename to core/src/main/java/kst4contest/view/map/TerrainCatalog.java diff --git a/src/main/java/kst4contest/view/map/TerrainCatalogClient.java b/core/src/main/java/kst4contest/view/map/TerrainCatalogClient.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainCatalogClient.java rename to core/src/main/java/kst4contest/view/map/TerrainCatalogClient.java diff --git a/src/main/java/kst4contest/view/map/TerrainCatalogPackageEntry.java b/core/src/main/java/kst4contest/view/map/TerrainCatalogPackageEntry.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainCatalogPackageEntry.java rename to core/src/main/java/kst4contest/view/map/TerrainCatalogPackageEntry.java diff --git a/src/main/java/kst4contest/view/map/TerrainCoverageResolver.java b/core/src/main/java/kst4contest/view/map/TerrainCoverageResolver.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainCoverageResolver.java rename to core/src/main/java/kst4contest/view/map/TerrainCoverageResolver.java diff --git a/src/main/java/kst4contest/view/map/TerrainPackageDownloader.java b/core/src/main/java/kst4contest/view/map/TerrainPackageDownloader.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainPackageDownloader.java rename to core/src/main/java/kst4contest/view/map/TerrainPackageDownloader.java diff --git a/src/main/java/kst4contest/view/map/TerrainPackageInstaller.java b/core/src/main/java/kst4contest/view/map/TerrainPackageInstaller.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainPackageInstaller.java rename to core/src/main/java/kst4contest/view/map/TerrainPackageInstaller.java diff --git a/src/main/java/kst4contest/view/map/TerrainPackageManifest.java b/core/src/main/java/kst4contest/view/map/TerrainPackageManifest.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainPackageManifest.java rename to core/src/main/java/kst4contest/view/map/TerrainPackageManifest.java diff --git a/src/main/java/kst4contest/view/map/TerrainPackageService.java b/core/src/main/java/kst4contest/view/map/TerrainPackageService.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainPackageService.java rename to core/src/main/java/kst4contest/view/map/TerrainPackageService.java diff --git a/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java b/core/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java rename to core/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java diff --git a/src/main/java/kst4contest/view/map/TerrainProfileData.java b/core/src/main/java/kst4contest/view/map/TerrainProfileData.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainProfileData.java rename to core/src/main/java/kst4contest/view/map/TerrainProfileData.java diff --git a/src/main/java/kst4contest/view/map/TerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/TerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/TerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/TerrainProfileRequest.java b/core/src/main/java/kst4contest/view/map/TerrainProfileRequest.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainProfileRequest.java rename to core/src/main/java/kst4contest/view/map/TerrainProfileRequest.java diff --git a/src/main/java/kst4contest/view/map/TerrainTileMetadata.java b/core/src/main/java/kst4contest/view/map/TerrainTileMetadata.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainTileMetadata.java rename to core/src/main/java/kst4contest/view/map/TerrainTileMetadata.java diff --git a/src/main/java/kst4contest/view/map/TileProxyServer.java b/core/src/main/java/kst4contest/view/map/TileProxyServer.java similarity index 100% rename from src/main/java/kst4contest/view/map/TileProxyServer.java rename to core/src/main/java/kst4contest/view/map/TileProxyServer.java diff --git a/src/main/java/kst4contest/view/map/mapTest.java b/core/src/main/java/kst4contest/view/map/mapTest.java similarity index 100% rename from src/main/java/kst4contest/view/map/mapTest.java rename to core/src/main/java/kst4contest/view/map/mapTest.java diff --git a/src/main/resources/LTTR0.mp3 b/core/src/main/resources/LTTR0.mp3 similarity index 100% rename from src/main/resources/LTTR0.mp3 rename to core/src/main/resources/LTTR0.mp3 diff --git a/src/main/resources/LTTR1.mp3 b/core/src/main/resources/LTTR1.mp3 similarity index 100% rename from src/main/resources/LTTR1.mp3 rename to core/src/main/resources/LTTR1.mp3 diff --git a/src/main/resources/LTTR2.mp3 b/core/src/main/resources/LTTR2.mp3 similarity index 100% rename from src/main/resources/LTTR2.mp3 rename to core/src/main/resources/LTTR2.mp3 diff --git a/src/main/resources/LTTR3.mp3 b/core/src/main/resources/LTTR3.mp3 similarity index 100% rename from src/main/resources/LTTR3.mp3 rename to core/src/main/resources/LTTR3.mp3 diff --git a/src/main/resources/LTTR4.mp3 b/core/src/main/resources/LTTR4.mp3 similarity index 100% rename from src/main/resources/LTTR4.mp3 rename to core/src/main/resources/LTTR4.mp3 diff --git a/src/main/resources/LTTR5.mp3 b/core/src/main/resources/LTTR5.mp3 similarity index 100% rename from src/main/resources/LTTR5.mp3 rename to core/src/main/resources/LTTR5.mp3 diff --git a/src/main/resources/LTTR6.mp3 b/core/src/main/resources/LTTR6.mp3 similarity index 100% rename from src/main/resources/LTTR6.mp3 rename to core/src/main/resources/LTTR6.mp3 diff --git a/src/main/resources/LTTR7.mp3 b/core/src/main/resources/LTTR7.mp3 similarity index 100% rename from src/main/resources/LTTR7.mp3 rename to core/src/main/resources/LTTR7.mp3 diff --git a/src/main/resources/LTTR8.mp3 b/core/src/main/resources/LTTR8.mp3 similarity index 100% rename from src/main/resources/LTTR8.mp3 rename to core/src/main/resources/LTTR8.mp3 diff --git a/src/main/resources/LTTR9.mp3 b/core/src/main/resources/LTTR9.mp3 similarity index 100% rename from src/main/resources/LTTR9.mp3 rename to core/src/main/resources/LTTR9.mp3 diff --git a/src/main/resources/LTTRA.mp3 b/core/src/main/resources/LTTRA.mp3 similarity index 100% rename from src/main/resources/LTTRA.mp3 rename to core/src/main/resources/LTTRA.mp3 diff --git a/src/main/resources/LTTRB.mp3 b/core/src/main/resources/LTTRB.mp3 similarity index 100% rename from src/main/resources/LTTRB.mp3 rename to core/src/main/resources/LTTRB.mp3 diff --git a/src/main/resources/LTTRC.mp3 b/core/src/main/resources/LTTRC.mp3 similarity index 100% rename from src/main/resources/LTTRC.mp3 rename to core/src/main/resources/LTTRC.mp3 diff --git a/src/main/resources/LTTRD.mp3 b/core/src/main/resources/LTTRD.mp3 similarity index 100% rename from src/main/resources/LTTRD.mp3 rename to core/src/main/resources/LTTRD.mp3 diff --git a/src/main/resources/LTTRE.mp3 b/core/src/main/resources/LTTRE.mp3 similarity index 100% rename from src/main/resources/LTTRE.mp3 rename to core/src/main/resources/LTTRE.mp3 diff --git a/src/main/resources/LTTRF.mp3 b/core/src/main/resources/LTTRF.mp3 similarity index 100% rename from src/main/resources/LTTRF.mp3 rename to core/src/main/resources/LTTRF.mp3 diff --git a/src/main/resources/LTTRG.mp3 b/core/src/main/resources/LTTRG.mp3 similarity index 100% rename from src/main/resources/LTTRG.mp3 rename to core/src/main/resources/LTTRG.mp3 diff --git a/src/main/resources/LTTRH.mp3 b/core/src/main/resources/LTTRH.mp3 similarity index 100% rename from src/main/resources/LTTRH.mp3 rename to core/src/main/resources/LTTRH.mp3 diff --git a/src/main/resources/LTTRI.mp3 b/core/src/main/resources/LTTRI.mp3 similarity index 100% rename from src/main/resources/LTTRI.mp3 rename to core/src/main/resources/LTTRI.mp3 diff --git a/src/main/resources/LTTRJ.mp3 b/core/src/main/resources/LTTRJ.mp3 similarity index 100% rename from src/main/resources/LTTRJ.mp3 rename to core/src/main/resources/LTTRJ.mp3 diff --git a/src/main/resources/LTTRK.mp3 b/core/src/main/resources/LTTRK.mp3 similarity index 100% rename from src/main/resources/LTTRK.mp3 rename to core/src/main/resources/LTTRK.mp3 diff --git a/src/main/resources/LTTRL.mp3 b/core/src/main/resources/LTTRL.mp3 similarity index 100% rename from src/main/resources/LTTRL.mp3 rename to core/src/main/resources/LTTRL.mp3 diff --git a/src/main/resources/LTTRM.mp3 b/core/src/main/resources/LTTRM.mp3 similarity index 100% rename from src/main/resources/LTTRM.mp3 rename to core/src/main/resources/LTTRM.mp3 diff --git a/src/main/resources/LTTRMINUS.mp3 b/core/src/main/resources/LTTRMINUS.mp3 similarity index 100% rename from src/main/resources/LTTRMINUS.mp3 rename to core/src/main/resources/LTTRMINUS.mp3 diff --git a/src/main/resources/LTTRN.mp3 b/core/src/main/resources/LTTRN.mp3 similarity index 100% rename from src/main/resources/LTTRN.mp3 rename to core/src/main/resources/LTTRN.mp3 diff --git a/src/main/resources/LTTRO.mp3 b/core/src/main/resources/LTTRO.mp3 similarity index 100% rename from src/main/resources/LTTRO.mp3 rename to core/src/main/resources/LTTRO.mp3 diff --git a/src/main/resources/LTTRP.mp3 b/core/src/main/resources/LTTRP.mp3 similarity index 100% rename from src/main/resources/LTTRP.mp3 rename to core/src/main/resources/LTTRP.mp3 diff --git a/src/main/resources/LTTRPOINT.mp3 b/core/src/main/resources/LTTRPOINT.mp3 similarity index 100% rename from src/main/resources/LTTRPOINT.mp3 rename to core/src/main/resources/LTTRPOINT.mp3 diff --git a/src/main/resources/LTTRQ.mp3 b/core/src/main/resources/LTTRQ.mp3 similarity index 100% rename from src/main/resources/LTTRQ.mp3 rename to core/src/main/resources/LTTRQ.mp3 diff --git a/src/main/resources/LTTRQUESTMARK.mp3 b/core/src/main/resources/LTTRQUESTMARK.mp3 similarity index 100% rename from src/main/resources/LTTRQUESTMARK.mp3 rename to core/src/main/resources/LTTRQUESTMARK.mp3 diff --git a/src/main/resources/LTTRR.mp3 b/core/src/main/resources/LTTRR.mp3 similarity index 100% rename from src/main/resources/LTTRR.mp3 rename to core/src/main/resources/LTTRR.mp3 diff --git a/src/main/resources/LTTRS.mp3 b/core/src/main/resources/LTTRS.mp3 similarity index 100% rename from src/main/resources/LTTRS.mp3 rename to core/src/main/resources/LTTRS.mp3 diff --git a/src/main/resources/LTTRSPACE.mp3 b/core/src/main/resources/LTTRSPACE.mp3 similarity index 100% rename from src/main/resources/LTTRSPACE.mp3 rename to core/src/main/resources/LTTRSPACE.mp3 diff --git a/src/main/resources/LTTRSTROKE.mp3 b/core/src/main/resources/LTTRSTROKE.mp3 similarity index 100% rename from src/main/resources/LTTRSTROKE.mp3 rename to core/src/main/resources/LTTRSTROKE.mp3 diff --git a/src/main/resources/LTTRT.mp3 b/core/src/main/resources/LTTRT.mp3 similarity index 100% rename from src/main/resources/LTTRT.mp3 rename to core/src/main/resources/LTTRT.mp3 diff --git a/src/main/resources/LTTRU.mp3 b/core/src/main/resources/LTTRU.mp3 similarity index 100% rename from src/main/resources/LTTRU.mp3 rename to core/src/main/resources/LTTRU.mp3 diff --git a/src/main/resources/LTTRV.mp3 b/core/src/main/resources/LTTRV.mp3 similarity index 100% rename from src/main/resources/LTTRV.mp3 rename to core/src/main/resources/LTTRV.mp3 diff --git a/src/main/resources/LTTRW.mp3 b/core/src/main/resources/LTTRW.mp3 similarity index 100% rename from src/main/resources/LTTRW.mp3 rename to core/src/main/resources/LTTRW.mp3 diff --git a/src/main/resources/LTTRX.mp3 b/core/src/main/resources/LTTRX.mp3 similarity index 100% rename from src/main/resources/LTTRX.mp3 rename to core/src/main/resources/LTTRX.mp3 diff --git a/src/main/resources/LTTRY.mp3 b/core/src/main/resources/LTTRY.mp3 similarity index 100% rename from src/main/resources/LTTRY.mp3 rename to core/src/main/resources/LTTRY.mp3 diff --git a/src/main/resources/LTTRZ.mp3 b/core/src/main/resources/LTTRZ.mp3 similarity index 100% rename from src/main/resources/LTTRZ.mp3 rename to core/src/main/resources/LTTRZ.mp3 diff --git a/src/main/resources/NOISECQWINDOW.mp3 b/core/src/main/resources/NOISECQWINDOW.mp3 similarity index 100% rename from src/main/resources/NOISECQWINDOW.mp3 rename to core/src/main/resources/NOISECQWINDOW.mp3 diff --git a/src/main/resources/NOISEERROR.mp3 b/core/src/main/resources/NOISEERROR.mp3 similarity index 100% rename from src/main/resources/NOISEERROR.mp3 rename to core/src/main/resources/NOISEERROR.mp3 diff --git a/src/main/resources/NOISENOTIFY.mp3 b/core/src/main/resources/NOISENOTIFY.mp3 similarity index 100% rename from src/main/resources/NOISENOTIFY.mp3 rename to core/src/main/resources/NOISENOTIFY.mp3 diff --git a/src/main/resources/NOISEPMWINDOW.mp3 b/core/src/main/resources/NOISEPMWINDOW.mp3 similarity index 100% rename from src/main/resources/NOISEPMWINDOW.mp3 rename to core/src/main/resources/NOISEPMWINDOW.mp3 diff --git a/src/main/resources/NOISESTARTUP.mp3 b/core/src/main/resources/NOISESTARTUP.mp3 similarity index 100% rename from src/main/resources/NOISESTARTUP.mp3 rename to core/src/main/resources/NOISESTARTUP.mp3 diff --git a/src/main/resources/VOICE0.mp3 b/core/src/main/resources/VOICE0.mp3 similarity index 100% rename from src/main/resources/VOICE0.mp3 rename to core/src/main/resources/VOICE0.mp3 diff --git a/src/main/resources/VOICE1.mp3 b/core/src/main/resources/VOICE1.mp3 similarity index 100% rename from src/main/resources/VOICE1.mp3 rename to core/src/main/resources/VOICE1.mp3 diff --git a/src/main/resources/VOICE2.mp3 b/core/src/main/resources/VOICE2.mp3 similarity index 100% rename from src/main/resources/VOICE2.mp3 rename to core/src/main/resources/VOICE2.mp3 diff --git a/src/main/resources/VOICE3.mp3 b/core/src/main/resources/VOICE3.mp3 similarity index 100% rename from src/main/resources/VOICE3.mp3 rename to core/src/main/resources/VOICE3.mp3 diff --git a/src/main/resources/VOICE4.mp3 b/core/src/main/resources/VOICE4.mp3 similarity index 100% rename from src/main/resources/VOICE4.mp3 rename to core/src/main/resources/VOICE4.mp3 diff --git a/src/main/resources/VOICE5.mp3 b/core/src/main/resources/VOICE5.mp3 similarity index 100% rename from src/main/resources/VOICE5.mp3 rename to core/src/main/resources/VOICE5.mp3 diff --git a/src/main/resources/VOICE6.mp3 b/core/src/main/resources/VOICE6.mp3 similarity index 100% rename from src/main/resources/VOICE6.mp3 rename to core/src/main/resources/VOICE6.mp3 diff --git a/src/main/resources/VOICE7.mp3 b/core/src/main/resources/VOICE7.mp3 similarity index 100% rename from src/main/resources/VOICE7.mp3 rename to core/src/main/resources/VOICE7.mp3 diff --git a/src/main/resources/VOICE73.mp3 b/core/src/main/resources/VOICE73.mp3 similarity index 100% rename from src/main/resources/VOICE73.mp3 rename to core/src/main/resources/VOICE73.mp3 diff --git a/src/main/resources/VOICE8.mp3 b/core/src/main/resources/VOICE8.mp3 similarity index 100% rename from src/main/resources/VOICE8.mp3 rename to core/src/main/resources/VOICE8.mp3 diff --git a/src/main/resources/VOICE9.mp3 b/core/src/main/resources/VOICE9.mp3 similarity index 100% rename from src/main/resources/VOICE9.mp3 rename to core/src/main/resources/VOICE9.mp3 diff --git a/src/main/resources/VOICEA.mp3 b/core/src/main/resources/VOICEA.mp3 similarity index 100% rename from src/main/resources/VOICEA.mp3 rename to core/src/main/resources/VOICEA.mp3 diff --git a/src/main/resources/VOICEB.mp3 b/core/src/main/resources/VOICEB.mp3 similarity index 100% rename from src/main/resources/VOICEB.mp3 rename to core/src/main/resources/VOICEB.mp3 diff --git a/src/main/resources/VOICEBELL.mp3 b/core/src/main/resources/VOICEBELL.mp3 similarity index 100% rename from src/main/resources/VOICEBELL.mp3 rename to core/src/main/resources/VOICEBELL.mp3 diff --git a/src/main/resources/VOICEC.mp3 b/core/src/main/resources/VOICEC.mp3 similarity index 100% rename from src/main/resources/VOICEC.mp3 rename to core/src/main/resources/VOICEC.mp3 diff --git a/src/main/resources/VOICED.mp3 b/core/src/main/resources/VOICED.mp3 similarity index 100% rename from src/main/resources/VOICED.mp3 rename to core/src/main/resources/VOICED.mp3 diff --git a/src/main/resources/VOICEE.mp3 b/core/src/main/resources/VOICEE.mp3 similarity index 100% rename from src/main/resources/VOICEE.mp3 rename to core/src/main/resources/VOICEE.mp3 diff --git a/src/main/resources/VOICEF.mp3 b/core/src/main/resources/VOICEF.mp3 similarity index 100% rename from src/main/resources/VOICEF.mp3 rename to core/src/main/resources/VOICEF.mp3 diff --git a/src/main/resources/VOICEG.mp3 b/core/src/main/resources/VOICEG.mp3 similarity index 100% rename from src/main/resources/VOICEG.mp3 rename to core/src/main/resources/VOICEG.mp3 diff --git a/src/main/resources/VOICEH.mp3 b/core/src/main/resources/VOICEH.mp3 similarity index 100% rename from src/main/resources/VOICEH.mp3 rename to core/src/main/resources/VOICEH.mp3 diff --git a/src/main/resources/VOICEHELLO.mp3 b/core/src/main/resources/VOICEHELLO.mp3 similarity index 100% rename from src/main/resources/VOICEHELLO.mp3 rename to core/src/main/resources/VOICEHELLO.mp3 diff --git a/src/main/resources/VOICEI.mp3 b/core/src/main/resources/VOICEI.mp3 similarity index 100% rename from src/main/resources/VOICEI.mp3 rename to core/src/main/resources/VOICEI.mp3 diff --git a/src/main/resources/VOICEJ.mp3 b/core/src/main/resources/VOICEJ.mp3 similarity index 100% rename from src/main/resources/VOICEJ.mp3 rename to core/src/main/resources/VOICEJ.mp3 diff --git a/src/main/resources/VOICEK.mp3 b/core/src/main/resources/VOICEK.mp3 similarity index 100% rename from src/main/resources/VOICEK.mp3 rename to core/src/main/resources/VOICEK.mp3 diff --git a/src/main/resources/VOICEL.mp3 b/core/src/main/resources/VOICEL.mp3 similarity index 100% rename from src/main/resources/VOICEL.mp3 rename to core/src/main/resources/VOICEL.mp3 diff --git a/src/main/resources/VOICEM.mp3 b/core/src/main/resources/VOICEM.mp3 similarity index 100% rename from src/main/resources/VOICEM.mp3 rename to core/src/main/resources/VOICEM.mp3 diff --git a/src/main/resources/VOICEMEEPMEEP.mp3 b/core/src/main/resources/VOICEMEEPMEEP.mp3 similarity index 100% rename from src/main/resources/VOICEMEEPMEEP.mp3 rename to core/src/main/resources/VOICEMEEPMEEP.mp3 diff --git a/src/main/resources/VOICEN.mp3 b/core/src/main/resources/VOICEN.mp3 similarity index 100% rename from src/main/resources/VOICEN.mp3 rename to core/src/main/resources/VOICEN.mp3 diff --git a/src/main/resources/VOICEO.mp3 b/core/src/main/resources/VOICEO.mp3 similarity index 100% rename from src/main/resources/VOICEO.mp3 rename to core/src/main/resources/VOICEO.mp3 diff --git a/src/main/resources/VOICEP.mp3 b/core/src/main/resources/VOICEP.mp3 similarity index 100% rename from src/main/resources/VOICEP.mp3 rename to core/src/main/resources/VOICEP.mp3 diff --git a/src/main/resources/VOICEQ.mp3 b/core/src/main/resources/VOICEQ.mp3 similarity index 100% rename from src/main/resources/VOICEQ.mp3 rename to core/src/main/resources/VOICEQ.mp3 diff --git a/src/main/resources/VOICER.mp3 b/core/src/main/resources/VOICER.mp3 similarity index 100% rename from src/main/resources/VOICER.mp3 rename to core/src/main/resources/VOICER.mp3 diff --git a/src/main/resources/VOICES.mp3 b/core/src/main/resources/VOICES.mp3 similarity index 100% rename from src/main/resources/VOICES.mp3 rename to core/src/main/resources/VOICES.mp3 diff --git a/src/main/resources/VOICESTROKE.mp3 b/core/src/main/resources/VOICESTROKE.mp3 similarity index 100% rename from src/main/resources/VOICESTROKE.mp3 rename to core/src/main/resources/VOICESTROKE.mp3 diff --git a/src/main/resources/VOICESTROKEPORTABLE.mp3 b/core/src/main/resources/VOICESTROKEPORTABLE.mp3 similarity index 100% rename from src/main/resources/VOICESTROKEPORTABLE.mp3 rename to core/src/main/resources/VOICESTROKEPORTABLE.mp3 diff --git a/src/main/resources/VOICET.mp3 b/core/src/main/resources/VOICET.mp3 similarity index 100% rename from src/main/resources/VOICET.mp3 rename to core/src/main/resources/VOICET.mp3 diff --git a/src/main/resources/VOICEU.mp3 b/core/src/main/resources/VOICEU.mp3 similarity index 100% rename from src/main/resources/VOICEU.mp3 rename to core/src/main/resources/VOICEU.mp3 diff --git a/src/main/resources/VOICEV.mp3 b/core/src/main/resources/VOICEV.mp3 similarity index 100% rename from src/main/resources/VOICEV.mp3 rename to core/src/main/resources/VOICEV.mp3 diff --git a/src/main/resources/VOICEW.mp3 b/core/src/main/resources/VOICEW.mp3 similarity index 100% rename from src/main/resources/VOICEW.mp3 rename to core/src/main/resources/VOICEW.mp3 diff --git a/src/main/resources/VOICEX.mp3 b/core/src/main/resources/VOICEX.mp3 similarity index 100% rename from src/main/resources/VOICEX.mp3 rename to core/src/main/resources/VOICEX.mp3 diff --git a/src/main/resources/VOICEY.mp3 b/core/src/main/resources/VOICEY.mp3 similarity index 100% rename from src/main/resources/VOICEY.mp3 rename to core/src/main/resources/VOICEY.mp3 diff --git a/src/main/resources/VOICEYOUGOTMAIL.mp3 b/core/src/main/resources/VOICEYOUGOTMAIL.mp3 similarity index 100% rename from src/main/resources/VOICEYOUGOTMAIL.mp3 rename to core/src/main/resources/VOICEYOUGOTMAIL.mp3 diff --git a/src/main/resources/VOICEZ.mp3 b/core/src/main/resources/VOICEZ.mp3 similarity index 100% rename from src/main/resources/VOICEZ.mp3 rename to core/src/main/resources/VOICEZ.mp3 diff --git a/src/main/resources/icons/kst4contest.icns b/core/src/main/resources/icons/kst4contest.icns similarity index 100% rename from src/main/resources/icons/kst4contest.icns rename to core/src/main/resources/icons/kst4contest.icns diff --git a/src/main/resources/icons/kst4contest.ico b/core/src/main/resources/icons/kst4contest.ico similarity index 100% rename from src/main/resources/icons/kst4contest.ico rename to core/src/main/resources/icons/kst4contest.ico diff --git a/src/main/resources/icons/kst4contest.png b/core/src/main/resources/icons/kst4contest.png similarity index 100% rename from src/main/resources/icons/kst4contest.png rename to core/src/main/resources/icons/kst4contest.png diff --git a/src/main/resources/praktiKST.db b/core/src/main/resources/praktiKST.db similarity index 100% rename from src/main/resources/praktiKST.db rename to core/src/main/resources/praktiKST.db diff --git a/src/main/resources/praktiKSTpreferences.old b/core/src/main/resources/praktiKSTpreferences.old similarity index 100% rename from src/main/resources/praktiKSTpreferences.old rename to core/src/main/resources/praktiKSTpreferences.old diff --git a/src/main/resources/praktiKSTpreferences.xml b/core/src/main/resources/praktiKSTpreferences.xml similarity index 100% rename from src/main/resources/praktiKSTpreferences.xml rename to core/src/main/resources/praktiKSTpreferences.xml diff --git a/src/main/resources/sk.mp3 b/core/src/main/resources/sk.mp3 similarity index 100% rename from src/main/resources/sk.mp3 rename to core/src/main/resources/sk.mp3 diff --git a/src/main/resources/tick.mp3 b/core/src/main/resources/tick.mp3 similarity index 100% rename from src/main/resources/tick.mp3 rename to core/src/main/resources/tick.mp3 diff --git a/src/test/java/kst4contest/controller/ChatControllerInitialWorkedStateTest.java b/core/src/test/java/kst4contest/controller/ChatControllerInitialWorkedStateTest.java similarity index 100% rename from src/test/java/kst4contest/controller/ChatControllerInitialWorkedStateTest.java rename to core/src/test/java/kst4contest/controller/ChatControllerInitialWorkedStateTest.java diff --git a/src/test/java/kst4contest/controller/ChatControllerSimpleLogTest.java b/core/src/test/java/kst4contest/controller/ChatControllerSimpleLogTest.java similarity index 100% rename from src/test/java/kst4contest/controller/ChatControllerSimpleLogTest.java rename to core/src/test/java/kst4contest/controller/ChatControllerSimpleLogTest.java diff --git a/core/src/test/java/kst4contest/controller/ChatMemberListViewTest.java b/core/src/test/java/kst4contest/controller/ChatMemberListViewTest.java new file mode 100644 index 00000000..05b28f03 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/ChatMemberListViewTest.java @@ -0,0 +1,164 @@ +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. This is deliberately not what " + + "SortedList did: it inserted single elements by binary search and left " + + "them wherever the search ended, so the position within a tie was " + + "undefined and drifted as stations came and went."); + } + + @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"); + } + + @Test + void aSecondOrderIsDerivedFromTheCanonicalListNotFromThePreviousResult() { + /* + * SortedList always ordered the unsorted source. Sorting an already sorted + * result again would make equally ranked stations keep the previous + * column's order instead of their arrival order, which is what the + * operator sees as rows jumping when switching the sort column. + */ + List canonical = List.of( + member("CCC"), member("AAA"), member("BBB"), member("DDD")); + + Comparator byLastCharacter = + Comparator.comparing(m -> m.getCallSign().substring(2)); + Comparator allEqual = (a, b) -> 0; + + List afterFirstOrder = ChatMemberListView.compute( + canonical, List.of(), byLastCharacter); + assertEquals(List.of("AAA", "BBB", "CCC", "DDD"), calls(afterFirstOrder)); + + List afterSecondOrder = ChatMemberListView.compute( + canonical, List.of(), allEqual); + + assertEquals(List.of("CCC", "AAA", "BBB", "DDD"), calls(afterSecondOrder), + "a new order must be derived from the canonical list, " + + "never from the previously shown order"); + assertEquals(calls(ChatMemberListView.compute(canonical, List.of(), allEqual)), + calls(afterSecondOrder), + "computing the visible list must not depend on what was computed before"); + } + + @Test + void countMatchingAgreesWithComputeWithoutOrdering() { + List input = List.of(member("DN9APW"), member("DO5AMF"), member("DL1ABC")); + Predicate endsWithF = m -> m.getCallSign().endsWith("F"); + + assertEquals( + ChatMemberListView.compute(input, List.of(endsWithF), null).size(), + ChatMemberListView.countMatching(input, List.of(endsWithF)), + "the status line only wants a number; it must not pay for sorting thousands of rows"); + assertEquals(3, ChatMemberListView.countMatching(input, List.of())); + } + + @Test + void countMatchingKeepsAMemberWhenAFilterThrows() { + List input = List.of(member("DN9APW"), member("DO5AMF")); + Predicate broken = m -> { throw new IllegalStateException("boom"); }; + + assertEquals(2, ChatMemberListView.countMatching(input, List.of(broken))); + } + + @Test + void aNullMemberDoesNotBreakTheComputation() { + List input = java.util.Arrays.asList(member("DN9APW"), null); + + List result = ChatMemberListView.compute(input, List.of(), null); + + assertEquals(2, result.size(), + "the JavaFX list this replaces carried nulls; throwing here would freeze the station list"); + } +} diff --git a/src/test/java/kst4contest/controller/DBControllerProfileDatabaseTest.java b/core/src/test/java/kst4contest/controller/DBControllerProfileDatabaseTest.java similarity index 100% rename from src/test/java/kst4contest/controller/DBControllerProfileDatabaseTest.java rename to core/src/test/java/kst4contest/controller/DBControllerProfileDatabaseTest.java diff --git a/src/test/java/kst4contest/controller/DXClusterSpotFormatterTest.java b/core/src/test/java/kst4contest/controller/DXClusterSpotFormatterTest.java similarity index 100% rename from src/test/java/kst4contest/controller/DXClusterSpotFormatterTest.java rename to core/src/test/java/kst4contest/controller/DXClusterSpotFormatterTest.java diff --git a/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java b/core/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java similarity index 100% rename from src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java rename to core/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java diff --git a/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java b/core/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java similarity index 100% rename from src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java rename to core/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java diff --git a/src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterCommentTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterCommentTest.java similarity index 94% rename from src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterCommentTest.java rename to core/src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterCommentTest.java index b3e579ff..aa9e3f8d 100644 --- a/src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterCommentTest.java +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterCommentTest.java @@ -1,8 +1,8 @@ package kst4contest.controller; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; -import javafx.collections.FXCollections; import kst4contest.model.AirPlane; import kst4contest.model.AirPlaneReflectionInfo; import kst4contest.model.ChatMember; @@ -25,7 +25,7 @@ class MessageBusManagementThreadDxClusterCommentTest { AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo(); reflectionInfo.setRisingAirplanes( - FXCollections.observableArrayList( + List.of( firstAircraft, secondAircraft ) diff --git a/src/test/java/kst4contest/controller/MessageBusManagementThreadFrequencyTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadFrequencyTest.java similarity index 100% rename from src/test/java/kst4contest/controller/MessageBusManagementThreadFrequencyTest.java rename to core/src/test/java/kst4contest/controller/MessageBusManagementThreadFrequencyTest.java diff --git a/src/test/java/kst4contest/controller/MessageBusRawRxLoggingTest.java b/core/src/test/java/kst4contest/controller/MessageBusRawRxLoggingTest.java similarity index 100% rename from src/test/java/kst4contest/controller/MessageBusRawRxLoggingTest.java rename to core/src/test/java/kst4contest/controller/MessageBusRawRxLoggingTest.java diff --git a/src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java b/core/src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java similarity index 100% rename from src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java rename to core/src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java diff --git a/src/test/java/kst4contest/controller/On4KstConnectionProbeTest.java b/core/src/test/java/kst4contest/controller/On4KstConnectionProbeTest.java similarity index 100% rename from src/test/java/kst4contest/controller/On4KstConnectionProbeTest.java rename to core/src/test/java/kst4contest/controller/On4KstConnectionProbeTest.java diff --git a/src/test/java/kst4contest/controller/On4KstHistoryIntegrationTest.java b/core/src/test/java/kst4contest/controller/On4KstHistoryIntegrationTest.java similarity index 100% rename from src/test/java/kst4contest/controller/On4KstHistoryIntegrationTest.java rename to core/src/test/java/kst4contest/controller/On4KstHistoryIntegrationTest.java diff --git a/src/test/java/kst4contest/controller/On4KstIdlePracticeTest.java b/core/src/test/java/kst4contest/controller/On4KstIdlePracticeTest.java similarity index 100% rename from src/test/java/kst4contest/controller/On4KstIdlePracticeTest.java rename to core/src/test/java/kst4contest/controller/On4KstIdlePracticeTest.java diff --git a/src/test/java/kst4contest/controller/On4KstProbeVariantPracticeTest.java b/core/src/test/java/kst4contest/controller/On4KstProbeVariantPracticeTest.java similarity index 100% rename from src/test/java/kst4contest/controller/On4KstProbeVariantPracticeTest.java rename to core/src/test/java/kst4contest/controller/On4KstProbeVariantPracticeTest.java diff --git a/src/test/java/kst4contest/controller/OperatorProfileManagementServiceTest.java b/core/src/test/java/kst4contest/controller/OperatorProfileManagementServiceTest.java similarity index 100% rename from src/test/java/kst4contest/controller/OperatorProfileManagementServiceTest.java rename to core/src/test/java/kst4contest/controller/OperatorProfileManagementServiceTest.java diff --git a/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java b/core/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java similarity index 100% rename from src/test/java/kst4contest/controller/OperatorProfilePathsTest.java rename to core/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java diff --git a/src/test/java/kst4contest/controller/OperatorProfileStoreTest.java b/core/src/test/java/kst4contest/controller/OperatorProfileStoreTest.java similarity index 100% rename from src/test/java/kst4contest/controller/OperatorProfileStoreTest.java rename to core/src/test/java/kst4contest/controller/OperatorProfileStoreTest.java diff --git a/src/test/java/kst4contest/controller/ScoreServiceRefreshTest.java b/core/src/test/java/kst4contest/controller/ScoreServiceRefreshTest.java similarity index 100% rename from src/test/java/kst4contest/controller/ScoreServiceRefreshTest.java rename to core/src/test/java/kst4contest/controller/ScoreServiceRefreshTest.java diff --git a/src/test/java/kst4contest/controller/UserActualizationTaskTest.java b/core/src/test/java/kst4contest/controller/UserActualizationTaskTest.java similarity index 100% rename from src/test/java/kst4contest/controller/UserActualizationTaskTest.java rename to core/src/test/java/kst4contest/controller/UserActualizationTaskTest.java diff --git a/src/test/java/kst4contest/controller/WinTestIhaveInventoryTest.java b/core/src/test/java/kst4contest/controller/WinTestIhaveInventoryTest.java similarity index 100% rename from src/test/java/kst4contest/controller/WinTestIhaveInventoryTest.java rename to core/src/test/java/kst4contest/controller/WinTestIhaveInventoryTest.java diff --git a/src/test/java/kst4contest/controller/WinTestLogSyncServiceTest.java b/core/src/test/java/kst4contest/controller/WinTestLogSyncServiceTest.java similarity index 100% rename from src/test/java/kst4contest/controller/WinTestLogSyncServiceTest.java rename to core/src/test/java/kst4contest/controller/WinTestLogSyncServiceTest.java diff --git a/src/test/java/kst4contest/controller/WinTestNetworkAddressResolverTest.java b/core/src/test/java/kst4contest/controller/WinTestNetworkAddressResolverTest.java similarity index 100% rename from src/test/java/kst4contest/controller/WinTestNetworkAddressResolverTest.java rename to core/src/test/java/kst4contest/controller/WinTestNetworkAddressResolverTest.java diff --git a/src/test/java/kst4contest/controller/WinTestPacketTest.java b/core/src/test/java/kst4contest/controller/WinTestPacketTest.java similarity index 100% rename from src/test/java/kst4contest/controller/WinTestPacketTest.java rename to core/src/test/java/kst4contest/controller/WinTestPacketTest.java diff --git a/core/src/test/java/kst4contest/model/ChatMemberFrequencyTest.java b/core/src/test/java/kst4contest/model/ChatMemberFrequencyTest.java new file mode 100644 index 00000000..cf6983c4 --- /dev/null +++ b/core/src/test/java/kst4contest/model/ChatMemberFrequencyTest.java @@ -0,0 +1,34 @@ +package kst4contest.model; + +import kst4contest.observe.SimpleValue; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +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()); + } +} diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesObservableTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesObservableTest.java new file mode 100644 index 00000000..35d448ed --- /dev/null +++ b/core/src/test/java/kst4contest/model/ChatPreferencesObservableTest.java @@ -0,0 +1,39 @@ +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()); + } +} diff --git a/core/src/test/java/kst4contest/observe/DirectUiDispatcherTest.java b/core/src/test/java/kst4contest/observe/DirectUiDispatcherTest.java new file mode 100644 index 00000000..0d1aa33b --- /dev/null +++ b/core/src/test/java/kst4contest/observe/DirectUiDispatcherTest.java @@ -0,0 +1,32 @@ +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"); + } +} diff --git a/core/src/test/java/kst4contest/observe/SimpleRosterTest.java b/core/src/test/java/kst4contest/observe/SimpleRosterTest.java new file mode 100644 index 00000000..4c306db4 --- /dev/null +++ b/core/src/test/java/kst4contest/observe/SimpleRosterTest.java @@ -0,0 +1,168 @@ +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +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()); + } + + @Test + void repeatedSnapshotsShareOneCopyUntilTheRosterChanges() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add("DN9APW"); + + List first = roster.snapshot(); + List second = roster.snapshot(); + assertSame(first, second, + "four message tabs read the snapshot per batch; copying it each time is wasted work"); + + roster.add("DO5AMF"); + assertNotSame(first, roster.snapshot(), "a change must produce a fresh snapshot"); + } + + @Test + void nullEntriesSurviveASnapshot() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add(null); + roster.add("DN9APW"); + + List snapshot = roster.snapshot(); + assertEquals(2, snapshot.size()); + assertNull(snapshot.get(0), + "the JavaFX list this replaces carried nulls; throwing here would freeze the mirror silently"); + } + + @Test + void isEmptyReportsTheContent() { + SimpleRoster roster = new SimpleRoster<>(); + assertTrue(roster.isEmpty()); + roster.add("DN9APW"); + assertFalse(roster.isEmpty()); + } +} diff --git a/core/src/test/java/kst4contest/observe/SimpleValueTest.java b/core/src/test/java/kst4contest/observe/SimpleValueTest.java new file mode 100644 index 00000000..93d37c16 --- /dev/null +++ b/core/src/test/java/kst4contest/observe/SimpleValueTest.java @@ -0,0 +1,86 @@ +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"); + } +} diff --git a/src/test/java/kst4contest/test/BandOpportunityResolverTest.java b/core/src/test/java/kst4contest/test/BandOpportunityResolverTest.java similarity index 100% rename from src/test/java/kst4contest/test/BandOpportunityResolverTest.java rename to core/src/test/java/kst4contest/test/BandOpportunityResolverTest.java diff --git a/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java similarity index 100% rename from src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java rename to core/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java diff --git a/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java similarity index 100% rename from src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java rename to core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java diff --git a/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java similarity index 100% rename from src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java rename to core/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java diff --git a/src/test/java/kst4contest/test/FrequencyTextParserRegressionTest.java b/core/src/test/java/kst4contest/test/FrequencyTextParserRegressionTest.java similarity index 100% rename from src/test/java/kst4contest/test/FrequencyTextParserRegressionTest.java rename to core/src/test/java/kst4contest/test/FrequencyTextParserRegressionTest.java diff --git a/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java b/core/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java similarity index 100% rename from src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java rename to core/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java diff --git a/src/test/java/kst4contest/test/PlatformUtilsTest.java b/core/src/test/java/kst4contest/test/PlatformUtilsTest.java similarity index 100% rename from src/test/java/kst4contest/test/PlatformUtilsTest.java rename to core/src/test/java/kst4contest/test/PlatformUtilsTest.java diff --git a/src/test/java/kst4contest/test/PropagationFrequencyResolverTest.java b/core/src/test/java/kst4contest/test/PropagationFrequencyResolverTest.java similarity index 100% rename from src/test/java/kst4contest/test/PropagationFrequencyResolverTest.java rename to core/src/test/java/kst4contest/test/PropagationFrequencyResolverTest.java diff --git a/src/test/java/kst4contest/test/TestApplicationFileUtils.java b/core/src/test/java/kst4contest/test/TestApplicationFileUtils.java similarity index 100% rename from src/test/java/kst4contest/test/TestApplicationFileUtils.java rename to core/src/test/java/kst4contest/test/TestApplicationFileUtils.java diff --git a/src/test/java/kst4contest/test/TestChatPreferences.java b/core/src/test/java/kst4contest/test/TestChatPreferences.java similarity index 100% rename from src/test/java/kst4contest/test/TestChatPreferences.java rename to core/src/test/java/kst4contest/test/TestChatPreferences.java diff --git a/src/test/java/kst4contest/test/TestDBController.java b/core/src/test/java/kst4contest/test/TestDBController.java similarity index 100% rename from src/test/java/kst4contest/test/TestDBController.java rename to core/src/test/java/kst4contest/test/TestDBController.java diff --git a/src/test/java/kst4contest/test/TestLocatorUtils.java b/core/src/test/java/kst4contest/test/TestLocatorUtils.java similarity index 100% rename from src/test/java/kst4contest/test/TestLocatorUtils.java rename to core/src/test/java/kst4contest/test/TestLocatorUtils.java diff --git a/src/test/java/kst4contest/test/TestReadUDPASListenerThread.java b/core/src/test/java/kst4contest/test/TestReadUDPASListenerThread.java similarity index 100% rename from src/test/java/kst4contest/test/TestReadUDPASListenerThread.java rename to core/src/test/java/kst4contest/test/TestReadUDPASListenerThread.java diff --git a/src/test/java/kst4contest/test/TestReadUDPUCXListenerThread.java b/core/src/test/java/kst4contest/test/TestReadUDPUCXListenerThread.java similarity index 100% rename from src/test/java/kst4contest/test/TestReadUDPUCXListenerThread.java rename to core/src/test/java/kst4contest/test/TestReadUDPUCXListenerThread.java diff --git a/src/test/java/kst4contest/test/TestUCXLogFileToHashsetParser.java b/core/src/test/java/kst4contest/test/TestUCXLogFileToHashsetParser.java similarity index 100% rename from src/test/java/kst4contest/test/TestUCXLogFileToHashsetParser.java rename to core/src/test/java/kst4contest/test/TestUCXLogFileToHashsetParser.java diff --git a/src/test/java/kst4contest/test/UDPAirScoutSendMessages.java b/core/src/test/java/kst4contest/test/UDPAirScoutSendMessages.java similarity index 100% rename from src/test/java/kst4contest/test/UDPAirScoutSendMessages.java rename to core/src/test/java/kst4contest/test/UDPAirScoutSendMessages.java diff --git a/src/test/java/kst4contest/test/testTimeGeneration.java b/core/src/test/java/kst4contest/test/testTimeGeneration.java similarity index 100% rename from src/test/java/kst4contest/test/testTimeGeneration.java rename to core/src/test/java/kst4contest/test/testTimeGeneration.java diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index cda8989f..bbc1c647 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -10,12 +10,12 @@ KST4Contest is a Java/JavaFX desktop client for ON4KST chat focused on VHF/UHF/m ## Current Architecture -- Java 21 / JavaFX desktop application built with Maven. -- Main code is under `src/main/java/kst4contest/`. +- Java 21 desktop application built with Gradle in the modules `core` (domain, free of user-interface technology) and `app-desktop` (the JavaFX user interface). +- Domain, controller and network code is under `core/src/main/java/kst4contest/`, the user interface under `app-desktop/src/main/java/kst4contest/`. Package names are shared across both modules. - Responsibilities are separated across controller, service, logic, model, utility and view areas. - Network/parser/service/controller/UI boundaries should remain explicit. - Long-running network/message processing must tolerate malformed or incomplete external input without terminating processing threads. -- JavaFX `ObservableList` state is a UI projection, not the canonical worker-thread domain store. +- `ObservableRoster` state in `core` is the canonical store; the `ObservableList` a `TableView` is bound to is a mirror of it, not the store. ## Important Invariants @@ -36,19 +36,26 @@ KST4Contest is a Java/JavaFX desktop client for ON4KST chat focused on VHF/UHF/m - Features that depend on frequency should use the current/actual QRG according to current implemented rules; do not silently revert to a fixed 144 MHz default. - Complete digit-only frequencies use their final three digits as the kHz part and are accepted only when the resulting MHz value lies within a supported `Band` range. The same full-frequency parser is used for station names and public or directed chat messages. Relative QRG rules and bare three-digit context handling remain separate. -### JavaFX/threading +### Threading Conceptually: ```text -thread-safe canonical domain state +thread-safe canonical domain state in core + | kst4contest.observe: ObservableRoster / ObservableValue | - | projection on JavaFX Application Thread + | hand-off via UiDispatcher v -JavaFX ObservableList / UI state +mirror on the user-interface thread (FxRosterBinding -> ObservableList) ``` -`MessageBusManagementThread` must not directly iterate or mutate UI-bound JavaFX collections. UI-visible changes should cross the controller/UI boundary and run on the JavaFX Application Thread. +`core` contains no user-interface technology. It observes through `kst4contest.observe` and hands UI-visible work over through `UiDispatcher`, whose JavaFX implementation is `JavaFxUiDispatcher` in `app-desktop`. + +`MessageBusManagementThread` must not directly iterate or mutate a UI-bound collection; it works on snapshots. UI-visible changes cross the controller/UI boundary through the dispatcher. + +A roster hands out immutable snapshots and has no incremental change protocol: listeners receive the whole new content. `FxRosterBinding` mirrors a roster into the `ObservableList` a `TableView` holds, and must be disposed when its owner goes away — `shutdownRuntime()` releases all of them, which is what keeps a discarded runtime from surviving an operator profile switch. + +A batch of changes belongs in one `SimpleRoster.mutate(...)`, not one call per element: with several thousand ON4KST users the difference is not cosmetic. ## Configuration and Layout Persistence @@ -119,7 +126,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen - Automatic QRG updates require both an enabled source and valid incoming `RadioInfo` or Win-Test `STATUS` data. Merely enabling a source does not provide or validate a current QRG. - UCXLog-compatible QSO packets and Win-Test `ADDQSO` packets are converted into one validated external-QSO state. Logger-specific numeric, metre and centimetre values and Win-Test band IDs are normalised once; the resolved band is then the sole source for per-band Worked and worked-grid state. - A missing or unknown logger band sets only the global Worked state. Worked-grid state requires both a recognised project band and a valid locator; no band or locator is inferred. Packets without a usable callsign are discarded without terminating the listener. -- External logger threads do not read or mutate the JavaFX user-list projection. `ChatController` applies global and per-band Worked state to every active variant of the base callsign on the JavaFX Application Thread before evaluating a band-upgrade notice. +- External logger threads do not read or mutate the user-list mirror. `ChatController` applies global and per-band Worked state to every active variant of the base callsign on the JavaFX Application Thread before evaluating a band-upgrade notice. - The established Win-Test handling for 24, 47 and 76 GHz remains unchanged. Their Worked flags are retained, while only frequencies represented by the project `Band` model can create worked-grid state. ### Win-Test log recovery @@ -189,7 +196,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen ## Build / Verification -- Use the repository Maven wrapper (`.\mvnw.cmd` on Windows). +- Use the repository Gradle wrapper (`.\gradlew.bat` on Windows). - The project uses Java 21 / JavaFX 21.x at this context snapshot. - JUnit 5/Mockito, PMD and SpotBugs are part of the verification environment. - Build/test configuration has historically allowed some test/static-analysis failures not to fail the process exit code. Always read actual summaries/reports. diff --git a/docs/superpowers/plans/2026-09-25-etappe1-gradle-build.md b/docs/superpowers/plans/2026-09-25-etappe1-gradle-build.md new file mode 100644 index 00000000..96083dd5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-etappe1-gradle-build.md @@ -0,0 +1,991 @@ +# 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 new file mode 100644 index 00000000..4a85e49f --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-etappe2-core-ohne-javafx.md @@ -0,0 +1,2028 @@ +# 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/specs/2026-09-25-compose-migration-design.md b/docs/superpowers/specs/2026-09-25-compose-migration-design.md new file mode 100644 index 00000000..b33a947e --- /dev/null +++ b/docs/superpowers/specs/2026-09-25-compose-migration-design.md @@ -0,0 +1,288 @@ +# Konzept: Ablösung von JavaFX durch Compose Multiplatform + +> Status: Konzept, noch nicht umgesetzt. Zielrelease **1.50**. Alle Zeilenanker beziehen sich auf `nextMajorRelease/version1_50` und wurden gegen den Branch verifiziert (Stand 2026-09-25). + +## Ziel + +KST4Contest verlässt JavaFX und wird auf Compose Multiplatform umgestellt. Ergebnis ist eine wartbare, optisch aufgefrischte Desktop-Oberfläche bei **unverändertem Funktionsumfang**. Der fachliche Kern wird dabei von jeder UI-Technologie gelöst. + +Drei Treiber, gleichrangig: + +1. Die Oberfläche soll modern aussehen und sich modern anfühlen. +2. Der UI-Code soll wieder änderbar sein. +3. Die JavaFX-Abhängigkeit soll verschwinden. + +## Nicht-Ziele + +- **Kein UX-Neuentwurf.** 1.50 ist ein funktionsgleicher Port. Bedienabläufe, Fensteraufteilung und Funktionsumfang bleiben erhalten. Ein echter Neuentwurf der Bedienung ist Thema für 2.0. +- **Keine Mobile-App.** Die Modulgrenze wird so gezogen, dass ein späteres Mobile-Repo den Kern abgreifen könnte. Mehr nicht. Mobile ist in diesem Konzept Randbedingung, kein Lieferergebnis. +- **Keine Kotlin-Konvertierung des Kerns.** Siehe „Bestätigte Festlegungen". +- **Keine Protokolländerungen.** Kein Framing, kein Port, kein Transport, keine Normalisierung wird angefasst. + +## Ausgangslage + +Gemessen am Branchstand: + +| Kennzahl | Wert | +|---|---| +| Java gesamt | 61.572 Zeilen, 196 Dateien | +| Kotlin | 0 Dateien | +| Dateien mit direktem JavaFX-Bezug | 33 | +| `Kst4ContestApplication.java` | 13.564 Zeilen, 387 Methoden | +| davon reines JavaFX-Gerüst | 82 `call()`-Cell-Factories, 64 `changed()`-Listener, 77 `handle()`-Eventhandler | +| `Platform.runLater` / `ObservableList` | 51 / 93 Vorkommen | +| Tests | 43 Dateien, 148 Testmethoden | + +### Die JavaFX-Kopplung ist flacher als die Zahlen vermuten lassen + +- `ChatMember`: **ein** gekoppeltes Feld, `StringProperty frequency` (`ChatMember.java:42`, Zugriff `:510`, `:516`, Neuanlage `:616`). Der Rest der Klasse ist reines Java. +- `ChatPreferences`: fünf Properties (`actualQTF` `:228`, `MYQRGFirstCat` `:266`, `MYQRGSecondCat` `:268`, `notify_optionalFrequencyPrefix` `:296`, `notify_DXCSrv_SpottersCallSign` `:298`) und vier `ObservableList` für Listeneinstellungen (`:307`, `:308`, `:324`, `:325`). +- `ScoreService`: bereits sauber getrennt. Es existiert ein Snapshot-Modell, `topCandidatesFx` (`:45`, `:62`) ist explizit als FX-Projektion benannt, und der Klassenkommentar hält „No per-member Platform.runLater flooding" fest. +- `MessageBusManagementThread`: eine einzige Methodensignatur nimmt `ObservableList` entgegen (`:577`). +- `ChatController` ist der Brocken: sieben `ObservableList`-Felder (`:1164`, `:1742`, `:1759`, `:1768` mit der synchronisierten Hülle `:1775`, `:1776`, `:1777`, `:1817`) und eine `FilteredList`/`SortedList`-Pipeline (`:1745`, `:1749`, `:1751`, `:1756`, `:1772`, `:1773`). + +Die Klasse dokumentiert ihr eigenes Muster bereits: der Kommentar ab `ChatController.java:1783` begründet, warum eine threadsichere Map die primäre Quelle ist und die `ObservableList` nur die gebündelte Projektion. Die hier vorgeschlagene Trennung setzt das fort, statt es zu erfinden. + +### Die Karte hängt nur flach an Leaflet + +`MapHtmlResources.java` nutzt aus der Leaflet-API lediglich `L.map`, `L.tileLayer`, `L.marker`, `L.divIcon`, `L.circleMarker`, `L.polyline`, `L.polygon`, `L.rectangle`, `L.layerGroup`, `L.latLng` und `L.latLngBounds`. Geometrie (`PathGeometryUtils`), Gitterplanung (`MaidenheadGridRenderPlanner`), Kachelbeschaffung (`TileProxyServer`), Terrain und Pfadanalyse liegen bereits vollständig in Java. + +### Weitere betroffene Stellen + +- `javafx.scene.media` wird ausschließlich in `Kst4ContestApplication.java` benutzt (`:33`, `:34`, `:6599`, `:6900`). MP3-Wiedergabe läuft ohnehin schon über `jlayer` in `PlayAudioUtils`. +- Lombok ist in `pom.xml` als Abhängigkeit deklariert, wird aber in keiner Quelldatei verwendet. +- `src/main/java/module-info.java` bindet das Projekt an JPMS; `pom.xml` enthält eine Enforcer-Regel, die die jpackage-Modulliste gegen Drift absichert. + +## Bestätigte Festlegungen + +| Punkt | Entscheidung | +|---|---| +| Zielrelease | 1.50. Die Compose-Migration findet auf `nextMajorRelease/version1_50` statt; 1.50 wird das Compose-Release | +| Funktionsumfang | Funktionsgleicher Port. UX-Neuentwurf frühestens 2.0 | +| Sprache Kern | `core` bleibt **Java 21**. Kotlin nur dort, wo Compose es erzwingt | +| Sprache UI | Kotlin, `app-desktop` | +| Build | Maven wird durch **Gradle** ersetzt. Compose Multiplatform wird offiziell nur mit Gradle unterstützt, Desktop-Packaging inbegriffen | +| Modulsystem | `module-info.java` entfällt. Compose Multiplatform liefert keine echten JPMS-Module | +| Packaging | Etappe 1 behält den direkten `jpackage`-Aufruf, nur aus Gradle statt aus Maven. Die Umstellung auf `compose.desktop.nativeDistributions` erfolgt in Etappe 3, wenn Kotlin und Compose ohnehin einziehen. DMG-Drag-Layout, Icon-Erzeugung, Flatpak und AUR werden dabei nachgezogen | +| Karte | Native Compose-Canvas-Umsetzung. Kein eingebettetes Chromium (KCEF/JCEF) | +| God-Klasse | `Kst4ContestApplication` wird in Phase 1 **nicht** zerschnitten. Compose löscht den Widget-Code ohnehin | +| Mobile | Nur als Modulgrenze berücksichtigt, nicht umgesetzt | +| Lombok | Entfällt (ungenutzt) | +| Entwickler | Marc (DO5AMF) und Philipp (DN9APW). Kein Termin | + +## Zielarchitektur + +``` +kst4contest/ +├── core/ Java 21, KEIN JavaFX, KEIN Compose +│ ├── model/ ChatMember, ChatMessage, ChatPreferences, Band … +│ ├── controller/ ChatController, MessageBus, Score, Reachability … +│ ├── net/ ON4KST, AirScout, Win-Test, UCXLog, PSTRotator, DXCluster +│ ├── map/ Geometrie, Terrain, Kachel-Proxy, Pfadanalyse +│ ├── audio/ Wiedergabe hinter einem Interface +│ └── observe/ Beobachtungs-Abstraktion +└── app-desktop/ Kotlin + Compose Multiplatform + ├── ui/ Fenster, Panels, Tabellen, Dialoge + ├── state/ UI-Zustand, Projektionen aus core + └── map/ Compose-Canvas-Karte +``` + +`core` hängt von keiner UI-Technologie ab. Das ist die Grenze, an der parallel gearbeitet werden kann, und dieselbe Grenze, die ein späteres Mobile-Repo abgreifen würde. + +### Das Paket `core/observe` + +Bewusst minimal. Kein Reaktiv-Framework, keine Bibliothek. + +- **`ObservableValue`** — `T get()`, `void addListener(Consumer)`, `void removeListener(Consumer)`. +- **`MutableValue extends ObservableValue`** — zusätzlich `void set(T)`. +- **`ObservableRoster`** — **snapshotbasiert**: `List snapshot()` liefert eine unveränderliche Liste; Listener erhalten die neue Liste. Es gibt **kein** inkrementelles Änderungsprotokoll. Das passt zum Neuzeichnungsmodell von Compose und ist ohne UI testbar. +- **`UiDispatcher`** — `void runOnUi(Runnable)` und `boolean isUiThread()`. + +`UiDispatcher` ist der Schlüssel der gesamten Umstellung. In Phase 1 wird er von `Platform::runLater` und `Platform::isFxApplicationThread` implementiert, in Phase 2 vom Compose-Main-Dispatcher. Die Invariante aus `AGENTS.md` bleibt dabei wortgleich gültig: Worker-Threads mutieren keine UI-gebundenen Sammlungen, UI-sichtbare Änderungen laufen über den Dispatcher. + +### Was aus der JavaFX-Pipeline wird + +`FilteredList` und `SortedList` werden zu einer **reinen Funktion** in `core`: aus Mitgliederliste, Filterprädikaten und Komparator entsteht eine unveränderliche Ergebnisliste. Die Stationslisten-Logik wird damit erstmals ohne laufende UI testbar. Das ist der wesentliche fachliche Gewinn von Phase 1. + +Die vorhandene Bündelung und Entprellung (`flushPendingChatMessagesToUi`, das Coalescing der Mitgliederliste) **bleibt erhalten**. Sie ist Leistungsschutz bei mehreren tausend ON4KST-Nutzern, kein JavaFX-Beiwerk. + +## Etappen + +Jede Etappe ist für sich abgeschlossen, lieferbar und bekommt einen eigenen Umsetzungsplan. + +### Etappe 1 — Gradle, zwei Module, JPMS entfernen, Packaging + +**Ziel:** Die unveränderte JavaFX-Anwendung baut, testet und paketiert unter Gradle. + +**Umfang** + +- Gradle-Wrapper ersetzt `mvnw`/`mvnw.cmd`. +- Aufteilung in die Module `core` und `app-desktop`; in dieser Etappe enthält `app-desktop` noch die JavaFX-Oberfläche. +- **`core` darf in dieser Etappe noch von JavaFX abhängen.** `ChatController`, `ChatMember`, `ChatPreferences` und `ScoreService` benutzen es; Etappe 2 entfernt die Abhängigkeit. Etappe 1 bleibt damit eine reine Build-Umstellung ohne fachliche Änderung. +- Paketnamen und `import`-Anweisungen ändern sich nicht. Gradle-Module erzwingen keine Paketumbenennung; die Aufteilung ist ein Verschieben von Verzeichnissen. +- `src/main/java/module-info.java` wird entfernt; die Enforcer-Regel zur jpackage-Modulliste in `pom.xml` entfällt mit ihr. +- SpotBugs- und PMD-Regelsätze wandern mit; `pmd-ruleset.xml` bleibt unverändert gültig. +- Lombok wird aus den Abhängigkeiten entfernt. +- Packaging für Windows, Linux und macOS ruft `jpackage` weiterhin direkt auf, künftig aus einer Gradle-Aufgabe. Das DMG-Drag-Layout, die Icon-Erzeugung, Flatpak und AUR bleiben inhaltlich unverändert; nur die Herkunft der Eingabedateien wechselt von `target/dist-libs` auf `app-desktop/build/dist-libs`. +- Die Projektversion wandert nach `gradle.properties`. Zehn Stellen in fünf Workflows und zwei PKGBUILDs lesen sie heute aus `pom.xml` und müssen umgestellt werden, bevor `pom.xml` entfällt. +- Die GitHub-Workflows unter `.github/` werden auf Gradle umgestellt. + +**Abnahmekriterium:** Alle 148 vorhandenen Tests laufen unter Gradle. Auf allen drei Plattformen entsteht ein installierbares Paket, das sich wie das bisherige verhält. Stable-, Beta- und Nightly-Strecke funktionieren unverändert. + +### Etappe 2 — `core` von JavaFX befreien + +**Ziel:** `core` compiliert ohne JavaFX auf dem Klassenpfad. Die JavaFX-Oberfläche läuft unverändert und beweist, dass die Schnittstelle trägt. + +**Umfang** + +- `core/observe` entsteht wie oben beschrieben. +- `ChatMember.frequency` wird von `StringProperty` auf `MutableValue` umgestellt (`ChatMember.java:42`, `:510`, `:516`, `:616`). +- Die fünf Properties und vier `ObservableList` in `ChatPreferences` werden auf `MutableValue` beziehungsweise `ObservableRoster` umgestellt. +- Die sieben `ObservableList`-Felder in `ChatController` werden zu `ObservableRoster`; die `FilteredList`/`SortedList`-Pipeline wird durch die reine Filter-/Sortierfunktion ersetzt. +- `ScoreService` gibt seinen Snapshot über `ObservableRoster` heraus statt über `topCandidatesFx`; `Platform.runLater` wird durch `UiDispatcher` ersetzt. +- `MessageBusManagementThread.checkListForChatMemberIndexByCallSign` (`:577`) nimmt eine `List` statt `ObservableList`. +- Audio wandert nach `core/audio` hinter ein Interface. `javafx.scene.media` (`Kst4ContestApplication.java:33`, `:34`, `:6599`, `:6900`) entfällt; MP3 über `jlayer`, WAV über `javax.sound.sampled`. +- `app-desktop` stellt die JavaFX-Implementierung von `UiDispatcher` bereit und adaptiert `ObservableRoster` auf `ObservableList` für die bestehenden `TableView`. + +**Absicherung:** Jede herausgelöste Einheit bekommt **vorher** einen Test, der das heutige Verhalten festschreibt — insbesondere Filterung und Sortierung der Stationsliste, die heute in `FilteredList`/`SortedList` steckt und nicht testbar ist. + +**Abnahmekriterium:** `core` hat keine JavaFX-Abhängigkeit mehr. Die Anwendung verhält sich unverändert: Stationsliste, Chat-Tabs, Prioritäten, Skeds, Cluster, Worked-Zustand, Profilwechsel. + +### Etappe 3 — `DataTable` und Einstellungsfenster in Compose + +**Ziel:** Das erste Compose-Fenster ist im Produkt. + +**Umfang** + +Der wesentliche Baustein dieser Etappe ist eine wiederverwendbare **`DataTable`**-Komponente. Compose kennt kein `TableView`, und die Anwendung hat fünf Tabellen: `TableView`, `TableView`, `TableView`, `TableView` und die Top-Prioritätenliste. Alle brauchen dasselbe: + +- Spaltenbreiten mit stabilen Tabellen- und Spalten-IDs, wie sie `preferences.xml` Version 7 bereits kennt; +- Sortierung nach Spalte; +- Zeilenauswahl und Kontextmenü; +- Zeilen- und Zell-Styling, heute `PrivateMessageRowStyleResolver`, `MessageTextTableCell`, `TruncatedTextTableCell`, `TruncatedTextTooltipSupport`. + +Umgesetzt als `LazyColumn` mit eigener Kopfzeile. Wird diese Komponente gut, ist der Rest der Portierung Fleißarbeit; wird sie schlecht, zieht sich das durch die gesamte Anwendung. + +Mit dieser Etappe ziehen Kotlin und das Compose-Gradle-Plugin ein. Das Packaging wird dabei von den direkten `jpackage`-Aufrufen auf `compose.desktop.nativeDistributions` umgestellt — der Zeitpunkt ist hier richtig, weil JavaFX dann nicht mehr über `jlink` gebündelt werden muss. + +Danach das Einstellungsfenster mit seinen zwölf Reitern: Station, Log synch, TRX synch, Airscout, Notification, Shortcuts, Macros, Beacon, Messagehandling, Workedstn database, GUI, Profiles. Es ist in sich abgeschlossen, hat wenig Domänenkopplung und eignet sich deshalb als Lernstück. + +**Abnahmekriterium:** Das Einstellungsfenster ist vollständig in Compose, alle zwölf Reiter sind funktionsgleich, und geänderte Einstellungen landen unverändert in `preferences.xml`. + +> Nachzuschärfen nach Etappe 2: der genaue Zuschnitt der `DataTable`-Schnittstelle. Er hängt davon ab, wie sich `ObservableRoster` in der Praxis anfühlt. + +### Etappe 4 — Profil-Dialog, Update-Fenster, Monitor-Fenster + +**Ziel:** Drei weitere Fenster portiert; die Muster aus Etappe 3 sind bestätigt. + +**Umfang** + +- `OperatorProfilePickerDialog` und `OperatorProfileSettingsPane`. +- Das Update-Fenster (`Kst4ContestApplication.java:9242`). +- Das Monitor-Fenster für Cluster und QSOs (`:9180`), das die `DataTable` erstmals mit Live-Daten belastet. + +Die Layout-IDs für das Monitor-Fenster bleiben von denen des Hauptfensters getrennt, wie in `PROJECT_CONTEXT.md` festgelegt. + +**Abnahmekriterium:** Die drei Fenster sind funktionsgleich. Fenstergrößen, Positionen und Spaltenbreiten werden unverändert entprellt gespeichert und beim Herunterfahren geschrieben. + +### Etappe 5 — Hauptfenster + +**Ziel:** JavaFX wird nur noch für die Karte benötigt. + +**Umfang** + +- Stationsliste mit Filterleiste und Prioritätenspalten. +- Die drei Chat-Reiter „Public messages", „DXCluster messages", „QSO of the other". +- Der Info-Bereich zur ausgewählten Station (`generateFurtherInfoAbtSelectedCallsignBP`, `Kst4ContestApplication.java:707`) samt seinem vertikalen `SplitPane` und dessen gespeicherten Divider-Positionen. +- Die Top-Prioritätenliste (`initTopPriorityListPane`, `:4416`). +- Die Statusleiste mit ihren Indikatoren: Sked-Warnung (`:5868`), Verbindungszustand (`:5887`), Band-Upgrade (`:6045`). +- `TimelineView`. +- Menüleiste, einschließlich der macOS-Systemmenüleiste. + +**Profilwechsel:** Der heutige Mechanismus reißt die Laufzeit ab und baut eine **neue** `Kst4ContestApplication`-Instanz auf, weil viele Bedienelemente inline initialisierte Instanzfelder sind. Dieser Grund entfällt in Compose — der Zustand hängt nicht mehr an Feldern einer Riesenklasse. Der Wechsel wird zum Austausch des Zustandsobjekts. `shutdownRuntime()` bleibt trotzdem verantwortlich für alles, was eine Verbindung überdauert: ON4KST-Supervisor, Sked-Erinnerung, Reachability-Executor, PSTRotator-Wiederholung, Kachel-Proxy, Zeitgeber. `ApplicationConstants.sessionRuntimeUniqueId` wird weiterhin nicht neu erzeugt. + +**Abnahmekriterium:** Das Hauptfenster ist funktionsgleich. Der Profilwechsel im laufenden Betrieb funktioniert, ohne Listener doppelt zu registrieren und ohne Ressourcen zu halten. + +> Nachzuschärfen nach Etappe 4: ob `Platform.setImplicitExit(false)` und `ApplicationRuntimeLauncher.exitApplication()` in der Compose-Fassung noch eine Entsprechung brauchen. + +### Etappe 6 — Karte und Entfernung von JavaFX + +**Ziel:** 1.50. Keine JavaFX-Abhängigkeit mehr im Projekt. + +**Umfang** + +Die Karte wird als Compose-Canvas neu umgesetzt: + +- Web-Mercator-Projektion, Zoomstufen und Schwenken wie bisher; +- Kacheln über den bestehenden `TileProxyServer`; +- Marker, Linien, Polygone, Rechtecke und Kreismarker direkt gezeichnet, entsprechend der heutigen flachen Leaflet-Nutzung; +- Maidenhead-Gitter über den bestehenden `MaidenheadGridRenderPlanner`; +- Pfaddarstellung und Geländeprofil (`PathProfileChart`) als Compose-Zeichnung; +- bildschirmbasiertes Clustering als Vorschalt-Schritt vor dem Zeichnen. `GUIstationMapClusteringEnabled` bleibt eine Layout-Einstellung unterhalb `guiOptions` mit Vorgabewert `true`. + +`MapHtmlResources`, `StationMapBridge` und die JavaScript-Brücke entfallen ersatzlos. `StationMapView` wird zur Compose-Komponente. + +Zum Abschluss werden die JavaFX-Abhängigkeiten aus dem Build entfernt und `opens kst4contest.view.map to javafx.web` verliert seinen Gegenstand. + +**Abnahmekriterium:** Die Karte zeigt dieselben Informationen wie die Leaflet-Fassung. Offline-DEM, Terrain-Pakete und der Profil-Cache funktionieren unverändert. Im Projekt existiert keine JavaFX-Abhängigkeit mehr. + +> Nachzuschärfen nach Etappe 5: Leistungsverhalten des Canvas bei mehreren tausend Markern. Falls das Zeichnen nicht trägt, ist eine Vorabaggregation der Marker im Kern der Ausweg. + +## Invarianten — was unverändert bleiben muss + +Diese Punkte sind in `AGENTS.md` und `PROJECT_CONTEXT.md` festgelegt und werden von der Umstellung nicht berührt. + +**Chat-Identität** + +- Vollständiges sichtbares Rufzeichen plus Kategorie bildet die Identität eines Chat-Teilnehmers. +- Basisruf-Normalisierung nur für explizit basisrufweite Funktionen. +- Worked-Zustand wird über Suffixvarianten desselben Basisrufs geteilt. +- Suffixe werden nicht global als Band, Kategorie oder Frequenz gedeutet. + +**Bänder und Verfügbarkeit** + +- Kategorien 2 und 3 sind die Hauptkategorien; unerwartete Werte müssen sicher fehlschlagen. +- `NOT-QRV` hat Vorrang vor positiven Band-Hinweisen. +- Fehlende Frequenz-, QRB- oder QTF-Werte bleiben unbekannt. `null` bedeutet nicht verfügbar, nicht null. +- Kein stilles Zurückfallen auf feste 144 MHz. + +**Externe Schnittstellen** + +- CR/LF-Framing, XML-Framing, Ports und Transporte bleiben unverändert. +- Die lokale DX-Cluster-Ausgabe behält ihre feste 75-Zeichen-Zeile, das DX-Rufzeichen ab Spalte 27, den 30-Zeichen-Kommentar ab Spalte 40 und die UTC-Zeit ab Spalte 71, gefolgt von zwei BEL und CRLF. +- Unerwartete Eingaben beenden keinen Verarbeitungs- oder UI-Thread. + +**Persistenz** + +- `preferences.xml` bleibt in Version 7, einschließlich der verwalteten Spaltenbreiten unterhalb `guiOptions` und der Regel, dass Elternspalten ihre Breite aus den Blattspalten ableiten. +- Selektives Schreiben aktualisiert die Datei auf der Platte, bewahrt unbekannte XML-Knoten und schreibt keine unbestätigten funktionalen Einstellungen. **Save Settings** bleibt der vollständige Schreiber. +- Vollständige und selektive Schreibvorgänge sind synchronisiert und ersetzen `preferences.xml` atomar. +- Operator-Profile behalten ihr Verhalten: Wurzelprofil flach, weitere Profile unter `profiles//`, `profiles.xml` wird verzögert angelegt, die Registrierung speichert niemals einen Pfad, ein neues Profil bekommt eine **leere** Datenbank. +- Das SQLite-Schema und die Worked-Semantik samt Dreitagesablauf bleiben unverändert. +- Simplelogfile-Verhalten bleibt unverändert, einschließlich des minütlichen Lesens und der Tatsache, dass daraus abgeleiteter Worked-Zustand nicht in SQLite landet. + +**Quelltext-Konventionen** + +- Kommentare und Javadoc ausschließlich auf Englisch. +- Log-, Protokoll- und API-Literale in ihrer kanonischen Form. +- Kommunikation mit Marc auf Deutsch; Commit-Nachrichten knapp auf Englisch. + +## Risiken + +| Risiko | Bewertung | Umgang | +|---|---|---| +| `DataTable` wird der Engpass | hoch | Etappe 3 baut sie zuerst und belastet sie in Etappe 4 mit Live-Daten, bevor das Hauptfenster davon abhängt | +| Packaging auf drei Plattformen neu | hoch | Etappe 1 macht das zuerst, mit unveränderter JavaFX-App als Vergleichsmaßstab | +| Canvas-Leistung bei vielen Markern | mittel | Ausweg ist Vorabaggregation im Kern; wird in Etappe 6 gemessen, nicht angenommen | +| Compose-Erfahrung fehlt bei beiden Entwicklern | mittel | Das Einstellungsfenster als erstes Lernstück ist bewusst ungefährlich gewählt | +| Funktionsgleichheit ist schwer nachweisbar | mittel | Vergleich Fenster für Fenster gegen die noch laufende JavaFX-Fassung; Protokoll und Persistenz sind durch `core`-Tests gedeckt | +| 1.50 verschiebt sich erheblich | angenommen | Bewusste Entscheidung. Kein Termin gesetzt | + +## Offene Punkte + +Diese Fragen werden bewusst erst beantwortet, wenn die vorangehende Etappe Erfahrung geliefert hat. Sie blockieren Etappe 1 und 2 nicht. + +1. Genauer Zuschnitt der `DataTable`-Schnittstelle — nach Etappe 2. +2. Ob `Platform.setImplicitExit(false)` in Compose eine Entsprechung braucht — nach Etappe 4. +3. Aggregationsstrategie für Kartenmarker, falls das Canvas-Zeichnen nicht trägt — nach Etappe 5. +4. Ob das Geländeprofil als eigene Compose-Zeichnung oder über eine Diagrammbibliothek entsteht — nach Etappe 5. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..5b1b7157 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,10 @@ +version=1.50.0-nightly +group=de.x08 +# Runtime modules jlink must keep. Derived from the former module-info.java +# requires clauses, minus javafx.* (JavaFX now comes from the classpath) and +# plus jdk.unsupported, which javafx.graphics used to pull in transitively. +# Single source for the Gradle build, the workflows and the AUR PKGBUILDs. +jpackageAddModules=java.desktop,java.net.http,java.sql,jdk.crypto.ec,jdk.jsobject,jdk.net,jdk.xml.dom,jdk.unsupported + +org.gradle.parallel=true +org.gradle.caching=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..6bd221a7 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,27 @@ +[versions] +javafx = "21.0.5" +javafxPlugin = "0.1.0" +sqliteJdbc = "3.43.2.2" +jlayer = "1.0.1" +junit = "5.10.1" +mockito = "5.7.0" +mockitoCompile = "5.17.0" +jetbrainsAnnotations = "24.0.1" +spotbugsPlugin = "6.0.26" +spotbugs = "4.9.8" +pmd = "7.17.0" + +[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" } +junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } +mockito-core-compile = { module = "org.mockito:mockito-core", version.ref = "mockitoCompile" } +jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrainsAnnotations" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } +mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } + +[plugins] +javafx = { id = "org.openjfx.javafxplugin", version.ref = "javafxPlugin" } +spotbugs = { id = "com.github.spotbugs", version.ref = "spotbugsPlugin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..eddabd2e Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..ad7845be --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..379a6582 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob//platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..8508ef68 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/mvnw b/mvnw deleted file mode 100755 index 8d937f4c..00000000 --- a/mvnw +++ /dev/null @@ -1,308 +0,0 @@ -#!/bin/sh -# ---------------------------------------------------------------------------- -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# ---------------------------------------------------------------------------- - -# ---------------------------------------------------------------------------- -# Apache Maven Wrapper startup batch script, version 3.2.0 -# -# Required ENV vars: -# ------------------ -# JAVA_HOME - location of a JDK home dir -# -# Optional ENV vars -# ----------------- -# MAVEN_OPTS - parameters passed to the Java VM when running Maven -# e.g. to debug Maven itself, use -# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -# MAVEN_SKIP_RC - flag to disable loading of mavenrc files -# ---------------------------------------------------------------------------- - -if [ -z "$MAVEN_SKIP_RC" ] ; then - - if [ -f /usr/local/etc/mavenrc ] ; then - . /usr/local/etc/mavenrc - fi - - if [ -f /etc/mavenrc ] ; then - . /etc/mavenrc - fi - - if [ -f "$HOME/.mavenrc" ] ; then - . "$HOME/.mavenrc" - fi - -fi - -# OS specific support. $var _must_ be set to either true or false. -cygwin=false; -darwin=false; -mingw=false -case "$(uname)" in - CYGWIN*) cygwin=true ;; - MINGW*) mingw=true;; - Darwin*) darwin=true - # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home - # See https://developer.apple.com/library/mac/qa/qa1170/_index.html - if [ -z "$JAVA_HOME" ]; then - if [ -x "/usr/libexec/java_home" ]; then - JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME - else - JAVA_HOME="/Library/Java/Home"; export JAVA_HOME - fi - fi - ;; -esac - -if [ -z "$JAVA_HOME" ] ; then - if [ -r /etc/gentoo-release ] ; then - JAVA_HOME=$(java-config --jre-home) - fi -fi - -# For Cygwin, ensure paths are in UNIX format before anything is touched -if $cygwin ; then - [ -n "$JAVA_HOME" ] && - JAVA_HOME=$(cygpath --unix "$JAVA_HOME") - [ -n "$CLASSPATH" ] && - CLASSPATH=$(cygpath --path --unix "$CLASSPATH") -fi - -# For Mingw, ensure paths are in UNIX format before anything is touched -if $mingw ; then - [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && - JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" -fi - -if [ -z "$JAVA_HOME" ]; then - javaExecutable="$(which javac)" - if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then - # readlink(1) is not available as standard on Solaris 10. - readLink=$(which readlink) - if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then - if $darwin ; then - javaHome="$(dirname "\"$javaExecutable\"")" - javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" - else - javaExecutable="$(readlink -f "\"$javaExecutable\"")" - fi - javaHome="$(dirname "\"$javaExecutable\"")" - javaHome=$(expr "$javaHome" : '\(.*\)/bin') - JAVA_HOME="$javaHome" - export JAVA_HOME - fi - fi -fi - -if [ -z "$JAVACMD" ] ; then - if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - else - JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" - fi -fi - -if [ ! -x "$JAVACMD" ] ; then - echo "Error: JAVA_HOME is not defined correctly." >&2 - echo " We cannot execute $JAVACMD" >&2 - exit 1 -fi - -if [ -z "$JAVA_HOME" ] ; then - echo "Warning: JAVA_HOME environment variable is not set." -fi - -# traverses directory structure from process work directory to filesystem root -# first directory with .mvn subdirectory is considered project base directory -find_maven_basedir() { - if [ -z "$1" ] - then - echo "Path not specified to find_maven_basedir" - return 1 - fi - - basedir="$1" - wdir="$1" - while [ "$wdir" != '/' ] ; do - if [ -d "$wdir"/.mvn ] ; then - basedir=$wdir - break - fi - # workaround for JBEAP-8937 (on Solaris 10/Sparc) - if [ -d "${wdir}" ]; then - wdir=$(cd "$wdir/.." || exit 1; pwd) - fi - # end of workaround - done - printf '%s' "$(cd "$basedir" || exit 1; pwd)" -} - -# concatenates all lines of a file -concat_lines() { - if [ -f "$1" ]; then - # Remove \r in case we run on Windows within Git Bash - # and check out the repository with auto CRLF management - # enabled. Otherwise, we may read lines that are delimited with - # \r\n and produce $'-Xarg\r' rather than -Xarg due to word - # splitting rules. - tr -s '\r\n' ' ' < "$1" - fi -} - -log() { - if [ "$MVNW_VERBOSE" = true ]; then - printf '%s\n' "$1" - fi -} - -BASE_DIR=$(find_maven_basedir "$(dirname "$0")") -if [ -z "$BASE_DIR" ]; then - exit 1; -fi - -MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR -log "$MAVEN_PROJECTBASEDIR" - -########################################################################################## -# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central -# This allows using the maven wrapper in projects that prohibit checking in binary data. -########################################################################################## -wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" -if [ -r "$wrapperJarPath" ]; then - log "Found $wrapperJarPath" -else - log "Couldn't find $wrapperJarPath, downloading it ..." - - if [ -n "$MVNW_REPOURL" ]; then - wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" - else - wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" - fi - while IFS="=" read -r key value; do - # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) - safeValue=$(echo "$value" | tr -d '\r') - case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; - esac - done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" - log "Downloading from: $wrapperUrl" - - if $cygwin; then - wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") - fi - - if command -v wget > /dev/null; then - log "Found wget ... using wget" - [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - else - wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - fi - elif command -v curl > /dev/null; then - log "Found curl ... using curl" - [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" - else - curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" - fi - else - log "Falling back to using Java to download" - javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" - javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" - # For Cygwin, switch paths to Windows format before running javac - if $cygwin; then - javaSource=$(cygpath --path --windows "$javaSource") - javaClass=$(cygpath --path --windows "$javaClass") - fi - if [ -e "$javaSource" ]; then - if [ ! -e "$javaClass" ]; then - log " - Compiling MavenWrapperDownloader.java ..." - ("$JAVA_HOME/bin/javac" "$javaSource") - fi - if [ -e "$javaClass" ]; then - log " - Running MavenWrapperDownloader.java ..." - ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" - fi - fi - fi -fi -########################################################################################## -# End of extension -########################################################################################## - -# If specified, validate the SHA-256 sum of the Maven wrapper jar file -wrapperSha256Sum="" -while IFS="=" read -r key value; do - case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; - esac -done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" -if [ -n "$wrapperSha256Sum" ]; then - wrapperSha256Result=false - if command -v sha256sum > /dev/null; then - if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then - wrapperSha256Result=true - fi - elif command -v shasum > /dev/null; then - if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then - wrapperSha256Result=true - fi - else - echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." - echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." - exit 1 - fi - if [ $wrapperSha256Result = false ]; then - echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 - echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 - echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 - exit 1 - fi -fi - -MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" - -# For Cygwin, switch paths to Windows format before running java -if $cygwin; then - [ -n "$JAVA_HOME" ] && - JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") - [ -n "$CLASSPATH" ] && - CLASSPATH=$(cygpath --path --windows "$CLASSPATH") - [ -n "$MAVEN_PROJECTBASEDIR" ] && - MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") -fi - -# Provide a "standardized" way to retrieve the CLI args that will -# work with both Windows and non-Windows executions. -MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" -export MAVEN_CMD_LINE_ARGS - -WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -# shellcheck disable=SC2086 # safe args -exec "$JAVACMD" \ - $MAVEN_OPTS \ - $MAVEN_DEBUG_OPTS \ - -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ - "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ - ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd deleted file mode 100644 index c4586b56..00000000 --- a/mvnw.cmd +++ /dev/null @@ -1,205 +0,0 @@ -@REM ---------------------------------------------------------------------------- -@REM Licensed to the Apache Software Foundation (ASF) under one -@REM or more contributor license agreements. See the NOTICE file -@REM distributed with this work for additional information -@REM regarding copyright ownership. The ASF licenses this file -@REM to you under the Apache License, Version 2.0 (the -@REM "License"); you may not use this file except in compliance -@REM with the License. You may obtain a copy of the License at -@REM -@REM http://www.apache.org/licenses/LICENSE-2.0 -@REM -@REM Unless required by applicable law or agreed to in writing, -@REM software distributed under the License is distributed on an -@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -@REM KIND, either express or implied. See the License for the -@REM specific language governing permissions and limitations -@REM under the License. -@REM ---------------------------------------------------------------------------- - -@REM ---------------------------------------------------------------------------- -@REM Apache Maven Wrapper startup batch script, version 3.2.0 -@REM -@REM Required ENV vars: -@REM JAVA_HOME - location of a JDK home dir -@REM -@REM Optional ENV vars -@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands -@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending -@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven -@REM e.g. to debug Maven itself, use -@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files -@REM ---------------------------------------------------------------------------- - -@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' -@echo off -@REM set title of command window -title %0 -@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' -@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% - -@REM set %HOME% to equivalent of $HOME -if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") - -@REM Execute a user defined script before this one -if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre -@REM check for pre script, once with legacy .bat ending and once with .cmd ending -if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* -if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* -:skipRcPre - -@setlocal - -set ERROR_CODE=0 - -@REM To isolate internal variables from possible post scripts, we use another setlocal -@setlocal - -@REM ==== START VALIDATION ==== -if not "%JAVA_HOME%" == "" goto OkJHome - -echo. -echo Error: JAVA_HOME not found in your environment. >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -:OkJHome -if exist "%JAVA_HOME%\bin\java.exe" goto init - -echo. -echo Error: JAVA_HOME is set to an invalid directory. >&2 -echo JAVA_HOME = "%JAVA_HOME%" >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -@REM ==== END VALIDATION ==== - -:init - -@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". -@REM Fallback to current working directory if not found. - -set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% -IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir - -set EXEC_DIR=%CD% -set WDIR=%EXEC_DIR% -:findBaseDir -IF EXIST "%WDIR%"\.mvn goto baseDirFound -cd .. -IF "%WDIR%"=="%CD%" goto baseDirNotFound -set WDIR=%CD% -goto findBaseDir - -:baseDirFound -set MAVEN_PROJECTBASEDIR=%WDIR% -cd "%EXEC_DIR%" -goto endDetectBaseDir - -:baseDirNotFound -set MAVEN_PROJECTBASEDIR=%EXEC_DIR% -cd "%EXEC_DIR%" - -:endDetectBaseDir - -IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig - -@setlocal EnableExtensions EnableDelayedExpansion -for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a -@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% - -:endReadAdditionalConfig - -SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" -set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" -set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" - -FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( - IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B -) - -@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central -@REM This allows using the maven wrapper in projects that prohibit checking in binary data. -if exist %WRAPPER_JAR% ( - if "%MVNW_VERBOSE%" == "true" ( - echo Found %WRAPPER_JAR% - ) -) else ( - if not "%MVNW_REPOURL%" == "" ( - SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" - ) - if "%MVNW_VERBOSE%" == "true" ( - echo Couldn't find %WRAPPER_JAR%, downloading it ... - echo Downloading from: %WRAPPER_URL% - ) - - powershell -Command "&{"^ - "$webclient = new-object System.Net.WebClient;"^ - "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ - "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ - "}"^ - "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ - "}" - if "%MVNW_VERBOSE%" == "true" ( - echo Finished downloading %WRAPPER_JAR% - ) -) -@REM End of extension - -@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file -SET WRAPPER_SHA_256_SUM="" -FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( - IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B -) -IF NOT %WRAPPER_SHA_256_SUM%=="" ( - powershell -Command "&{"^ - "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ - "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ - " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ - " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ - " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ - " exit 1;"^ - "}"^ - "}" - if ERRORLEVEL 1 goto error -) - -@REM Provide a "standardized" way to retrieve the CLI args that will -@REM work with both Windows and non-Windows executions. -set MAVEN_CMD_LINE_ARGS=%* - -%MAVEN_JAVA_EXE% ^ - %JVM_CONFIG_MAVEN_PROPS% ^ - %MAVEN_OPTS% ^ - %MAVEN_DEBUG_OPTS% ^ - -classpath %WRAPPER_JAR% ^ - "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ - %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* -if ERRORLEVEL 1 goto error -goto end - -:error -set ERROR_CODE=1 - -:end -@endlocal & set ERROR_CODE=%ERROR_CODE% - -if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost -@REM check for post script, once with legacy .bat ending and once with .cmd ending -if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" -if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" -:skipRcPost - -@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' -if "%MAVEN_BATCH_PAUSE%"=="on" pause - -if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% - -cmd /C exit /B %ERROR_CODE% diff --git a/packaging/AddModules.java b/packaging/AddModules.java deleted file mode 100644 index e3d8f2b9..00000000 --- a/packaging/AddModules.java +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Derives the jpackage --add-modules list from module-info.java so that the - * packaging scripts never drift from the module descriptor again. - * - * Run as a single file source program, which behaves identically on the Linux, - * macOS and Windows runners: - * - * java packaging/AddModules.java print the module list - * java packaging/AddModules.java --verify-pom fail if pom.xml drifted - * - * Only platform modules are emitted. Third party requires such as jlayer are - * skipped because they are supplied as ordinary jars on the class path, and - * automatic modules cannot be linked into a runtime image at all. Test only - * requires such as org.junit.jupiter.api are skipped for the same reason. - */ - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Set; -import java.util.TreeSet; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public final class AddModules { - - private static final Path DESCRIPTOR = - Path.of("src", "main", "java", "module-info.java"); - - private static final Path POM = Path.of("pom.xml"); - - /** Matches "requires [transitive] [static] some.module;" in any order. */ - private static final Pattern REQUIRES = Pattern.compile( - "requires\\s+((?:transitive\\s+|static\\s+)*)([A-Za-z0-9_.]+)\\s*;"); - - private static final Pattern ADD_MODULE = - Pattern.compile("\\s*([A-Za-z0-9_.]+)\\s*"); - - private static final Pattern BLOCK_COMMENT = - Pattern.compile("/\\*.*?\\*/", Pattern.DOTALL); - - private static final Pattern LINE_COMMENT = Pattern.compile("//[^\\n]*"); - - private AddModules() { - } - - public static void main(String[] args) throws IOException { - boolean verifyPom = args.length > 0 && "--verify-pom".equals(args[0]); - - Set required = platformModules(read(DESCRIPTOR)); - if (required.isEmpty()) { - fail("No platform modules found in " + DESCRIPTOR); - } - - if (!verifyPom) { - System.out.println(String.join(",", required)); - return; - } - - Set declared = new TreeSet<>(); - Matcher matcher = ADD_MODULE.matcher(read(POM)); - while (matcher.find()) { - declared.add(matcher.group(1)); - } - - if (declared.equals(required)) { - System.out.println("pom.xml matches module-info.java (" - + required.size() + " modules)"); - return; - } - - Set missing = new TreeSet<>(required); - missing.removeAll(declared); - Set extra = new TreeSet<>(declared); - extra.removeAll(required); - - System.err.println("pom.xml drifted from module-info.java."); - if (!missing.isEmpty()) { - System.err.println(" missing in pom.xml: " + String.join(", ", missing)); - } - if (!extra.isEmpty()) { - System.err.println(" not required by module-info.java: " - + String.join(", ", extra)); - } - System.err.println(" expected: " + String.join(",", required)); - System.exit(1); - } - - /** Returns the platform modules required by the given descriptor, sorted. */ - static Set platformModules(String source) { - String stripped = LINE_COMMENT.matcher( - BLOCK_COMMENT.matcher(source).replaceAll(" ")).replaceAll(" "); - - Set modules = new TreeSet<>(); - Matcher matcher = REQUIRES.matcher(stripped); - while (matcher.find()) { - // "requires static" is a compile time only dependency and must not - // be linked into the shipped runtime image. - if (matcher.group(1).contains("static")) { - continue; - } - String module = matcher.group(2); - if (isPlatformModule(module)) { - modules.add(module); - } - } - return modules; - } - - private static boolean isPlatformModule(String module) { - return module.startsWith("java.") - || module.startsWith("jdk.") - || module.startsWith("javafx."); - } - - private static String read(Path path) throws IOException { - if (!Files.isRegularFile(path)) { - fail("Not found: " + path.toAbsolutePath() - + " (run this from the repository root)"); - } - return Files.readString(path); - } - - private static void fail(String message) { - System.err.println(message); - System.exit(2); - } -} diff --git a/packaging/aur/kst4contest-git/PKGBUILD b/packaging/aur/kst4contest-git/PKGBUILD index 947014d8..f9909351 100644 --- a/packaging/aur/kst4contest-git/PKGBUILD +++ b/packaging/aur/kst4contest-git/PKGBUILD @@ -7,7 +7,7 @@ arch=('x86_64') url="https://github.com/praktimarc/kst4contest" license=('GPL-3.0-only') depends=('gst-plugins-base' 'gst-plugins-good') -makedepends=('java-environment=21' 'maven' 'git') +makedepends=('java-environment=21' 'gradle' 'git') provides=('kst4contest') conflicts=('kst4contest' 'kst4contest-bin') source=("kst4contest::git+https://github.com/praktimarc/kst4contest.git") @@ -15,8 +15,8 @@ sha256sums=('SKIP') pkgver() { cd "${srcdir}/kst4contest" - BASE=$(grep -m1 '' pom.xml \ - | sed 's/.*\(.*\)<\/version>.*/\1/' | sed 's/[-.]nightly//') + BASE=$(grep -m1 '^version=' gradle.properties \ + | cut -d= -f2 | sed 's/[-.]nightly//') printf '%s.r%s.g%s' "${BASE}" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" } @@ -26,22 +26,17 @@ build() { export JAVA_HOME=$(find /usr/lib/jvm -maxdepth 1 -name 'java-21-*' -type d | head -n 1) export PATH="${JAVA_HOME}/bin:${PATH}" - mvn -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 + ./gradlew --offline -S :app-desktop:collectRuntime mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" + ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" jpackage \ --type app-image \ --name KST4Contest \ --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ + --input app-desktop/build/dist-libs \ --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ + --main-class kst4contest.view.Main \ --add-modules "$ADD_MODULES" \ --dest dist } diff --git a/packaging/aur/kst4contest/PKGBUILD b/packaging/aur/kst4contest/PKGBUILD index 79aab26f..7333f58a 100644 --- a/packaging/aur/kst4contest/PKGBUILD +++ b/packaging/aur/kst4contest/PKGBUILD @@ -7,7 +7,7 @@ arch=('x86_64') url="https://github.com/praktimarc/kst4contest" license=('GPL-3.0-only') depends=('gst-plugins-base' 'gst-plugins-good') -makedepends=('java-environment=21' 'maven') +makedepends=('java-environment=21' 'gradle') provides=('kst4contest') conflicts=('kst4contest-bin' 'kst4contest-git') source=("${pkgname}-${pkgver}.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v${pkgver}.tar.gz") @@ -19,17 +19,14 @@ build() { export JAVA_HOME=$(find /usr/lib/jvm -maxdepth 1 -name 'java-21-*' -type d | head -n 1) export PATH="${JAVA_HOME}/bin:${PATH}" - mvn -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 + ./gradlew --offline -S :app-desktop:collectRuntime mkdir -p dist - # This PKGBUILD builds from a released source tarball, which may predate - # packaging/AddModules.java. Older tarballs carry the same list in pom.xml, - # which the build keeps in sync with module-info.java from v1.42.0 onwards. - if [ -f packaging/AddModules.java ]; then + # From the Gradle build onwards the list lives in gradle.properties. Older + # tarballs still carry it in pom.xml or derive it via packaging/AddModules.java. + if [ -f gradle.properties ]; then + ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" + elif [ -f packaging/AddModules.java ]; then ADD_MODULES="$(java packaging/AddModules.java)" else ADD_MODULES="$(sed -n 's:.*\(.*\).*:\1:p' pom.xml | paste -sd,)" @@ -45,10 +42,9 @@ build() { --type app-image \ --name KST4Contest \ "${ICON_ARGS[@]}" \ - --input target/dist-libs \ + --input app-desktop/build/dist-libs \ --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ + --main-class kst4contest.view.Main \ --add-modules "$ADD_MODULES" \ --dest dist } diff --git a/packaging/icons/generate-icons.sh b/packaging/icons/generate-icons.sh new file mode 100755 index 00000000..c661a49e --- /dev/null +++ b/packaging/icons/generate-icons.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Regenerate every raster icon from kst4contest.svg. +# +# The rasters are committed, so nothing in CI runs this — it exists so the +# icons can be rebuilt reproducibly whenever the SVG changes. Requires +# rsvg-convert (librsvg) and Python Pillow. +# +# packaging/icons/generate-icons.sh +# +# Outputs, all regenerated in place and mirrored into src/main/resources/icons +# so the in-app window icon matches what the launcher shows: +# kst4contest.png 256px, the size jpackage --icon feeds to every Linux +# package and the size hicolor/256x256/apps expects +# kst4contest.ico 16/24/32/48/64 as BMP, 128/256 as PNG (Windows convention) +# kst4contest.icns ic07-ic14, the modern macOS set up to 1024px + +set -euo pipefail + +ICON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${ICON_DIR}/../.." && pwd)" +cd "$REPO_ROOT" + +for tool in rsvg-convert python3; do + command -v "$tool" >/dev/null || { echo "Missing: $tool" >&2; exit 1; } +done +python3 -c "import PIL" 2>/dev/null || { echo "Missing: Python Pillow" >&2; exit 1; } + +# The SVG asks for 'Space Grotesk' and falls back to Helvetica Neue, then Arial. +# Whichever is installed is baked into the raster, so a machine with a different +# font set produces visibly different icons. Warn rather than fail — Arial is +# the author's own third choice and renders acceptably. +if ! fc-list 2>/dev/null | grep -qi "space grotesk"; then + echo "WARNING: 'Space Grotesk' is not installed; the KST wordmark will be" + echo " rendered with a fallback font (checked: $(fc-match 'Space Grotesk' 2>/dev/null | head -1))." +fi + +python3 - <<'PY' +import struct, subprocess, os +from PIL import Image + +SRC, OUT = "packaging/icons/kst4contest.svg", "packaging/icons" +_cache = {} + +def render(size): + if size not in _cache: + p = f"/tmp/kst4contest-icon-{size}.png" + subprocess.run(["rsvg-convert", "-w", str(size), "-h", str(size), SRC, "-o", p], check=True) + _cache[size] = p + return _cache[size] + +# --- Linux ------------------------------------------------------------- +Image.open(render(256)).convert("RGBA").save(f"{OUT}/kst4contest.png", optimize=True) + +# --- Windows ----------------------------------------------------------- +def bmp_entry(im): + w, h = im.size + px = im.load() + hdr = struct.pack("I", len(data) + 8) + data +open(f"{OUT}/kst4contest.icns", "wb").write(b"icns" + struct.pack(">I", len(chunks) + 8) + chunks) +PY + +cp packaging/icons/kst4contest.{png,ico,icns} src/main/resources/icons/ +echo "Regenerated packaging/icons and src/main/resources/icons from kst4contest.svg" diff --git a/packaging/macos/build-signed-dmg.sh b/packaging/macos/build-signed-dmg.sh index 2e5f8956..0883e766 100755 --- a/packaging/macos/build-signed-dmg.sh +++ b/packaging/macos/build-signed-dmg.sh @@ -50,11 +50,8 @@ elif [ -n "${NOTARY_PROFILE:-}" ]; then fi echo "==> Building JAR and collecting runtime dependencies" -chmod +x mvnw -./mvnw -B -DskipTests package \ - dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs -JAR="$(ls -t target/praktiKST-*.jar | head -n 1)" -cp "$JAR" target/dist-libs/app.jar +chmod +x gradlew +./gradlew -S :app-desktop:collectRuntime # jpackage only accepts a numeric major[.minor[.patch]] as the macOS bundle # version, so a Maven qualifier like "-nightly" has to be trimmed off. @@ -67,17 +64,16 @@ echo "==> Version: $POM_VERSION -> bundle version $APP_VERSION" echo "==> Step 1/4: jpackage app-image (unsigned)" rm -rf dist mkdir -p dist -ADD_MODULES="$(java packaging/AddModules.java)" +ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" MACOSX_DEPLOYMENT_TARGET="13.0" jpackage \ --type app-image \ --name KST4Contest \ --app-version "$APP_VERSION" \ --icon packaging/icons/kst4contest.icns \ - --input target/dist-libs \ + --input app-desktop/build/dist-libs \ --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ + --main-class kst4contest.view.Main \ --add-modules "$ADD_MODULES" \ --mac-package-identifier "$BUNDLE_ID" \ --mac-package-name KST4Contest \ diff --git a/pom.xml b/pom.xml deleted file mode 100644 index a572d3a6..00000000 --- a/pom.xml +++ /dev/null @@ -1,510 +0,0 @@ - - - 4.0.0 - - de.x08 - praktiKST - 1.50.0-nightly - - praktiKST - - - DO5AMF - - - http://www.x08.de - - jar - - - - ${project.artifactId} - ${project.artifactId} - ${project.artifactId} - kst4contest.view.Kst4ContestApplication - 21 - 3.6.3 - ${project.artifactId}-${project.version} - - - 21.0.5 - 24.0.1 - 5.10.1 - 1.18.44 - 5.7.0 - 3.43.2.2 - - - 3.3.2 - 3.11.0 - 3.3.0 - 3.1.1 - 3.4.1 - 3.1.1 - 3.3.0 - 3.3.1 - 4.0.0-M11 - 3.2.2 - 3.2.0 - 1.0.0.RC2 - 0.1.3 - 3.28.0 - 7.17.0 - 2.16.1 - 0.0.8 - 4.9.8.2 - 4.9.8 - 3.1.0 - - - UTF-8 - ${java.version} - ${java.version} - ${java.version} - VERBOSE - - - - - - org.openjfx - javafx-controls - ${javafx.version} - - - org.openjfx - javafx-graphics - ${javafx.version} - - - org.openjfx - javafx-fxml - ${javafx.version} - - - org.openjfx - javafx-web - ${javafx.version} - - - org.openjfx - javafx-media - ${javafx.version} - - - - - javazoom - jlayer - 1.0.1 - - - - - org.xerial - sqlite-jdbc - ${sqlite.version} - - - - - org.projectlombok - lombok - ${lombok.version} - provided - - - - - org.junit.jupiter - junit-jupiter-engine - ${junit.version} - test - - - org.junit.jupiter - junit-jupiter-params - ${junit.version} - test - - - - - org.mockito - mockito-core - ${mockito.version} - test - - - org.mockito - mockito-junit-jupiter - ${mockito.version} - test - - - - - org.jetbrains - annotations - ${jetbrains.annotations.version} - compile - - - org.junit.jupiter - junit-jupiter-api - ${junit.version} - compile - - - org.mockito - mockito-core - 5.17.0 - compile - - - - - - - - ${jar.filename} - - - org.apache.maven.plugins - maven-wrapper-plugin - ${maven.wrapper.plugin} - - - org.apache.maven.plugins - maven-enforcer-plugin - ${maven.enforcer.plugin} - - - enforce-versions - - enforce - - - - - ${required.maven.version} - - - - - - - - - - org.codehaus.mojo - exec-maven-plugin - ${exec.maven.plugin} - - - verify-packaging-module-list - validate - - exec - - - ${java.home}/bin/java - ${project.basedir} - - packaging/AddModules.java - --verify-pom - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - ${maven.compiler.plugin} - - ${java.version} - - - org.projectlombok - lombok - ${lombok.version} - - - - - - - org.apache.maven.plugins - maven-clean-plugin - ${maven.clean.plugin} - - - - org.apache.maven.plugins - maven-deploy-plugin - ${maven.deploy.plugin} - - - - org.apache.maven.plugins - maven-install-plugin - ${maven.install.plugin} - - - - org.apache.maven.plugins - maven-jar-plugin - ${maven.jar.plugin} - - - - org.apache.maven.plugins - maven-resources-plugin - ${maven.resources.plugin} - - - - org.apache.maven.plugins - maven-site-plugin - ${maven.site.plugin} - - - - org.apache.maven.plugins - maven-surefire-plugin - ${maven.surfire.plugin} - - true - - false - - - - - com.github.spotbugs - spotbugs-maven-plugin - ${spotbugs.maven.plugin} - - - - com.github.spotbugs - spotbugs - ${spotbugs.version} - - - - - prepare-package - - - spotbugs - - - - - - - - - - org.apache.maven.plugins - maven-pmd-plugin - ${maven.pmd.version} - - 100 - ${java.version} - false - - pmd-ruleset.xml - - - - - prepare-package - - - pmd - - - - - - - - - - org.codehaus.mojo - versions-maven-plugin - ${codehaus.version.plugin} - - - validate - - display-dependency-updates - display-plugin-updates - display-property-updates - - - - - - - - org.openjfx - javafx-maven-plugin - ${javafx.maven.plugin} - - ${main.class} - - - - - - - - - - - Image - - - Image - - - - - - - org.apache.maven.plugins - maven-dependency-plugin - ${maven.dependency.plugin} - - - - copy-dependencies - package - - copy-dependencies - - - ${project.build.directory}/modules - runtime - false - false - true - - - - - - copy - install - - copy - - - ${project.build.directory}/modules - - - ${project.groupId} - ${project.artifactId} - ${project.version} - ${project.packaging} - ${project.build.finalName}.jar - - - true - - - - - - - com.github.akman - jpackage-maven-plugin - ${jpackage.maven.plugin} - - ${appName} - IMAGE - - - - - javafx\..* - - - - - - - java.desktop - java.net.http - java.sql - javafx.controls - javafx.fxml - javafx.media - javafx.web - jdk.crypto.ec - jdk.jsobject - jdk.net - jdk.xml.dom - - ${main.class} - ${project.build.directory}/modules - ${jar.filename}.jar - - - - install - - jpackage - - - - - - - - - - - \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..439f8c27 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,4 @@ +rootProject.name = "kst4contest" + +include(":core") +include(":app-desktop") diff --git a/src/main/java/kst4contest/utils/BoundedDequeObservableList.java b/src/main/java/kst4contest/utils/BoundedDequeObservableList.java deleted file mode 100644 index 5db1a6ec..00000000 --- a/src/main/java/kst4contest/utils/BoundedDequeObservableList.java +++ /dev/null @@ -1,168 +0,0 @@ -package kst4contest.utils; - -import javafx.collections.ObservableListBase; - -import java.util.Arrays; - -/** - * A bounded ObservableList backed by a circular buffer (ring buffer). - *

- * Provides O(1) {@link #addFirst} and {@link #addLast} as well as O(1) - * random access via {@link #get}. When the list reaches {@code maxCapacity}, - * adding a new element at the front automatically evicts the oldest element - * at the back — and vice versa. - *

- * This is a drop-in replacement for {@code FXCollections.observableArrayList()} - * wherever elements are prepended frequently, e.g. chat message lists. - */ -public class BoundedDequeObservableList extends ObservableListBase { - - private final int maxCapacity; - private final Object[] elements; - private int head = 0; - private int size = 0; - - public BoundedDequeObservableList(int maxCapacity) { - if (maxCapacity <= 0) throw new IllegalArgumentException("maxCapacity must be > 0"); - this.maxCapacity = maxCapacity; - this.elements = new Object[maxCapacity]; - } - - // ── read access ────────────────────────────────────────────────────────── - - @Override - public int size() { - return size; - } - - @Override - @SuppressWarnings("unchecked") - public E get(int index) { - checkIndex(index); - return (E) elements[physicalIndex(index)]; - } - - // ── O(1) deque operations ───────────────────────────────────────────────── - - /** - * Inserts {@code element} at index 0 (newest-first order). - * If the list is already at capacity the oldest element (last index) is - * removed first — both changes are reported as a single compound change. - */ - public void addFirst(E element) { - beginChange(); - if (size == maxCapacity) { - // evict last element - int lastPhysical = physicalIndex(size - 1); - @SuppressWarnings("unchecked") - E evicted = (E) elements[lastPhysical]; - elements[lastPhysical] = null; - size--; - nextRemove(size, evicted); // index after decrement == old last index - } - head = (head - 1 + maxCapacity) % maxCapacity; - elements[head] = element; - size++; - nextAdd(0, 1); - endChange(); - } - - /** - * Appends {@code element} at the last index (oldest-first order). - * If the list is already at capacity the newest element (index 0) is - * removed first. - */ - public void addLast(E element) { - beginChange(); - if (size == maxCapacity) { - // evict first element - @SuppressWarnings("unchecked") - E evicted = (E) elements[head]; - elements[head] = null; - head = (head + 1) % maxCapacity; - size--; - nextRemove(0, evicted); - } - elements[physicalIndex(size)] = element; - size++; - nextAdd(size - 1, size); - endChange(); - } - - // ── standard List mutation (O(n) — use addFirst/addLast for hot path) ───── - - @Override - public void add(int index, E element) { - if (index == 0) { - addFirst(element); - return; - } - if (index == size) { - addLast(element); - return; - } - checkIndexForAdd(index); - beginChange(); - if (size == maxCapacity) { - int lastPhysical = physicalIndex(size - 1); - @SuppressWarnings("unchecked") - E evicted = (E) elements[lastPhysical]; - elements[lastPhysical] = null; - size--; - nextRemove(size, evicted); - } - // shift elements [index .. size-1] one position towards the end - for (int i = size; i > index; i--) { - elements[physicalIndex(i)] = elements[physicalIndex(i - 1)]; - } - elements[physicalIndex(index)] = element; - size++; - nextAdd(index, index + 1); - endChange(); - } - - @Override - public E remove(int index) { - checkIndex(index); - beginChange(); - @SuppressWarnings("unchecked") - E removed = (E) elements[physicalIndex(index)]; - // shift elements [index+1 .. size-1] one position towards the front - for (int i = index; i < size - 1; i++) { - elements[physicalIndex(i)] = elements[physicalIndex(i + 1)]; - } - elements[physicalIndex(size - 1)] = null; - size--; - nextRemove(index, removed); - endChange(); - return removed; - } - - @Override - public E set(int index, E element) { - checkIndex(index); - beginChange(); - @SuppressWarnings("unchecked") - E old = (E) elements[physicalIndex(index)]; - elements[physicalIndex(index)] = element; - nextSet(index, old); - endChange(); - return old; - } - - // ── helpers ─────────────────────────────────────────────────────────────── - - private int physicalIndex(int virtualIndex) { - return (head + virtualIndex) % maxCapacity; - } - - private void checkIndex(int index) { - if (index < 0 || index >= size) - throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); - } - - private void checkIndexForAdd(int index) { - if (index < 0 || index > size) - throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); - } -} diff --git a/src/main/java/kst4contest/utils/TestAudioPlayerUtils.java b/src/main/java/kst4contest/utils/TestAudioPlayerUtils.java deleted file mode 100644 index def1fde3..00000000 --- a/src/main/java/kst4contest/utils/TestAudioPlayerUtils.java +++ /dev/null @@ -1,19 +0,0 @@ -package kst4contest.utils; - -import javafx.application.Application; -import javafx.stage.Stage; -import kst4contest.utils.PlayAudioUtils; - -public class TestAudioPlayerUtils extends Application { - - public static void main(String[] args) { - - } - - @Override - public void start(Stage stage) throws Exception { - - PlayAudioUtils testAudio = new PlayAudioUtils(); - testAudio.playCWLauncher("DO5AMF"); - } -} diff --git a/src/main/java/kst4contest/view/map/StationMapPerformanceSupport.java b/src/main/java/kst4contest/view/map/StationMapPerformanceSupport.java deleted file mode 100644 index 7a6a05a0..00000000 --- a/src/main/java/kst4contest/view/map/StationMapPerformanceSupport.java +++ /dev/null @@ -1,96 +0,0 @@ -package kst4contest.view.map; - -import java.util.Objects; -import java.util.concurrent.TimeUnit; - -/** Small JavaFX-independent state helpers for station-map refresh decisions. */ -final class StationMapPerformanceSupport { - - /** Minimum duration for a slow-refresh diagnostic. */ - private static final long SLOW_NANOS = - TimeUnit.MILLISECONDS.toNanos(100L); - - private StationMapPerformanceSupport() { - } - - /* default */ static void runDebugIfEnabled( - final boolean enabled, - final Runnable operation - ) { - Objects.requireNonNull(operation, "operation"); - if (enabled) { - operation.run(); - } - } - - /* default */ static boolean isSlowRefresh(final long totalNanos) { - return totalNanos >= SLOW_NANOS; - } - - /* default */ static String formatSlowRefreshMessage( - final long totalNanos, - final long snapshotNanos, - final long renderNanos, - final int visibleCount, - final int stationCount - ) { - return "Station map refresh slow: total=" - + TimeUnit.NANOSECONDS.toMillis(totalNanos) - + " ms, snapshots=" - + TimeUnit.NANOSECONDS.toMillis(snapshotNanos) - + " ms, render=" - + TimeUnit.NANOSECONDS.toMillis(renderNanos) - + " ms, visibleMembers=" - + visibleCount - + ", stations=" - + stationCount; - } - - /** Tracks deferred refresh work while the map window is hidden. */ - /* default */ static final class RefreshState { - /** Whether current application state still needs to reach the map. */ - private boolean dirty = true; - - /* default */ boolean shouldRefreshNow(final boolean visible) { - dirty = !visible; - return visible; - } - - /* default */ void markDirty() { - dirty = true; - } - - /* default */ boolean consumeDirtyOnShow() { - final boolean refreshRequired = dirty; - dirty = false; - return refreshRequired; - } - } - - /** Complete set of inputs that can change the visible grid rendering. */ - /* default */ record GridRenderInput( - double southLatitude, - double westLongitude, - double northLatitude, - double eastLongitude, - int zoom, - double viewportWidth, - double viewportHeight - ) { - } - - /** Rejects duplicate grid inputs before cell construction starts. */ - /* default */ static final class GridRenderState { - /** Inputs used for the last planned grid rendering. */ - private GridRenderInput lastInput; - - /* default */ boolean shouldRender(final GridRenderInput input) { - Objects.requireNonNull(input, "input"); - final boolean renderRequired = !input.equals(lastInput); - if (renderRequired) { - lastInput = input; - } - return renderRequired; - } - } -} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java deleted file mode 100644 index dbe78109..00000000 --- a/src/main/java/module-info.java +++ /dev/null @@ -1,24 +0,0 @@ -module praktiKST { - requires javafx.controls; - requires javafx.fxml; - requires javafx.web; - requires jdk.xml.dom; - requires java.sql; - requires javafx.media; - requires jdk.jsobject; - requires jlayer; - requires java.net.http; - requires java.desktop; - requires jdk.crypto.ec; - requires jdk.net; - requires org.junit.jupiter.api; - requires org.mockito; - exports kst4contest.controller.interfaces; - exports kst4contest.controller; - exports kst4contest.locatorUtils; - exports kst4contest.model; - exports kst4contest.view; - - - opens kst4contest.view.map to javafx.web; -} diff --git a/src/test/java/kst4contest/view/map/StationMapPerformanceSupportTest.java b/src/test/java/kst4contest/view/map/StationMapPerformanceSupportTest.java deleted file mode 100644 index c71ec3b4..00000000 --- a/src/test/java/kst4contest/view/map/StationMapPerformanceSupportTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package kst4contest.view.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; - -import java.util.concurrent.atomic.AtomicBoolean; - -import org.junit.jupiter.api.Test; - -class StationMapPerformanceSupportTest { - - @Test - void hiddenRefreshIsDeferredUntilTheNextShowOnly() { - StationMapPerformanceSupport.RefreshState state = - new StationMapPerformanceSupport.RefreshState(); - - assertFalse(state.shouldRefreshNow(false)); - assertTrue(state.consumeDirtyOnShow()); - assertFalse(state.consumeDirtyOnShow()); - } - - @Test - void visibleRefreshCanRunImmediately() { - StationMapPerformanceSupport.RefreshState state = - new StationMapPerformanceSupport.RefreshState(); - - assertTrue(state.shouldRefreshNow(true)); - assertFalse(state.consumeDirtyOnShow()); - } - - @Test - void unchangedGridInputsAreRejectedBeforeRendering() { - StationMapPerformanceSupport.GridRenderState state = - new StationMapPerformanceSupport.GridRenderState(); - StationMapPerformanceSupport.GridRenderInput input = gridInput(); - - assertTrue(state.shouldRender(input)); - assertFalse(state.shouldRender(input)); - } - - @Test - void everyGridInputCanTriggerANewRender() { - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.1, 6.0, 55.0, 16.0, 7, 1024.0, 768.0)); - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.0, 6.1, 55.0, 16.0, 7, 1024.0, 768.0)); - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.0, 6.0, 55.1, 16.0, 7, 1024.0, 768.0)); - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.0, 6.0, 55.0, 16.1, 7, 1024.0, 768.0)); - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.0, 6.0, 55.0, 16.0, 8, 1024.0, 768.0)); - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.0, 6.0, 55.0, 16.0, 7, 1200.0, 768.0)); - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.0, 6.0, 55.0, 16.0, 7, 1024.0, 800.0)); - } - - @Test - void disabledMapDebugSkipsTheDiagnosticOperation() { - AtomicBoolean executed = new AtomicBoolean(); - - StationMapPerformanceSupport.runDebugIfEnabled( - false, - () -> executed.set(true)); - - assertFalse(executed.get()); - } - - @Test - void enabledMapDebugRunsTheDiagnosticOperation() { - AtomicBoolean executed = new AtomicBoolean(); - - StationMapPerformanceSupport.runDebugIfEnabled( - true, - () -> executed.set(true)); - - assertTrue(executed.get()); - } - - @Test - void slowRefreshStartsAtOneHundredMilliseconds() { - assertFalse(StationMapPerformanceSupport.isSlowRefresh(99_999_999L)); - assertTrue(StationMapPerformanceSupport.isSlowRefresh(100_000_000L)); - } - - @Test - void slowRefreshMessageSeparatesSnapshotAndRenderTimes() { - assertEquals( - "Station map refresh slow: total=245 ms, snapshots=42 ms, " - + "render=198 ms, visibleMembers=500, stations=487", - StationMapPerformanceSupport.formatSlowRefreshMessage( - 245_000_000L, - 42_000_000L, - 198_000_000L, - 500, - 487 - ) - ); - } - - private void assertChanged( - StationMapPerformanceSupport.GridRenderInput changed) { - StationMapPerformanceSupport.GridRenderState state = - new StationMapPerformanceSupport.GridRenderState(); - assertTrue(state.shouldRender(gridInput())); - assertTrue(state.shouldRender(changed)); - } - - private StationMapPerformanceSupport.GridRenderInput gridInput() { - return new StationMapPerformanceSupport.GridRenderInput( - 49.0, - 6.0, - 55.0, - 16.0, - 7, - 1024.0, - 768.0 - ); - } -}