Compare commits

..
Author SHA1 Message Date
dependabot[bot] 76237062de build(deps): bump actions/setup-java from 4.1.0 to 6.0.1
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4.1.0 to 6.0.1.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v4.1.0...v6.0.1)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-09 20:16:05 +02:00
dependabot[bot] 1884f54863 build(deps): bump actions/upload-artifact from 4 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-09 20:15:36 +02:00
dependabot[bot] 78bc697a48 build(deps): bump actions/download-artifact from 4.1.3 to 8.0.1
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.1.3 to 8.0.1.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4.1.3...v8.0.1)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-09 20:14:53 +02:00
dependabot[bot] 875b53146a build(deps): bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-09 20:11:26 +02:00
dependabot[bot] b0cbfa961c build(deps): bump Andrew-Chen-Wang/github-wiki-action from 4 to 5
Bumps [Andrew-Chen-Wang/github-wiki-action](https://github.com/andrew-chen-wang/github-wiki-action) from 4 to 5.
- [Release notes](https://github.com/andrew-chen-wang/github-wiki-action/releases)
- [Commits](https://github.com/andrew-chen-wang/github-wiki-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: Andrew-Chen-Wang/github-wiki-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-09 20:06:26 +02:00
Rsclub2_2andClaude Sonnet 5.5 4c43e14b54 chore: add Dependabot configuration for Gradle and GitHub Actions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 20:02:22 +02:00
231 changed files with 4379 additions and 19551 deletions

No files matched your search

+1 -1
View File
@@ -102,7 +102,7 @@ jobs:
github_docs/de-Changelog.md
- name: Upload PDF artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: docs-pdf
path: dist/KST4Contest-*-manual-*.pdf
+9 -110
View File
@@ -25,104 +25,9 @@ permissions:
packages: write
jobs:
# Same job as in pr-compile-check.yml, copied rather than shared: a reusable workflow
# would be one more indirection to read, and the comments belong next to the steps they
# explain. Every packaging job below needs this one, so a commit whose tests fail builds
# no artifact (#62). Edit all three copies together.
test:
name: Test (Java 21, headless)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Java 21
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
- name: Ensure gradlew is executable
run: chmod +x gradlew
# Skiko's native library links against libGL, so it has to be loadable even though
# nothing here renders through it: the drawing tests use Skia's software backend and
# runComposeUiTest never opens a window. Without this the whole Compose half of the
# suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object
# file" -- which reads like a missing display and is not one. Found by running this
# job under act, where the container has no libGL; GitHub's own runner image happens
# to ship it, so the step is what makes the job reproducible off GitHub.
- name: Install the OpenGL client library skiko links against
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libgl1
# No Xvfb, deliberately. Everything that pins behaviour runs without a display:
# runComposeUiTest reaches the semantics tree headless (measured before this job was
# written), and the ImageComposeScene drawing tests render in software. The handful
# of tests that open a real window guard themselves with assumeFalse(isHeadless) and
# skip here; the summary below prints the skip count, so that is visible rather than
# assumed.
#
# Software GL under Xvfb was considered and rejected: a missing Skia GL context is
# the hardest failure in this codebase to read, and importing that class of flake
# into CI would cost more than the few window tests are worth here.
# All three modules. The translation generator is a Gradle module and not buildSrc
# precisely so that its 42 tests run here: buildSrc tests do not run on `./gradlew
# build` -- measured, not assumed. The summary below globs */build/test-results, so
# the module was counted the moment it existed.
- name: Test
run: ./gradlew -S test
- name: Summarise the test results
if: always()
run: |
set -euo pipefail
shopt -s nullglob
files=(*/build/test-results/test/TEST-*.xml)
if [ ${#files[@]} -eq 0 ]; then
echo "No test result XML was produced -- the test task did not run." >&2
exit 1
fi
# Counted from the XML and not from Gradle's console output: this project's own
# rule is never to infer "all tests passed" from an exit code.
summary=$(grep -h "<testsuite " "${files[@]}" \
| sed 's/.*tests="\([0-9]*\)".*skipped="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3 \4/' \
| awk '{t+=$1; s+=$2; f+=$3; e+=$4} END {print t" "s" "f" "e}')
read -r total skipped failures errors <<< "$summary"
echo "tests=$total skipped=$skipped failures=$failures errors=$errors"
{
echo "### Tests"
echo ""
echo "| total | skipped | failures | errors |"
echo "|---|---|---|---|"
echo "| $total | $skipped | $failures | $errors |"
echo ""
echo "Skipped are the tests that need a display, plus the deliberately"
echo "disabled reference-data generator. This job runs headless on purpose."
} >> "$GITHUB_STEP_SUMMARY"
if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then
echo "The result XML reports failures or errors." >&2
exit 1
fi
- name: Keep the test reports on a failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: test-results
path: |
*/build/test-results/test/
*/build/reports/tests/test/
if-no-files-found: ignore
retention-days: 7
build-windows-zip:
name: Build Windows ZIP
runs-on: windows-latest
needs: test
steps:
- name: Checkout
@@ -166,7 +71,7 @@ jobs:
Compress-Archive -Path dist/KST4Contest -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
- name: Upload Windows artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: windows-zip
path: dist/praktiKST-*-windows-x64.zip
@@ -175,7 +80,6 @@ jobs:
build-linux-appimage:
name: Build Linux AppImage
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
@@ -240,7 +144,7 @@ jobs:
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir "dist/${ASSET_BASENAME}-linux-x86_64.AppImage"
- name: Upload Linux artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: linux-appimage
path: dist/KST4Contest-*-linux-x86_64.AppImage
@@ -249,7 +153,6 @@ jobs:
build-linux-deb:
name: Build Debian package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
@@ -286,7 +189,7 @@ jobs:
mv "$DEB" "dist/${ASSET_BASENAME}-debian-amd64.deb"
- name: Upload Debian artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: linux-debian
path: dist/KST4Contest-*-debian-amd64.deb
@@ -295,7 +198,6 @@ jobs:
build-linux-rpm:
name: Build Fedora package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
@@ -332,7 +234,7 @@ jobs:
mv "$RPM" "dist/${ASSET_BASENAME}-fedora-x86_64.rpm"
- name: Upload Fedora artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: linux-fedora
path: dist/KST4Contest-*-fedora-x86_64.rpm
@@ -341,7 +243,6 @@ jobs:
build-linux-arch:
name: Build Arch Linux package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
@@ -444,7 +345,7 @@ jobs:
tar --zstd --transform 's|^\./||' -cf "dist/${ASSET_BASENAME}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" .
- name: Upload Arch Linux artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: linux-arch
path: dist/KST4Contest-*-archlinux-*.pkg.tar.zst
@@ -453,7 +354,6 @@ jobs:
build-flatpak:
name: Build Flatpak
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
@@ -563,13 +463,13 @@ jobs:
EOF
- name: Upload flatpakref
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: flatpakref
path: dist/de.x08.KST4Contest.nightly.flatpakref
- name: Upload Flatpak OSTree repo
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: flatpak-ostree-repo
path: target/flatpak-repo/
@@ -594,7 +494,7 @@ jobs:
echo "FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')" >> "$GITHUB_ENV"
- name: Download OSTree repo artifact
uses: actions/download-artifact@v8.0.2
uses: actions/download-artifact@v8.0.1
with:
name: flatpak-ostree-repo
path: flatpak-ostree-repo/
@@ -638,7 +538,6 @@ jobs:
build-macos-dmg:
name: Build macOS DMG (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: test
strategy:
fail-fast: false
matrix:
@@ -703,7 +602,7 @@ jobs:
mv "$DMG" "dist/${ASSET_BASENAME}-macos-${ARCH}.dmg"
- name: Upload macOS artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: macos-dmg-${{ matrix.os }}
path: dist/KST4Contest-*-macos-*.dmg
+1 -111
View File
@@ -41,64 +41,6 @@ jobs:
- name: Compile
run: ./gradlew -S classes testClasses
# Report only: both tools run with ignoreFailures, so a finding cannot fail this step
# -- a crash of the tool itself still does, which is wanted. The findings used to exist
# only in the console log; since #110 they are files, and the table and the artifact
# below are what makes them readable from a pull request.
- name: Static analysis (report only)
run: ./gradlew -S pmdMain spotbugsMain
# Counted from the report XML, never from Gradle's output or exit code. A missing
# report prints "no report" and does not fail the step: that case is already visible
# in the step above.
- name: Summarise the static analysis
if: always()
run: |
set -euo pipefail
shopt -s nullglob
count() {
local file="$1" element="$2"
if [ -f "$file" ]; then
# grep exits 1 on zero matches, which pipefail would turn into a failed step.
{ grep -o "<$element " "$file" || true; } | wc -l | tr -d ' '
else
echo "no report"
fi
}
{
echo "### Static analysis"
echo ""
echo "| module | PMD violations | SpotBugs bugs |"
echo "|---|---|---|"
} >> "$GITHUB_STEP_SUMMARY"
for module in core app-desktop i18n-generator; do
pmd=$(count "$module/build/reports/pmd/main.xml" violation)
spotbugs=$(count "$module/build/reports/spotbugs/main.xml" BugInstance)
echo "$module: pmd=$pmd spotbugs=$spotbugs"
echo "| \`$module\` | $pmd | $spotbugs |" >> "$GITHUB_STEP_SUMMARY"
done
{
echo ""
echo "Report only: nothing fails on a finding. The XML and HTML reports are the"
echo "static-analysis artifact of this run. i18n-generator is Kotlin only, so PMD"
echo "has no Java source there and writes no report."
} >> "$GITHUB_STEP_SUMMARY"
- name: Keep the static analysis reports
if: always()
uses: actions/upload-artifact@v7
with:
name: static-analysis
path: |
*/build/reports/pmd/
*/build/reports/spotbugs/
if-no-files-found: ignore
retention-days: 14
# What a translation PR is actually about, put where its author will look. The counts
# come from the files rather than from Gradle's output, so an up-to-date task cannot
# make the table disappear.
@@ -199,14 +141,6 @@ jobs:
- name: Test
run: ./gradlew -S test
# Coverage is a report, not a gate: no threshold, nothing in it can fail on a
# percentage. Deliberately not `if: always()` -- without a completed test run the
# report would be partial, and after a failed test the result XML below is what matters.
# jacocoTestReport writes the per-module XML the summary reads; the aggregate merges
# core and app-desktop (i18n-generator is build tooling and stays out).
- name: Coverage report
run: ./gradlew -S jacocoTestReport jacocoAggregatedReport
- name: Summarise the test results
if: always()
run: |
@@ -239,53 +173,9 @@ jobs:
exit 1
fi
- name: Summarise the coverage
if: always()
run: |
set -euo pipefail
# The last LINE/BRANCH counter in a JaCoCo XML is the report-level total; the
# per-package and per-class counters come before it.
percent() {
local file="$1" type="$2"
{ grep -o "<counter type=\"$type\"[^>]*/>" "$file" || true; } | tail -1 \
| sed 's/.*missed="\([0-9]*\)".*covered="\([0-9]*\)".*/\1 \2/' \
| awk 'NF == 2 { t=$1+$2; if (t==0) print "n/a"; else printf "%.1f%% (%d/%d)", 100*$2/t, $2, t }'
}
row() {
local label="$1" file="$2"
if [ -f "$file" ]; then
echo "| $label | $(percent "$file" LINE) | $(percent "$file" BRANCH) |"
else
echo "| $label | no report | no report |"
fi
}
{
echo ""
echo "### Coverage"
echo ""
echo "| module | lines | branches |"
echo "|---|---|---|"
row "core" core/build/reports/jacoco/test/jacocoTestReport.xml
row "app-desktop" app-desktop/build/reports/jacoco/test/jacocoTestReport.xml
row "core + app-desktop" build/reports/jacoco/aggregated/jacoco.xml
echo ""
echo "Report only: nothing fails on a percentage. The aggregated HTML report is the"
echo "coverage-report artifact of this run. i18n-generator is build tooling and is"
echo "not counted."
} >> "$GITHUB_STEP_SUMMARY"
- name: Keep the coverage report
if: always()
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: build/reports/jacoco/aggregated/html/
if-no-files-found: ignore
retention-days: 14
- name: Keep the test reports on a failure
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: test-results
path: |
+24 -272
View File
@@ -16,132 +16,9 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# Same job as in pr-compile-check.yml, copied rather than shared: a reusable workflow
# would be one more indirection to read, and the comments belong next to the steps they
# explain. Every packaging job below needs this one, so a commit whose tests fail builds
# no artifact (#62). Edit all three copies together -- except for the tag check
# right after the checkout, which only makes sense here.
test:
name: Test (Java 21, headless)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
# composePackageVersion is the package version of every Compose target format,
# and the Linux package release below is derived from the tag alone. A tag that
# names a different X.Y.Z than gradle.properties would therefore ship packages
# whose version contradicts their file name and upgrade order (#113). Fail
# before the tests rather than after an hour of packaging. Skipped for
# workflow_dispatch, where the ref is a branch and carries no version.
- name: Verify the tag matches composePackageVersion
if: github.ref_type == 'tag'
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ "$REF_NAME" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
TAG_VERSION="${BASH_REMATCH[1]}"
elif [[ "$REF_NAME" =~ ^beta-v([0-9]+\.[0-9]+\.[0-9]+)-.+$ ]]; then
TAG_VERSION="${BASH_REMATCH[1]}"
else
echo "::error::Tag '$REF_NAME' is neither vX.Y.Z nor beta-vX.Y.Z-<suffix>, so it carries no package version"
exit 1
fi
PROPERTY_VERSION="$(sed -n 's/^composePackageVersion=//p' gradle.properties)"
if [[ "$TAG_VERSION" != "$PROPERTY_VERSION" ]]; then
echo "::error::Tag $REF_NAME names version $TAG_VERSION, but gradle.properties has composePackageVersion=$PROPERTY_VERSION. Bump composePackageVersion (and version) before tagging, or move the tag."
exit 1
fi
echo "Tag $REF_NAME matches composePackageVersion=$PROPERTY_VERSION"
- name: Set up Java 21
uses: actions/setup-java@v6.0.1
with:
distribution: temurin
java-version: "21"
- name: Ensure gradlew is executable
run: chmod +x gradlew
# Skiko's native library links against libGL, so it has to be loadable even though
# nothing here renders through it: the drawing tests use Skia's software backend and
# runComposeUiTest never opens a window. Without this the whole Compose half of the
# suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object
# file" -- which reads like a missing display and is not one. Found by running this
# job under act, where the container has no libGL; GitHub's own runner image happens
# to ship it, so the step is what makes the job reproducible off GitHub.
- name: Install the OpenGL client library skiko links against
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libgl1
# No Xvfb, deliberately. Everything that pins behaviour runs without a display:
# runComposeUiTest reaches the semantics tree headless (measured before this job was
# written), and the ImageComposeScene drawing tests render in software. The handful
# of tests that open a real window guard themselves with assumeFalse(isHeadless) and
# skip here; the summary below prints the skip count, so that is visible rather than
# assumed.
#
# Software GL under Xvfb was considered and rejected: a missing Skia GL context is
# the hardest failure in this codebase to read, and importing that class of flake
# into CI would cost more than the few window tests are worth here.
# All three modules. The translation generator is a Gradle module and not buildSrc
# precisely so that its 42 tests run here: buildSrc tests do not run on `./gradlew
# build` -- measured, not assumed. The summary below globs */build/test-results, so
# the module was counted the moment it existed.
- name: Test
run: ./gradlew -S test
- name: Summarise the test results
if: always()
run: |
set -euo pipefail
shopt -s nullglob
files=(*/build/test-results/test/TEST-*.xml)
if [ ${#files[@]} -eq 0 ]; then
echo "No test result XML was produced -- the test task did not run." >&2
exit 1
fi
# Counted from the XML and not from Gradle's console output: this project's own
# rule is never to infer "all tests passed" from an exit code.
summary=$(grep -h "<testsuite " "${files[@]}" \
| sed 's/.*tests="\([0-9]*\)".*skipped="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3 \4/' \
| awk '{t+=$1; s+=$2; f+=$3; e+=$4} END {print t" "s" "f" "e}')
read -r total skipped failures errors <<< "$summary"
echo "tests=$total skipped=$skipped failures=$failures errors=$errors"
{
echo "### Tests"
echo ""
echo "| total | skipped | failures | errors |"
echo "|---|---|---|---|"
echo "| $total | $skipped | $failures | $errors |"
echo ""
echo "Skipped are the tests that need a display, plus the deliberately"
echo "disabled reference-data generator. This job runs headless on purpose."
} >> "$GITHUB_STEP_SUMMARY"
if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then
echo "The result XML reports failures or errors." >&2
exit 1
fi
- name: Keep the test reports on a failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: test-results
path: |
*/build/test-results/test/
*/build/reports/tests/test/
if-no-files-found: ignore
retention-days: 7
build-windows-zip:
name: Build Windows ZIP and MSI
name: Build Windows ZIP
runs-on: windows-latest
needs: test
steps:
- name: Checkout
@@ -176,41 +53,14 @@ jobs:
Compress-Archive -Path dist/KST4Contest -DestinationPath dist/praktiKST-${{ github.ref_name }}-windows-x64.zip -Force
- name: Upload Windows artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: windows-zip
path: dist/praktiKST-${{ github.ref_name }}-windows-x64.zip
# Stable tags only (issue #61): the MSI carries composePackageVersion
# from gradle.properties, so a nightly or beta MSI would have the same
# version as the following stable one. Windows Installer refuses to
# install an equal version over the installed one (jpackage's upgrade
# table only accepts strictly higher versions), so a beta tester could
# not move to the stable without uninstalling first. Betas and nightlies
# therefore stay ZIP-only. jpackage needs WiX 3.x, installed above. The
# ZIP is unchanged and stays alongside.
- name: Build Windows MSI installer
if: ${{ !startsWith(github.ref_name, 'beta-') }}
shell: pwsh
run: |
.\gradlew.bat -S :app-desktop:packageMsi
$msi = Get-ChildItem -Path app-desktop/build/compose/binaries/main/msi -Filter *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $msi) {
throw "No Windows MSI produced by packageMsi"
}
Copy-Item $msi.FullName dist/KST4Contest-${{ github.ref_name }}-windows-x64.msi
- name: Upload Windows MSI artifact
if: ${{ !startsWith(github.ref_name, 'beta-') }}
uses: actions/upload-artifact@v7
with:
name: windows-msi
path: dist/KST4Contest-${{ github.ref_name }}-windows-x64.msi
build-linux-appimage:
name: Build Linux AppImage
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
@@ -269,7 +119,7 @@ jobs:
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
- name: Upload Linux artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: linux-appimage
path: dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
@@ -277,7 +127,6 @@ jobs:
build-linux-deb:
name: Build Debian package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
@@ -297,41 +146,9 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
# rpm and dpkg refuse to replace a package by one of equal version, and the
# version is composePackageVersion for beta and stable alike. The package
# RELEASE number tells them apart, following the Fedora pre-release convention:
# beta-v1.50.0-beta-001 -> 1.50.0-0.beta.001, beta-v1.50.0-rc02 -> 1.50.0-0.rc02,
# v1.50.0 -> 1.50.0-1. Everything starting with "0." sorts below "1", so the
# stable installs as an upgrade over any beta (#113). The beta part is the tag
# remainder after beta-vX.Y.Z- with "-" turned into "."; rpm and dpkg accept
# only [A-Za-z0-9.] there, anything else fails this step. nightly-artifacts.yml
# does not set the property and keeps release 1, as do local builds.
- name: Derive the Linux package release from the tag
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
set -euo pipefail
if [[ "$REF_TYPE" != "tag" ]]; then
RELEASE="1"
elif [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
RELEASE="1"
elif [[ "$REF_NAME" =~ ^beta-v[0-9]+\.[0-9]+\.[0-9]+-(.+)$ ]]; then
RELEASE="0.${BASH_REMATCH[1]//-/.}"
else
echo "::error::Tag '$REF_NAME' is neither vX.Y.Z nor beta-vX.Y.Z-<suffix>, so no package release can be derived"
exit 1
fi
if [[ ! "$RELEASE" =~ ^[A-Za-z0-9.]+$ ]]; then
echo "::error::Derived package release '$RELEASE' contains characters rpm and dpkg do not accept"
exit 1
fi
echo "Linux package release for $REF_NAME: $RELEASE"
echo "LINUX_APP_RELEASE=$RELEASE" >> "$GITHUB_ENV"
- name: Build Debian package
run: |
./gradlew -S :app-desktop:packageDeb -PlinuxAppRelease="$LINUX_APP_RELEASE"
./gradlew -S :app-desktop:packageDeb
mkdir -p dist
DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
if [ -z "$DEB" ]; then
@@ -340,7 +157,7 @@ jobs:
mv "$DEB" "dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb"
- name: Upload Debian artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: linux-debian
path: dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb
@@ -348,7 +165,6 @@ jobs:
build-linux-rpm:
name: Build Fedora package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
@@ -368,41 +184,9 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
# rpm and dpkg refuse to replace a package by one of equal version, and the
# version is composePackageVersion for beta and stable alike. The package
# RELEASE number tells them apart, following the Fedora pre-release convention:
# beta-v1.50.0-beta-001 -> 1.50.0-0.beta.001, beta-v1.50.0-rc02 -> 1.50.0-0.rc02,
# v1.50.0 -> 1.50.0-1. Everything starting with "0." sorts below "1", so the
# stable installs as an upgrade over any beta (#113). The beta part is the tag
# remainder after beta-vX.Y.Z- with "-" turned into "."; rpm and dpkg accept
# only [A-Za-z0-9.] there, anything else fails this step. nightly-artifacts.yml
# does not set the property and keeps release 1, as do local builds.
- name: Derive the Linux package release from the tag
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
set -euo pipefail
if [[ "$REF_TYPE" != "tag" ]]; then
RELEASE="1"
elif [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
RELEASE="1"
elif [[ "$REF_NAME" =~ ^beta-v[0-9]+\.[0-9]+\.[0-9]+-(.+)$ ]]; then
RELEASE="0.${BASH_REMATCH[1]//-/.}"
else
echo "::error::Tag '$REF_NAME' is neither vX.Y.Z nor beta-vX.Y.Z-<suffix>, so no package release can be derived"
exit 1
fi
if [[ ! "$RELEASE" =~ ^[A-Za-z0-9.]+$ ]]; then
echo "::error::Derived package release '$RELEASE' contains characters rpm and dpkg do not accept"
exit 1
fi
echo "Linux package release for $REF_NAME: $RELEASE"
echo "LINUX_APP_RELEASE=$RELEASE" >> "$GITHUB_ENV"
- name: Build Fedora package
run: |
./gradlew -S :app-desktop:packageRpm -PlinuxAppRelease="$LINUX_APP_RELEASE"
./gradlew -S :app-desktop:packageRpm
mkdir -p dist
RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then
@@ -411,7 +195,7 @@ jobs:
mv "$RPM" "dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm"
- name: Upload Fedora artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: linux-fedora
path: dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm
@@ -419,7 +203,6 @@ jobs:
build-linux-arch:
name: Build Arch Linux package
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
@@ -514,7 +297,7 @@ jobs:
tar --zstd --transform 's|^\./||' -cf "dist/KST4Contest-${{ github.ref_name }}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" .
- name: Upload Arch Linux artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: linux-arch
path: dist/KST4Contest-${{ github.ref_name }}-archlinux-*.pkg.tar.zst
@@ -522,7 +305,6 @@ jobs:
build-flatpak:
name: Build Flatpak
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
@@ -634,13 +416,13 @@ jobs:
EOF
- name: Upload flatpakref
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: flatpakref
path: dist/*.flatpakref
- name: Upload Flatpak OSTree repo
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: flatpak-ostree-repo
path: target/flatpak-repo/
@@ -648,7 +430,6 @@ jobs:
build-macos-dmg:
name: Build macOS DMG (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: test
strategy:
fail-fast: false
matrix:
@@ -704,7 +485,7 @@ jobs:
mv "$DMG" "dist/KST4Contest-${{ github.ref_name }}-macos-${ARCH}.dmg"
- name: Upload macOS artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: macos-dmg-${{ matrix.os }}
path: dist/KST4Contest-${{ github.ref_name }}-macos-*.dmg
@@ -781,7 +562,7 @@ jobs:
github_docs/de-Changelog.md
- name: Upload PDF artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: docs-pdf
path: dist/KST4Contest-${{ github.ref_name }}-manual-*.pdf
@@ -806,7 +587,7 @@ jobs:
echo "FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')" >> "$GITHUB_ENV"
- name: Download OSTree repo artifact
uses: actions/download-artifact@v8.0.2
uses: actions/download-artifact@v8.0.1
with:
name: flatpak-ostree-repo
path: flatpak-ostree-repo/
@@ -867,9 +648,7 @@ jobs:
fetch-depth: 0
# GitHub's generated release notes list merged pull requests only. Commits
# pushed directly to main are added here as their own section, and the
# message of an annotated tag opens the body:
# <tag message> (annotated tags only; signature dropped)
# pushed directly to main are added here as their own section:
# ## What's Changed
# ### Commits on main (first-parent commits, no merges, no bots)
# ### Merged PRs (GitHub's generated PR list)
@@ -894,19 +673,6 @@ jobs:
RANGE="$TAG"
[ -n "$PREVIOUS_TAG" ] && RANGE="${PREVIOUS_TAG}..${TAG}"
# actions/checkout can leave the triggering tag as a lightweight ref, so
# fetch the tag object itself before reading its message.
git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"
# A lightweight tag has no message of its own (its "message" would be the
# commit's); only an annotated tag contributes. contents:subject and
# contents:body leave out an SSH or GPG signature.
: > tag-message.md
if [ "$(git cat-file -t "refs/tags/${TAG}")" = "tag" ]; then
git for-each-ref "refs/tags/${TAG}" \
--format='%(contents:subject)%0a%0a%(contents:body)' > tag-message.md
fi
echo "Tag message:"; cat tag-message.md
# --first-parent keeps the commits made directly on main; commits inside
# pull requests are covered by the PR list. Squash-merged PRs end in
# "(#123)" and are listed by GitHub as well.
@@ -928,7 +694,6 @@ jobs:
import re
commits = open("direct-commits.md", encoding="utf-8").read().strip()
tag_message = open("tag-message.md", encoding="utf-8").read().strip()
generated = open("generated-notes.md", encoding="utf-8").read()
# GitHub's body: "## What's Changed" with the PR list, optionally
@@ -948,8 +713,6 @@ jobs:
rest.append(line)
parts = []
if tag_message:
parts.append(tag_message)
if commits or prs:
parts.append("## What's Changed")
if commits:
@@ -966,57 +729,50 @@ jobs:
cat release-notes.md
- name: Download Windows artifact
uses: actions/download-artifact@v8.0.2
uses: actions/download-artifact@v8.0.1
with:
name: windows-zip
path: release-assets/windows
- name: Download Windows MSI artifact
if: ${{ !startsWith(github.ref_name, 'beta-') }}
uses: actions/download-artifact@v8.0.2
with:
name: windows-msi
path: release-assets/windows
- name: Download Linux artifact
uses: actions/download-artifact@v8.0.2
uses: actions/download-artifact@v8.0.1
with:
name: linux-appimage
path: release-assets/linux
- name: Download macOS artifacts
uses: actions/download-artifact@v8.0.2
uses: actions/download-artifact@v8.0.1
with:
pattern: macos-dmg-*
merge-multiple: true
path: release-assets/macos
- name: Download Debian artifact
uses: actions/download-artifact@v8.0.2
uses: actions/download-artifact@v8.0.1
with:
name: linux-debian
path: release-assets/debian
- name: Download Fedora artifact
uses: actions/download-artifact@v8.0.2
uses: actions/download-artifact@v8.0.1
with:
name: linux-fedora
path: release-assets/fedora
- name: Download Arch Linux artifact
uses: actions/download-artifact@v8.0.2
uses: actions/download-artifact@v8.0.1
with:
name: linux-arch
path: release-assets/archlinux
- name: Download flatpakref
uses: actions/download-artifact@v8.0.2
uses: actions/download-artifact@v8.0.1
with:
name: flatpakref
path: release-assets/flatpakref
- name: Download PDF manuals
uses: actions/download-artifact@v8.0.2
uses: actions/download-artifact@v8.0.1
with:
name: docs-pdf
path: release-assets/docs
@@ -1033,12 +789,8 @@ jobs:
makeLatest: ${{ !startsWith(github.ref_name, 'beta-') }}
bodyFile: release-notes.md
generateReleaseNotes: false
# The MSI entry is absent for beta tags (the Windows job skips it);
# artifactErrorsFailBuild is left at its default, so a missing path
# only warns and the release still gets every other asset.
artifacts: >-
release-assets/windows/praktiKST-${{ github.ref_name }}-windows-x64.zip,
release-assets/windows/KST4Contest-${{ github.ref_name }}-windows-x64.msi,
release-assets/linux/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage,
release-assets/debian/KST4Contest-${{ github.ref_name }}-debian-amd64.deb,
release-assets/fedora/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm,
@@ -1052,7 +804,7 @@ jobs:
# release. Otherwise the Releases API cannot return the release notes
# belonging to the tag which triggered this workflow.
- name: Set up Node.js for website build
uses: actions/setup-node@v7
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
@@ -1083,7 +835,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload verified website artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: kst4contest-website-${{ github.ref_name }}
path: website/_site/
-3
View File
@@ -65,6 +65,3 @@ scratch/
# Kotlin incremental build data
.kotlin/
# JVM crash reports
hs_err_pid*.log
+3 -3
View File
@@ -124,12 +124,12 @@ Read the current `gradle.properties` and `gradle/libs.versions.toml` before rely
At this context snapshot the project uses Java 21, Kotlin 2.2 and Compose Multiplatform 1.8.x, with JUnit 5/Mockito, PMD and SpotBugs integrated. JavaFX is gone; there is no `org.openjfx` dependency left.
Important: a test failure fails the build, but PMD and SpotBugs run with `ignoreFailures`, so static-analysis findings do not. Test classes live under `src/test` only (since #62; before that a few sat under `src/main` and were never run), and JUnit/Mockito are test-only dependencies. 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 Gradle test summary;
- inspect the test-results XML under `*/build/test-results/` when needed;
- do not infer "all tests passed" from exit code 0 -- read the XML;
- report PMD/SpotBugs findings that are visible in the relevant build. Both tools write XML and HTML: `<module>/build/reports/pmd/main.{xml,html}` and `<module>/build/reports/spotbugs/main.{xml,html}` (since #110; before that SpotBugs printed to the console only). Count from the XML (`<violation`, `<BugInstance`), not from the console. The PR workflow runs `pmdMain spotbugsMain`, prints the counts per module into the job summary and publishes the reports as the `static-analysis` artifact.
- do not infer "all tests passed" from exit code 0;
- report PMD/SpotBugs findings that are visible in the relevant build.
Run focused tests first, then normally the relevant broader test/build command for the scope.
-9
View File
@@ -94,12 +94,3 @@ Windows:
## License
KST4Contest is distributed under the [GNU General Public License v3.0](LICENSE).
## Supporters/Sponsor
This Project is possible due to donations of the community.
You can sponsor KST4Contest here: [Support on Ko-Fi](https://ko-fi.com/praktimarc). If you add an Display Name there you will be mentioned here.
The following people are sponsoring KST4Contest:
- Marco (DJ5KW)
+8 -50
View File
@@ -10,8 +10,10 @@ kotlin {
}
dependencies {
// JUnit and Mockito are test-only. Until #62 a few test classes sat under src/main/java
// and dragged both -- plus byte-buddy and objenesis -- into lib/app of every app image.
// Mirrors the compile-scope entries in pom.xml: a few test classes still 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(compose.desktop.currentOs)
@@ -66,14 +68,11 @@ val generateStrings by tasks.registering(JavaExec::class) {
mainClass.set("kst4contest.i18n.GeneratorMain")
val input = layout.projectDirectory.dir("src/main/i18n")
// A local copy: the provider below must not capture the script-level val, which
// drags the build script object into the configuration cache and fails to serialize.
val output = generatedI18nDirectory
inputs.dir(input)
outputs.dir(output)
outputs.dir(generatedI18nDirectory)
argumentProviders.add(CommandLineArgumentProvider {
listOf(input.asFile.absolutePath, output.get().asFile.absolutePath)
listOf(input.asFile.absolutePath, generatedI18nDirectory.get().asFile.absolutePath)
})
}
@@ -146,49 +145,8 @@ compose.desktop {
"jdk.xml.dom", "jdk.unsupported",
)
linux {
iconFile.set(rootProject.file("packaging/icons/kst4contest.png"))
/*
* Package release number behind the version (1.50.0-<release>).
* The version itself is composePackageVersion for every target
* format, so a beta DEB/RPM would otherwise carry exactly the
* version of the following stable one -- and dnf, rpm -U and
* dpkg refuse to replace a package by one of equal version, so
* a beta user could not move to the stable (#113). The tagged
* release workflow therefore passes -PlinuxAppRelease following
* the Fedora pre-release convention:
*
* beta-v1.50.0-beta-001 -> 1.50.0-0.beta.001
* beta-v1.50.0-rc02 -> 1.50.0-0.rc02
* v1.50.0 -> 1.50.0-1
*
* Everything starting with "0." sorts below "1" for both rpm
* and dpkg. Nightlies and local builds do not set the property
* and keep release 1.
*/
appRelease = providers.gradleProperty("linuxAppRelease").orElse("1").get()
}
windows {
iconFile.set(rootProject.file("packaging/icons/kst4contest.ico"))
/*
* The MSI installer is built for tagged releases only; nightlies ship
* the portable ZIP alone, because an MSI always carries the plain
* composePackageVersion and two nightly MSIs could not be told apart.
* It installs per user, so it needs neither administrator rights nor
* a UAC prompt, and it adds a Start menu group and a desktop shortcut.
*
* upgradeUuid must NEVER change: Windows Installer uses it to
* recognise an installed KST4Contest and replace it in place when a
* newer MSI runs. A different value would install the new release
* side by side with the old one instead of upgrading it.
*/
upgradeUuid = "3375071a-25ba-468e-9ae5-8296bc535462"
menu = true
menuGroup = "KST4Contest"
shortcut = true
perUserInstall = true
dirChooser = true
}
linux { iconFile.set(rootProject.file("packaging/icons/kst4contest.png")) }
windows { iconFile.set(rootProject.file("packaging/icons/kst4contest.ico")) }
macOS {
iconFile.set(rootProject.file("packaging/icons/kst4contest.icns"))
// Must stay de.x08.KST4Contest: build-signed-dmg.sh signs against it.
@@ -1,5 +1,5 @@
# German. Held complete by a test, because this is a shipped language.
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken nach dem Übernehmen. Nach einer Änderung des UDP-Ports neu verbinden.
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken sofort. Nach einer Änderung des UDP-Ports neu verbinden.
airscout.automaticFrequency=AirScout-Frequenz je Station automatisch wählen
airscout.automaticHint=Die automatische Wahl nimmt zuerst die aktuelle QRG der Station, dann Hinweise aus Stationsname und Chat-Kategorie. Nur abschalten, um für jede Station einen festen Protokollwert zu erzwingen.
airscout.clientIdentifier=KST4Contest-Clientkennung [KST]:
@@ -26,7 +26,6 @@ gui.language=Sprache
gui.language.system=Systemsprache
gui.startupDesign=Design beim Start
gui.startupFilters=Beim Start aktive Nachrichtenfilter
gui.windows=Fenster
logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen
logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe)
logSynch.fileLabel=Zu überwachende Logdatei:
@@ -89,13 +88,6 @@ notification.soundsSection=Töne
notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen
notification.spotterCallsign=Spotter-Rufzeichen
settings.applyAndClose=Übernehmen/Schließen
settings.applyFailed=Einige Einstellungen konnten nicht übernommen werden oder haben die laufende Sitzung nicht erreicht.\n\n(Technische Details: {0})
settings.pendingApply=Übernehmen
settings.pendingBack=Zurück zu den Einstellungen
settings.pendingDiscard=Verwerfen
settings.pendingQuestion=Einige Einstellungen wurden geändert, aber noch nicht übernommen. Zuerst übernehmen?
settings.pendingTitle=Nicht übernommene Änderungen
settings.invalidEntry={0}: „{1}" ist keine gültige Eingabe.
settings.save=Einstellungen speichern
shortcuts.addButton=Kurztaste hinzufügen
shortcuts.addSnippet=Neuen Textbaustein hinzufügen
@@ -107,7 +99,6 @@ station.callsign=Rufzeichen
station.chatSection=Chat-Kategorie
station.defaultsSection=Vorgaben
station.demDirectory=DEM-Stammverzeichnis
station.invalidOwnHeightAsl=„{0}" ist keine gültige Höhe. Meter von -500 bis 9000 eingeben oder das Feld leer lassen, dann gilt das Höhenmodell.
station.locator=Locator
station.mainCategory=Hauptkategorie
station.maxQrb=Maximaler QRB
@@ -115,7 +106,6 @@ station.nameMain=Name, Hauptkategorie
station.nameSecond=Name, zweite Kategorie
station.ownAntennaGain=Eigener Antennengewinn, dBi
station.ownAntennaHeight=Eigene Antennenhöhe, Meter
station.ownHeightAsl=Eigene Standorthöhe über NN, Meter
station.ownSection=Eigene Station
station.ownTxPower=Eigene Sendeleistung, Watt
station.password=Passwort
@@ -125,31 +115,21 @@ station.rotatorEnabled=Rotorabgleich aktiv
station.rotatorHost=Rotor-Host
station.rotatorPort=Rotor-Port
station.rotatorSection=PSTRotator
station.rotator2Enabled=Rotor-2-Abgleich aktiv
station.rotator2Host=Rotor-2-Host
station.rotator2Port=Rotor-2-Port (antwortet auf Port + 1)
station.rotator2PortClash=Port {0} ist derselbe oder ein benachbarter Port wie beim anderen PSTRotator
station.rotator2Section=PSTRotator 2 (SO2R)
station.secondCategory=Zweite Kategorie
station.secondChatEnabled=2. Chat: in einer zweiten Kategorie anmelden
station.serverHost=Servername
station.serverPort=Serverport
station.serverSection=ON4KST-Server
station.targetAntennaGain=Antennengewinn der Gegenstation, dBi
station.targetHighestPoint=Gegenstation auf dem höchsten Punkt ihres Locatorfelds
station.targetTxPower=Sendeleistung der Gegenstation, Watt
trxSynch.hint=Eine aktivierte Quelle erzeugt von sich aus keine Frequenz. Die eigene QRG wird erst aktualisiert, wenn tatsächlich gültige RadioInfo- oder Win-Test-STATUS-Daten eintreffen; bis dahin bleibt sie so, wie sie von Hand eingetragen wurde.
trxSynch.hintSection=Was eine aktivierte Quelle bewirkt
trxSynch.so2rEnabled=SO2R: zwei Geräte getrennt führen (Gerät 2 schreibt SECONDQRG)
trxSynch.so2rHint=N1MM, DXLog und UCXLog werden über RadioNr zugeordnet; dafür ist nichts einzustellen. Win-Test braucht je Gerät einen Stationsnamen.
trxSynch.so2rSection=SO2R
trxSynch.ucxEnabled=MYQRG aus RadioInfo-Nachrichten auf dem gemeinsamen Log-Sync-Port aktualisieren
trxSynch.ucxSection=UCXLog-TRX-Infos empfangen
trxSynch.winTestEnabled=Win-Test-STATUS-QRG-Abgleich (aktualisiert die eigene QRG aus der Win-Test-Transceiverfrequenz)
trxSynch.winTestPassFrequency=Durchgangsfrequenz aus Win-Test-STATUS verwenden (statt der eigenen QRG)
trxSynch.winTestSection=Win-Test-TRX-Abgleich
trxSynch.winTestStationFilter=Win-Test-Stationsnamenfilter (z. B. STN1, leer = alle annehmen)
trxSynch.winTestStationRadio2=Win-Test-Stationsname von Gerät 2 (z. B. STN2)
worked.empty=Die Datenbank enthält keine Rufzeichen.
worked.expiryHint=Worked-, NOT-QRV- und Feldangaben verfallen nach drei Tagen von selbst. Ein Zurücksetzen vor jedem Contest ist normalerweise nicht nötig.
worked.lastResetAffected=Das letzte Zurücksetzen betraf {0} Zeilen.
@@ -248,7 +228,6 @@ column.lastQrgTx=Letzte QRG TX
filter.find=Suchen...
filter.reset=Filter zurücksetzen
filter.showOnlyQrb=Nur QRB [km] <=
filter.showOnlyQrbAbove=Nur QRB [km] >
filter.showOnlyQtf=Nur QTF:
filter.hideWorked=Gearbeitete ausblenden:
filter.inactiveStations=Inaktive Stationen
@@ -274,8 +253,6 @@ selected.lookUpAndShow=Nachschlagen und zeigen
selected.lookupQrz=Auf qrz.com nachschlagen
selected.lookupQrzcq=Auf qrzcq.com nachschlagen
selected.showPathInAs=Strecke in AS zeigen
selected.turnAntenna=Antenne 1 Richtung {0} drehen
selected.turnAntenna2=Antenne 2 Richtung {0} drehen
selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.)
candidates.title=Stationen mit höchster Priorität
candidates.none=Noch keine Kandidaten.
@@ -345,8 +322,6 @@ title.connecting=Verbindung zu ON4KST wird aufgebaut…
title.authenticating=Verbunden – Anmeldung bei ON4KST läuft…
title.synchronizing=Verbunden – ON4KST-Chatdaten werden abgeglichen…
title.disconnected=NICHT VERBUNDEN!
title.online=Verbunden mit: {0} als {1} ({2}) in {3} ({4} Benutzer online, {5} angezeigt), {6} Nachrichten insgesamt.
title.bothCategories={0} und {1}
status.noConnection=Keine ON4KST-Verbindung
status.linkTooltip=ON4KST-Verbindung: {0}\n{1}
status.linkConnected=🟢 LINK: Verbunden
@@ -386,12 +361,10 @@ tooltip.unknownGrid=unbekanntes Feld
tooltip.grid=Feld {0}
messages.public=Öffentliche Nachrichten
messages.dxCluster=DXCluster-Nachrichten
messages.qsoOfTheOther=Fremd-QSOs
messages.qsoOfTheOther=QSO der Gegenstation
chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie
chat.secondQrgTooltip=Die Frequenz für die zweite Chat-Kategorie von Hand eintragen
chat.qtfTooltip=Die eigene Antennenrichtung (QTF); wird vom Rotor übernommen, wenn einer abgeglichen wird
chat.qtf2Tooltip=Richtung von Antenne 2 (Rotor 2, SO2R); nur Anzeige
chat.secondQrgTooltipSo2r=QRG von Gerät 2 (aus RadioNr 2 bzw. Win-Test-Station 2)
list.moveDown=Auswahl nach unten
list.moveUp=Auswahl nach oben
list.clearToRemove=Den Text eines Eintrags leeren, um ihn zu entfernen.
@@ -405,50 +378,3 @@ contrast.textOnWindowSurface=Text auf der Fensterfläche
contrast.textInsideField=Text im Feld
contrast.accentOnWindowSurface=Akzent auf der Fensterfläche
contrast.textOnMenuStrip=Text auf der Menüzeile
gui.startInEveningDesign=Im Abenddesign starten
gui.filterPublic=Öffentliche Nachrichten
gui.filterPmToMe=Privatnachrichten an mich
gui.filterPmToOthers=Privatnachrichten an andere
gui.filterNothing=Kein Filter
gui.hintFreshCall=Ein zum ersten Mal gesehenes Rufzeichen markieren
gui.hintLargeGridWorked=Ein gearbeitetes Großfeld markieren
gui.rememberWindowPositions=Fenster nach einem Neustart dort öffnen, wohin sie zuletzt geschoben wurden (nur auf demselben Bildschirm)
form.choose=Wählen...
form.notSelected=nicht gewählt
dialog.apply=Übernehmen
chat.clear=leeren
selected.showOnMap=🗺 Auf der Karte zeigen
selected.notQrvAll=alle
column.category=Kategorie
column.sender=Absender
column.receiver=Empfänger
column.lastQrg=Letzte QRG
column.airplane=AP [Minuten / Pot%]
column.score=Wertung
monitor.title=Cluster & Fremd-QSOs
profiles.createFailed=Das Profil konnte nicht angelegt werden. Die Profilliste konnte nicht geschrieben werden.
profiles.createdWithoutCredentials=Das Profil „{0}" wurde ohne Rufzeichen und Passwort angelegt. Nach dem Wechsel zu diesem Profil beides im Reiter „Station" eintragen.
profiles.duplicateFailed=Das Profil konnte nicht dupliziert werden.
profiles.rootNotDeletable=Das Standardprofil verwendet die Dateien der Installation selbst und kann nicht gelöscht werden.
profiles.activeNotDeletable=Das gerade benutzte Profil kann nicht gelöscht werden. Zuerst zu einem anderen Profil wechseln.
profiles.deleteFailed=Das Profil konnte nicht gelöscht werden.
profiles.deletionFolder=Der folgende Ordner wird endgültig entfernt:\n{0}
profiles.deletionKeepsShared=Die gemeinsamen gearbeiteten Stationen der Station werden nicht angefasst.
profiles.deletionRemovesOwn=Die gearbeiteten Stationen dieses Profils werden ebenfalls gelöscht.
profiles.rootAlwaysShared=Das Standardprofil verwendet immer die gemeinsamen gearbeiteten Stationen der Station, weil diese Datenbank die der Installation selbst ist.
profiles.changeAfterSwitch=Die Änderung wirkt erst nach einem erneuten Wechsel zu diesem Profil.
profiles.alreadyActive=Dieses Profil ist bereits aktiv.
profiles.selectFirst=Zuerst ein Profil in der Tabelle wählen.
beacon.invalidMessage=Der Bakentext ist ungültig: {0}
beacon.intervalTooShort=Einen ganzzahligen Bakenabstand von mindestens {0} Minute eingeben.
notification.invalidPort=„{0}" ist kein gültiger TCP-Port. Einen Wert von 1 bis 65535 eingeben.
notification.invalidSpotter=„{0}" ist kein gültiges Spotter-Rufzeichen.
notification.invalidCallsign=Bitte ein gültiges Rufzeichen eingeben.
notification.alreadyMonitored=Dieses Grundrufzeichen steht bereits in der Überwachungsliste.
settings.windowTitle=Client-Einstellungen ändern
settings.noticeInfo=Info
settings.noticeProblem=Einstellungen
profiles.unknown=unbekannt
profiles.copyOf=Kopie von {0}
profiles.sharedDatabase=gemeinsame Stationsdatenbank
profiles.ownDatabase=eigene Datenbank
@@ -8,7 +8,7 @@
#
# Nothing that goes to the ON4KST server or to other radio amateurs belongs in this file --
# none of it is here, and three tests keep it that way.
airscout.applyHint=Server identifier, client identifier and frequency mode take effect when applied. Reconnect after changing the UDP port.
airscout.applyHint=Server identifier, client identifier and frequency mode are applied immediately. Reconnect after changing the UDP port.
airscout.automaticFrequency=Select AirScout frequency automatically per station
airscout.automaticHint=Automatic selection uses the station's current QRG first, then station-name and chat-category evidence. Disable it only to force one protocol value for every station.
airscout.clientIdentifier=KST4Contest client identifier [KST]:
@@ -35,7 +35,6 @@ gui.language=Language
gui.language.system=System language
gui.startupDesign=Startup design
gui.startupFilters=Message filters active at startup
gui.windows=Windows
logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File
logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information)
logSynch.fileLabel=Log file to be monitored:
@@ -98,13 +97,6 @@ notification.soundsSection=Sounds
notification.speakCallsign=Speak the callsign on a received private message
notification.spotterCallsign=Spotter callsign
settings.applyAndClose=Apply/Close prefs
settings.applyFailed=Some settings could not be applied, or their change did not reach the running session.\n\n(Technical details: {0})
settings.pendingApply=Apply
settings.pendingBack=Back to the settings
settings.pendingDiscard=Discard
settings.pendingQuestion=Some settings have been changed but not applied yet. Apply them first?
settings.pendingTitle=Changes not applied
settings.invalidEntry={0}: "{1}" is not a valid entry.
settings.save=Save settings
shortcuts.addButton=Add shortcut
shortcuts.addSnippet=Add new snippet
@@ -116,7 +108,6 @@ station.callsign=Callsign
station.chatSection=Chat category
station.defaultsSection=Defaults
station.demDirectory=DEM root directory
station.invalidOwnHeightAsl="{0}" is not a valid height. Enter metres from -500 to 9000, or leave the field empty to use the elevation model.
station.locator=Locator
station.mainCategory=Main category
station.maxQrb=Maximum QRB
@@ -124,7 +115,6 @@ station.nameMain=Name, main category
station.nameSecond=Name, second category
station.ownAntennaGain=Own antenna gain, dBi
station.ownAntennaHeight=Own antenna height, metres
station.ownHeightAsl=Own ground height ASL, metres
station.ownSection=Own station
station.ownTxPower=Own TX power, watts
station.password=Password
@@ -134,31 +124,21 @@ station.rotatorEnabled=Rotator sync enabled
station.rotatorHost=Rotator host
station.rotatorPort=Rotator port
station.rotatorSection=PSTRotator
station.rotator2Enabled=Rotator 2 sync enabled
station.rotator2Host=Rotator 2 host
station.rotator2Port=Rotator 2 port (answers on port + 1)
station.rotator2PortClash=Port {0} is the same as or next to the port of the other PSTRotator
station.rotator2Section=PSTRotator 2 (SO2R)
station.secondCategory=Second category
station.secondChatEnabled=2nd chat: log in to a second category
station.serverHost=Server host name
station.serverPort=Server port
station.serverSection=ON4KST server
station.targetAntennaGain=Target antenna gain, dBi
station.targetHighestPoint=Target station on the highest point of its locator square
station.targetTxPower=Target TX power, watts
trxSynch.hint=An enabled source does not produce a frequency by itself. The own QRG is only updated once valid RadioInfo or Win-Test STATUS data actually arrives; until then it stays as entered by hand.
trxSynch.hintSection=What an enabled source does
trxSynch.so2rEnabled=SO2R: keep two radios apart (radio 2 writes SECONDQRG)
trxSynch.so2rHint=N1MM, DXLog and UCXLog are assigned by RadioNr; nothing to set. Win-Test needs one station name per radio.
trxSynch.so2rSection=SO2R
trxSynch.ucxEnabled=Update MYQRG from RadioInfo messages received on the shared log-sync port
trxSynch.ucxSection=Receive UCXLog TRX info
trxSynch.winTestEnabled=Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)
trxSynch.winTestPassFrequency=Use pass frequency from Win-Test STATUS (instead of own QRG)
trxSynch.winTestSection=Win-Test TRX sync
trxSynch.winTestStationFilter=Win-Test station name filter (e.g. STN1, empty = accept all)
trxSynch.winTestStationRadio2=Win-Test station name of radio 2 (e.g. STN2)
worked.empty=The worked database holds no callsigns.
worked.expiryHint=Worked, NOT-QRV and grid data expire after three days on their own. A reset before every contest is normally not required.
worked.lastResetAffected=Last reset affected {0} rows.
@@ -257,7 +237,6 @@ column.lastQrgTx=Last QRG TX
filter.find=Find...
filter.reset=Reset filters
filter.showOnlyQrb=Show only QRB [km] <=
filter.showOnlyQrbAbove=Show only QRB [km] >
filter.showOnlyQtf=Show only QTF:
filter.hideWorked=Hide worked:
filter.inactiveStations=Inactive stations
@@ -283,8 +262,6 @@ selected.lookUpAndShow=Look up and show
selected.lookupQrz=Lookup on qrz.com
selected.lookupQrzcq=Lookup on qrzcq.com
selected.showPathInAs=Show path in AS
selected.turnAntenna=Turn ant1 to {0}
selected.turnAntenna2=Turn ant2 to {0}
selected.lastActivity=Last activity: {0} ({1} min ago)
candidates.title=Top priority candidates
candidates.none=No candidates yet.
@@ -354,8 +331,6 @@ title.connecting=Connecting to ON4KST…
title.authenticating=Connected – authenticating with ON4KST…
title.synchronizing=Connected – synchronizing ON4KST chat data…
title.disconnected=DISCONNECTED!
title.online=Connected to: {0} as {1} ({2}) in {3} ({4} users online, {5} shown), {6} messages total.
title.bothCategories={0} and {1}
status.noConnection=No ON4KST connection
status.linkTooltip=ON4KST link: {0}\n{1}
status.linkConnected=🟢 LINK: Connected
@@ -399,8 +374,6 @@ messages.qsoOfTheOther=QSO of the other
chat.ownQrgTooltip=Your own frequency in the main category
chat.secondQrgTooltip=Enter the frequency for the second chat category by hand
chat.qtfTooltip=Your antenna heading (QTF); read from the rotator when one is synced
chat.qtf2Tooltip=Heading of antenna 2 (rotator 2, SO2R); display only
chat.secondQrgTooltipSo2r=QRG of radio 2 (from RadioNr 2 or Win-Test station 2)
list.moveDown=Move selected down
list.moveUp=Move selected up
list.clearToRemove=Clear the text of an entry to remove it.
@@ -414,50 +387,3 @@ contrast.textOnWindowSurface=Text on the window surface
contrast.textInsideField=Text inside a field
contrast.accentOnWindowSurface=Accent on the window surface
contrast.textOnMenuStrip=Text on the menu strip
gui.startInEveningDesign=Start in the evening design
gui.filterPublic=Public messages
gui.filterPmToMe=Private messages to me
gui.filterPmToOthers=Private messages to others
gui.filterNothing=No filter
gui.hintFreshCall=Mark a callsign seen for the first time
gui.hintLargeGridWorked=Mark a worked large grid field
gui.rememberWindowPositions=Reopen windows across restarts where they were last moved to (same screen only)
form.choose=Choose...
form.notSelected=not selected
dialog.apply=Apply
chat.clear=clear
selected.showOnMap=🗺 Show on map
selected.notQrvAll=all
column.category=Category
column.sender=Sender
column.receiver=Receiver
column.lastQrg=Last QRG
column.airplane=AP [minutes / pot%]
column.score=Score
monitor.title=Cluster & QSO of the other
profiles.createFailed=The profile could not be created. The profile registry could not be written.
profiles.createdWithoutCredentials=The profile "{0}" was created without callsign and password. Enter them on the Station tab after switching to it.
profiles.duplicateFailed=The profile could not be duplicated.
profiles.rootNotDeletable=The default profile uses the files of the installation itself and cannot be deleted.
profiles.activeNotDeletable=The profile currently in use cannot be deleted. Switch to another profile first.
profiles.deleteFailed=The profile could not be deleted.
profiles.deletionFolder=The following folder is removed permanently:\n{0}
profiles.deletionKeepsShared=The common station worked stations are not touched.
profiles.deletionRemovesOwn=The worked stations of this profile are deleted as well.
profiles.rootAlwaysShared=The default profile always uses the common station worked stations, because that database is the one of the installation itself.
profiles.changeAfterSwitch=The change takes effect after switching to this profile again.
profiles.alreadyActive=This profile is already active.
profiles.selectFirst=Select a profile in the table first.
beacon.invalidMessage=The beacon message is invalid: {0}
beacon.intervalTooShort=Enter a whole beacon interval of at least {0} minute.
notification.invalidPort="{0}" is not a valid TCP port. Enter a value from 1 to 65535.
notification.invalidSpotter="{0}" is not a valid spotter callsign.
notification.invalidCallsign=Please enter a valid callsign.
notification.alreadyMonitored=This base callsign is already in the monitoring list.
settings.windowTitle=Change Client Settings
settings.noticeInfo=Info
settings.noticeProblem=Settings
profiles.unknown=unknown
profiles.copyOf=Copy of {0}
profiles.sharedDatabase=common station database
profiles.ownDatabase=own database
@@ -18,8 +18,7 @@ class ChatPreferencesStationMapVisibilityTest {
@Test
void pathAnalysisIsVisibleByDefault() {
ChatPreferences preferences = new ChatPreferences(
temporaryDirectory.resolve("preferences.xml"));
ChatPreferences preferences = new ChatPreferences();
assertTrue(preferences.isGUIstationMapPathAnalysisVisible());
}
@@ -28,7 +27,9 @@ class ChatPreferencesStationMapVisibilityTest {
void hiddenPathAnalysisStateSurvivesXmlRoundTrip() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
ChatPreferences writtenPreferences = new ChatPreferences(preferencesFile);
ChatPreferences writtenPreferences = new ChatPreferences();
writtenPreferences.setStoreAndRestorePreferencesFileName(
preferencesFile.toString());
writtenPreferences.setGUIstationMapPathAnalysisVisible(false);
writtenPreferences.writePreferencesToXmlFile();
@@ -37,7 +38,9 @@ class ChatPreferencesStationMapVisibilityTest {
"<GUIstationMapPathAnalysisVisible>false"
+ "</GUIstationMapPathAnalysisVisible>"));
ChatPreferences restoredPreferences = new ChatPreferences(preferencesFile);
ChatPreferences restoredPreferences = new ChatPreferences();
restoredPreferences.setStoreAndRestorePreferencesFileName(
preferencesFile.toString());
restoredPreferences.readPreferencesFromXmlFile();
assertFalse(restoredPreferences.isGUIstationMapPathAnalysisVisible());
@@ -60,8 +63,9 @@ class ChatPreferencesStationMapVisibilityTest {
</praktiKST>
""");
ChatPreferences restoredPreferences =
new ChatPreferences(legacyPreferencesFile);
ChatPreferences restoredPreferences = new ChatPreferences();
restoredPreferences.setStoreAndRestorePreferencesFileName(
legacyPreferencesFile.toString());
restoredPreferences.readPreferencesFromXmlFile();
assertTrue(restoredPreferences.isGUIstationMapPathAnalysisVisible());
@@ -0,0 +1,131 @@
package kst4contest.test;
import kst4contest.observe.SimpleValue;
import kst4contest.controller.Utils4KST;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class PatternMatcherTest {
/**
* Tests if pattern matches with the given String.
*
* @param testString
* @param regExPattern
* @return true if match, else false
*/
private static boolean testPattern(String testString, String regExPattern) {
Pattern pattern = Pattern.compile(regExPattern);
Matcher matcher = pattern.matcher(testString);
return matcher.find();
}
/**
* Normalizes a chatmembers frequency-string for cluster usage<br/>
* <b>returns a frequency String in KHz like = "144300" or "144300.0" to match DXC protocol needs</b>
*
* @param optionalPrefix: if there is a value like ".300", it have to be decided, wich ".300": 144.300, 432.300, 1296.300 .... prefix means for example "144."
*/
private static String normalizeFrequencyString(String qrgString, String optionalPrefix) {
// final String PTRN_QRG_CAT2 = "(([0-9]{3,4}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)|(([a-zA-Z][0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)|((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)";
final String PTRN_QRG_CAT2_wholeQRGMHz4Digits = "(([0-9]{4}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)"; //1296.300.3 etc
final String PTRN_QRG_CAT2_wholeQRGMHz3Digits = "(([0-9]{3}[\\.|,| ]?[0-9]{3})([\\.][\\d]{1,2})?)"; //144.300.3 etc
final String PTRN_QRG_CAT2_QRGwithoutPrefix = "((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)"; //144.300.3 etc
String predefinedPrefixInMHz = optionalPrefix;
String stringAggregation = "";
if (testPattern(qrgString, PTRN_QRG_CAT2_wholeQRGMHz4Digits)) {
System.out.print("yep: ");
stringAggregation = qrgString;
stringAggregation = stringAggregation.replace(".","");
stringAggregation = stringAggregation.replace(",","");
if (stringAggregation.length() == 8) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-1) + "." + stringAggregation.substring(stringAggregation.length()-1, stringAggregation.length());
stringAggregation = stringAggregationNew;
} else if (stringAggregation.length() == 9) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-2) + "." + stringAggregation.substring(stringAggregation.length()-2, stringAggregation.length());
stringAggregation = stringAggregationNew;
}
} else
if (testPattern(qrgString, PTRN_QRG_CAT2_wholeQRGMHz3Digits)) {
System.out.print("yep: ");
stringAggregation = qrgString;
stringAggregation = stringAggregation.replace(".","");
stringAggregation = stringAggregation.replace(",","");
if (stringAggregation.length() == 7) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-1) + "." + stringAggregation.substring(stringAggregation.length()-1, stringAggregation.length());
stringAggregation = stringAggregationNew;
} else if (stringAggregation.length() == 8) {
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-2) + "." + stringAggregation.substring(stringAggregation.length()-2, stringAggregation.length());
stringAggregation = stringAggregationNew;
}
}
else
if (testPattern(qrgString, PTRN_QRG_CAT2_QRGwithoutPrefix)) { //case ".050 or .300 or something like that"
System.out.print("yep: ");
stringAggregation = qrgString;
stringAggregation = stringAggregation.replace(".", "");
stringAggregation = stringAggregation.replace(",", "");
if (stringAggregation.length() == 3) { // like 050 or 300
String stringAggregationNew = optionalPrefix + stringAggregation;
stringAggregation = stringAggregationNew;
return stringAggregation;
} else if (stringAggregation.length() == 4) { //like 050.2 --> 0502
stringAggregation = optionalPrefix + stringAggregation;
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length() - 1) + "." + stringAggregation.substring(stringAggregation.length() - 1, stringAggregation.length());
stringAggregation = stringAggregationNew;
return stringAggregation;
} else if (stringAggregation.length() == 5) { //like 050.20 --> 05020
stringAggregation = optionalPrefix + stringAggregation;
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length() - 2) + "." + stringAggregation.substring(stringAggregation.length() - 2, stringAggregation.length());
stringAggregation = stringAggregationNew;
return stringAggregation;
}
}
return qrgString;
}
public static void main(String[] args) {
int i = 0;
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")));
}
}
@@ -1,5 +1,6 @@
package kst4contest.view;
import javax.swing.Timer;
import java.util.Objects;
/**
@@ -13,24 +14,16 @@ import java.util.Objects;
*/
public final class CoalescingTrigger {
private final DelayTimer timer;
private final Timer timer;
/**
* @param delayMs how long to wait after the last request
* @param action runs on the user-interface thread once the delay elapses
*/
public CoalescingTrigger(final int delayMs, final Runnable action) {
this(delayMs, action, DelayTimer.SWING);
}
/**
* Test seam: the same trigger on a timer whose firing the caller controls.
*
* @param timers creates the one-shot timer; {@link DelayTimer#SWING} in production
*/
CoalescingTrigger(final int delayMs, final Runnable action, final DelayTimer.Factory timers) {
Objects.requireNonNull(action, "action");
this.timer = timers.create(delayMs, action);
this.timer = new Timer(delayMs, event -> action.run());
this.timer.setRepeats(false);
}
/** Requests a run, restarting the delay. */
@@ -1,58 +0,0 @@
package kst4contest.view;
import javax.swing.Timer;
import java.util.Objects;
/**
* A one-shot delay that can be restarted and stopped.
*
* <p>The thin seam between the two coalescing classes in this package and the
* {@code javax.swing.Timer} they run on. In production the Swing timer is the only
* implementation; tests substitute one whose firing they control, so "nothing ran" can
* be asserted without waiting for a delay that might not have elapsed yet.</p>
*/
interface DelayTimer {
/** Starts the delay, or starts it over if it is already running. */
void restart();
/** Drops a pending run. Idempotent. */
void stop();
/** Creates one-shot timers for a fixed delay and action. */
interface Factory {
/**
* @param delayMs how long the timer waits after a {@link #restart()}
* @param onFire what runs once the delay elapses
*/
DelayTimer create(int delayMs, Runnable onFire);
}
/**
* The production factory: a {@code javax.swing.Timer}, which fires on the AWT event
* thread where the user interface lives.
*/
Factory SWING = (delayMs, onFire) -> new Swing(delayMs, onFire);
/** {@code javax.swing.Timer} behind the interface. */
final class Swing implements DelayTimer {
private final Timer timer;
Swing(final int delayMs, final Runnable onFire) {
Objects.requireNonNull(onFire, "onFire");
this.timer = new Timer(delayMs, event -> onFire.run());
this.timer.setRepeats(false);
}
@Override
public void restart() {
timer.restart();
}
@Override
public void stop() {
timer.stop();
}
}
}
@@ -1,5 +1,4 @@
package kst4contest.view;
import kst4contest.view.compose.SettingsDraft;
import kst4contest.view.compose.SettingsHost;
import kst4contest.view.compose.SettingsNotices;
import kst4contest.view.compose.SettingsTabsKt;
@@ -1986,20 +1985,11 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
*/
private void requestOperatorProfileSwitch(OperatorProfile targetProfile) {
if (targetProfile == null) {
if (targetProfile == null || !confirmOperatorProfileSwitch(targetProfile)) {
return;
}
/*
* Settings edits not applied yet are asked about first: applied, they land in the
* profile being left, which is the one they were made in; "back" cancels the switch.
* Without pending edits this continues at once, as before.
*/
SettingsWindow.settleThen(() -> {
if (confirmOperatorProfileSwitch(targetProfile)) {
ApplicationRuntimeLauncher.switchProfile(targetProfile);
}
});
ApplicationRuntimeLauncher.switchProfile(targetProfile);
}
/**
@@ -2120,16 +2110,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
}
/**
* Hands the window hosts the active profile's stored window positions. Set on every
* startup, so a profile switch replaces the previous profile's positions. The opt-in is
* read on each call, so applying the setting takes effect without a restart.
*/
private void installWindowPositionMemory(ChatPreferences prefs) {
kst4contest.view.compose.WindowPlacementOnScreen.setMemory(
new kst4contest.view.compose.PreferencesWindowPositionMemory(prefs, this::requestLayoutSave));
}
private void requestLayoutSave() {
if (layoutAutosave != null) {
layoutAutosave.requestSave();
@@ -2173,7 +2153,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
.isMessageHandling_debugModeToFileEnabled());
layoutAutosave = new LayoutAutosave(
chatcontroller.getChatPreferences()::writeLayoutPreferencesToXmlFile, uiDispatcher);
installWindowPositionMemory(chatcontroller.getChatPreferences());
// Every profile starts with its own configured design; the menu quick switch only lasts for the session.
chatcontroller.getChatPreferences().setGUI_darkModeActive(
chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault());
@@ -2442,15 +2421,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
private void closeWindowEvent() {
System.out.println("Window close request ...");
/*
* Settings edits not applied yet are asked about before the quit question; "back"
* cancels the exit. Without pending edits this continues at once, as before.
*/
SettingsWindow.settleThen(this::confirmAndExit);
}
/** Asks whether to quit, and quits on yes. */
private void confirmAndExit() {
/*
* ConfirmKind.YES and not the default: the JavaFX dialog used ButtonType.YES here,
* and JavaFX ordered YES ahead of CANCEL on Linux and Windows but behind it on
@@ -2639,7 +2609,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
if (state.isOn(kst4contest.view.compose.StationFilter.NEW_BANDS) && !isNewBandOpportunity(member)) return false;
if (state.isOn(kst4contest.view.compose.StationFilter.AIRSCOUT_NEXT_5_MIN) && !hasAsWindowInNextMinutes(member, 5)) return false;
if (!state.acceptsQrb(member.getQrb())) return false;
if (state.isOn(kst4contest.view.compose.StationFilter.MAX_QRB)) {
Double qrb = member.getQrb();
if (qrb == null || qrb > state.getMaxQrbKm()) return false;
}
if (state.isOn(kst4contest.view.compose.StationFilter.QTF)) {
Double qtf = member.getQTFdirection();
@@ -3061,7 +3034,8 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
member -> parseTableDouble(formatPriorityScore(member)),
(member, band, worked) -> formatBandCellStatus(member, band, worked),
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())),
RowKeys.INSTANCE.byValue(StationColumns.INSTANCE::rowKey),
RowKeys.INSTANCE.byValue(member -> member.getCallSign() + "|"
+ (member.getChatCategory() == null ? "" : member.getChatCategory().getCategoryNumber())),
"compose-stations",
widths);
@@ -3099,9 +3073,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
MainMenuState menu = new MainMenuState();
MainWindowSurroundings surroundings = new MainWindowSurroundings(menu);
surroundings.setSo2rEnabled(chatcontroller.getChatPreferences().isTrxSynch_so2rEnabled());
surroundings.setSecondAntennaOffered(chatcontroller.getChatPreferences().isTrxSynch_so2rEnabled()
&& chatcontroller.getChatPreferences().isStn_pstRotator2Enabled());
SelectedStationState selectedStation = new SelectedStationState(
member -> {
@@ -3165,16 +3136,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
if (member == null) return null;
java.util.EnumSet<Band> enabledBands = kst4contest.logic.BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
return resolveDefaultSkedBand(member, enabledBands);
},
azimuth -> {
chatcontroller.rotateTo(kst4contest.controller.RadioSlot.FIRST, azimuth);
return kotlin.Unit.INSTANCE;
},
azimuth -> {
chatcontroller.rotateTo(kst4contest.controller.RadioSlot.SECOND, azimuth);
return kotlin.Unit.INSTANCE;
},
surroundings::getSecondAntennaOffered);
});
if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) {
selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_ME);
@@ -3765,19 +3727,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
}
}
/**
* The session state the settings window shows, read from the main window's Compose state
* so the Station tab's login fields follow it while the window is open. Before that state
* exists the controller's value is the best there is.
*/
private On4KstConnectionState settingsConnectionState() {
if (composeMainWindowState != null) {
return composeMainWindowState.getSurroundings().getConnectionState();
}
On4KstConnectionState current = chatcontroller.getOn4KstConnectionState();
return current == null ? On4KstConnectionState.DISCONNECTED : current;
}
/**
* Opens the settings window, or does nothing when it is already open.
*
@@ -3786,17 +3735,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
*/
private void openSettingsWindow() {
/*
* One draft per opening: the tabs stage into it, the buttons apply it, and the window
* asks about it when closed with edits pending.
*/
SettingsDraft draft = new SettingsDraft();
SettingsWindow.show(
SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore,
this::settingsConnectionState, draft),
SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore),
settingsNotices,
draft,
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
SETTINGS_WINDOW_FONT_SIZE_SP,
paletteStore,
@@ -3809,7 +3750,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
requestLayoutSave();
return kotlin.Unit.INSTANCE;
},
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices, draft));
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices));
}
/*
@@ -3881,13 +3822,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
chatcontroller.getMessageTXBus().add(sendMe);
}
/**
* Options/Windows -> open or close the settings window. Closing goes the way of the
* window's own X, so edits not applied yet are asked about rather than dropped.
*/
/** Options/Windows -> open or close the settings window. */
void menuActionToggleSettingsWindow() {
if (SettingsWindow.isOpen()) {
SettingsWindow.requestClose();
SettingsWindow.close();
} else {
openSettingsWindow();
}
@@ -4001,20 +3939,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
}
}
/** Pushes the stored SO2R switches to the main window so it shows or hides the radio 2 controls. */
@Override
public void refreshSo2rState() {
runOnUi(() -> {
if (composeMainWindowState == null) {
return;
}
kst4contest.model.ChatPreferences preferences = chatcontroller.getChatPreferences();
MainWindowSurroundings target = composeMainWindowState.getSurroundings();
target.setSo2rEnabled(preferences.isTrxSynch_so2rEnabled());
target.setSecondAntennaOffered(preferences.isTrxSynch_so2rEnabled() && preferences.isStn_pstRotator2Enabled());
});
}
/**
* No-op since the JavaFX own-QRG text field was removed with the never-shown window.
*
@@ -2,6 +2,7 @@ package kst4contest.view;
import kst4contest.observe.UiDispatcher;
import javax.swing.Timer;
import java.util.Objects;
/**
@@ -21,7 +22,7 @@ public final class LayoutAutosave {
private final Runnable writeLayout;
private final UiDispatcher uiDispatcher;
private final DelayTimer saveDelay;
private final Timer saveDelay;
private boolean pending;
/**
@@ -29,28 +30,16 @@ public final class LayoutAutosave {
* @param uiDispatcher confines the pending state to one thread
*/
public LayoutAutosave(final Runnable writeLayout, final UiDispatcher uiDispatcher) {
this.writeLayout = Objects.requireNonNull(writeLayout, "writeLayout");
this.uiDispatcher = Objects.requireNonNull(uiDispatcher, "uiDispatcher");
/*
* A javax.swing.Timer and not the former JavaFX PauseTransition: it fires on the
* AWT event thread, which is where the user interface lives now, and restart()
* has the same meaning playFromStart() had. A PauseTransition would need the
* JavaFX toolkit to be running, which it no longer is.
*/
this(writeLayout, uiDispatcher, DelayTimer.SWING);
}
/**
* Test seam: the same debounce on a timer whose firing the caller controls.
*
* @param timers creates the one-shot timer; {@link DelayTimer#SWING} in production
*/
LayoutAutosave(
final Runnable writeLayout,
final UiDispatcher uiDispatcher,
final DelayTimer.Factory timers
) {
this.writeLayout = Objects.requireNonNull(writeLayout, "writeLayout");
this.uiDispatcher = Objects.requireNonNull(uiDispatcher, "uiDispatcher");
this.saveDelay = timers.create(SAVE_DELAY_MS, this::flushPending);
this.saveDelay = new Timer(SAVE_DELAY_MS, event -> flushPending());
this.saveDelay.setRepeats(false);
}
/** Asks for a save, restarting the delay. Safe to call from any thread. */
@@ -1,7 +1,6 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
/**
@@ -35,27 +34,6 @@ object AirplaneCell {
*/
private const val NOTHING_COMING = "nil"
// ---- the order ----------------------------------------------------------
/** Unknown or no aeroplane sorts after every real one. */
private const val NO_PLANE = Int.MAX_VALUE
/** The aeroplane the cell shows first; the parser already put the best one there. */
private fun first(info: AirPlaneReflectionInfo?): AirPlane? = info?.risingAirplanes?.firstOrNull()
/**
* Ascending: the minutes the operator reads first, then the better potential first.
*
* Issue #87: without a comparator the table fell back to the rendered text, which put
* "13 (75%)" above "3 (50%)". Only the first aeroplane counts, as it is the one the
* operator reads and the one the colour marks; the second is in the cell for context.
* Empty and "nil" cells carry [NO_PLANE]: like qrb and qtf, an absent value gets a
* sentinel instead of a null check, here the maximum so the cell sorts last ascending.
*/
val order: Comparator<AirPlaneReflectionInfo?> =
compareBy<AirPlaneReflectionInfo?> { first(it)?.arrivingDurationMinutes ?: NO_PLANE }
.thenByDescending { first(it)?.potential ?: 0 }
/** `.table-cell-100PercentAP`, identical in both stylesheets: a certain path. */
val PURPLE_100 = Color(0xFFF98AFF)
@@ -158,8 +158,6 @@ fun ChatInputRow(
ownQrgMain: ObservedValue<String>,
ownQrgSecond: ObservedValue<String>,
antennaQtf: ObservedValue<Double>,
antennaQtf2: ObservedValue<Double>,
so2rEnabled: Boolean,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
@@ -183,7 +181,7 @@ fun ChatInputRow(
)
Form.button(text = "TX", onClick = { state.send() })
Form.button(text = kst4contest.view.i18n.LocalStrings.current.chatClear, onClick = { state.clear() })
Form.button(text = "clear", onClick = { state.clear() })
VerticalRule()
@@ -197,26 +195,13 @@ fun ChatInputRow(
InlineCommittedField(
stored = ownQrgSecond.value.orEmpty(),
onCommit = { prefs.getMYQRGSecondCat().set(it) },
tooltip = if (so2rEnabled) {
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltipSo2r
} else {
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip
},
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip,
)
InlineCommittedField(
stored = antennaQtf.value?.toString().orEmpty(),
onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip,
)
if (so2rEnabled) {
// Display only: rotator 2 writes it, the operator does not.
InlineCommittedField(
stored = antennaQtf2.value?.takeIf { it.isFinite() }?.toString().orEmpty(),
onCommit = { },
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtf2Tooltip,
editable = false,
)
}
}
}
@@ -233,7 +218,6 @@ private fun InlineCommittedField(
stored: String,
onCommit: (String) -> Unit,
tooltip: String,
editable: Boolean = true,
) {
var draft by remember(stored) { mutableStateOf(stored) }
@@ -241,7 +225,6 @@ private fun InlineCommittedField(
CompactTextField(
value = draft,
onValueChange = { draft = it },
enabled = editable,
modifier = Modifier
.width(QRG_FIELD_WIDTH)
.onFocusChanged { focus ->
@@ -254,7 +254,7 @@ object ComposeAlert {
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS)
dialog.title = title
dialog.size = DIALOG_SIZE
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
dialog.setLocationRelativeTo(null)
/* Disposed and not hidden, for the reason spelled out in open(). */
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
dialog.isAlwaysOnTop = true
@@ -274,7 +274,7 @@ object ComposeAlert {
AlertContent(
header = header,
body = body,
buttons = alertButtons(CurrentStrings.get().alertOk, null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX),
buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX),
onPressed = { dialog.dispose() },
link = linkText to linkTarget,
)
@@ -322,7 +322,7 @@ object ComposeAlert {
)
dialog.title = title
dialog.size = DIALOG_SIZE
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
dialog.setLocationRelativeTo(null)
/*
* Disposed, not hidden. The JDialog default is HIDE_ON_CLOSE, which would leave the
* window and its Skia layer alive behind the operator's back — one per dismissal,
@@ -11,25 +11,16 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.loadImageBitmap
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.WindowPlacement as ComposeWindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import androidx.compose.ui.awt.ComposeWindow
import java.awt.EventQueue
import java.awt.Frame
import java.awt.Point
import java.awt.Toolkit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
/**
* A Compose window that lives beside the JavaFX application.
@@ -54,20 +45,10 @@ import kotlinx.coroutines.flow.drop
* - **It must follow a design switch.** The operator can change the design from the
* main window's menu while this window is open.
*
* Every window but the main one also opens on the screen the operator is working on
* (issue #59): centred on the main window's screen, or where the operator moved it to
* earlier in the session — see [WindowPlacement] for the rule.
*
* @param threadName names the thread in a stack dump, which is where one looks when a
* window will not close. It is also the key of the window's stored position, so
* renaming it forgets where the operator put the window.
* @param isMainWindow true for the main window only. It keeps the platform's placement
* and publishes its frame, which is what every other window is placed against.
* window will not close
*/
class ComposeWindowHost(
private val threadName: String,
private val isMainWindow: Boolean = false,
) {
class ComposeWindowHost(private val threadName: String) {
private val open = AtomicBoolean(false)
private val darkModeState = mutableStateOf(false)
@@ -80,16 +61,6 @@ class ComposeWindowHost(
@Volatile
private var frame: ComposeWindow? = null
/**
* Where the operator moved this window to, for this session. Not persisted here: a
* stored position outlives the monitor layout it was made on, which is the very problem
* of issue #59. Keeping positions across restarts is the operator's opt-in, handled by
* [WindowPlacementOnScreen.memory] and used only on the very screen it was made on. Lives as long as the host, and the hosts are
* singletons, so it survives closing and reopening the window and a profile switch.
*/
@Volatile
private var movedTo: Point? = null
/** Whether the window is currently open, so a caller can tell without opening one. */
val isOpen: Boolean
get() = open.get()
@@ -156,7 +127,7 @@ class ComposeWindowHost(
* [deiconifiedState] for what to write — so the only thing left here that no headless
* test can observe is the single call to `toFront`.
*/
fun raise() {
private fun raise() {
val frame = this.frame ?: return
EventQueue.invokeLater {
@@ -245,34 +216,9 @@ class ComposeWindowHost(
return@application
}
/*
* Decided once per opening, on the event thread this composition runs
* on — which is where the screens and the main window may be read.
* Null keeps the platform's placement: always for the main window, and
* for any window that opens before the main window exists.
*/
val placed = remember {
val width = widthDp.roundToInt()
val height = heightDp.roundToInt()
when {
/*
* The main window only ever uses a stored position; without
* one the platform places it, as before.
*/
isMainWindow -> WindowPlacementOnScreen.restore(threadName, width, height)
/* This session's move is newer than anything stored. */
movedTo != null -> WindowPlacementOnScreen.place(movedTo, width, height)
else -> WindowPlacementOnScreen.restore(threadName, width, height)
?: WindowPlacementOnScreen.place(null, width, height)
}
}
val windowState = rememberWindowState(
position = placed
?.let { WindowPosition(it.x.dp, it.y.dp) }
?: WindowPosition.PlatformDefault,
width = placed?.width?.dp ?: widthDp.dp,
height = placed?.height?.dp ?: heightDp.dp,
width = widthDp.dp,
height = heightDp.dp,
)
/*
@@ -281,103 +227,11 @@ class ComposeWindowHost(
* comfort: these tables are wider than the default window, so an
* operator who widens it once must not have to do it again.
*/
/*
* The last size and position this host cut the window down to. Neither
* is the operator's choice: the size must not be written back as the
* stored size, and the position must not count as a move.
*/
val fit = remember { FitRecord() }
if (onResized != null) {
LaunchedEffect(windowState) {
/*
* A secondary window skips the opening size: it may be the
* screen-clamped one, and writing that back would shrink the
* stored size for good because the window once opened on a
* smaller screen. The main window keeps writing it, as before,
* unless it was opened at a stored position, where the size may
* be clamped the same way. A size this host fitted the window to
* is skipped for the same reason.
*/
snapshotFlow { windowState.size }
.drop(if (isMainWindow && placed == null) 0 else 1)
.collect { size ->
if (size.width.isSpecified && size.height.isSpecified
&& !fit.takeSize(size.width.value.roundToInt(), size.height.value.roundToInt())
) {
onResized(size.width.value, size.height.value)
}
}
}
}
LaunchedEffect(windowState) {
/*
* One tracker per opening; the rule is in MoveTracker. An operator
* move is remembered for the session (secondary windows) and handed
* to the stored positions, which keep it only when the operator
* opted in. Only moves are stored, never the window manager's
* nudges, so a restored window cannot drift a little on every start.
*/
var tracker = MoveTracker(placed?.location)
snapshotFlow { windowState.position }.collect { position ->
if (position is WindowPosition.Absolute
&& windowState.placement == ComposeWindowPlacement.Floating
&& !windowState.isMinimized
) {
val now = Point(
position.x.value.roundToInt(),
position.y.value.roundToInt(),
)
val size = windowState.size
/*
* The host's own fit is a new anchor, not a move; the window
* manager may still nudge it within the tolerance.
*/
if (fit.takePosition(now)) {
tracker = MoveTracker(now)
return@collect
}
tracker.observe(now)?.let { moved ->
if (!isMainWindow) {
movedTo = moved
}
WindowPlacementOnScreen.store(
threadName,
moved,
if (size.width.isSpecified) size.width.value.roundToInt() else widthDp.roundToInt(),
if (size.height.isSpecified) size.height.value.roundToInt() else heightDp.roundToInt(),
)
}
}
}
}
LaunchedEffect(windowState) {
/*
* A window moved onto a smaller screen -- a laptop panel, a 640x480
* field monitor -- is cut down to it, so no edge or button ends up out
* of reach. Only once it has rested: resizing in the middle of a drag
* would fight the window manager for the window.
*/
snapshotFlow { windowState.position to windowState.size }.collectLatest { (position, size) ->
delay(FIT_SETTLE_MILLIS)
if (position is WindowPosition.Absolute
&& size.width.isSpecified && size.height.isSpecified
&& windowState.placement == ComposeWindowPlacement.Floating
&& !windowState.isMinimized
) {
WindowPlacementOnScreen.fitted(
java.awt.Rectangle(
position.x.value.roundToInt(),
position.y.value.roundToInt(),
size.width.value.roundToInt(),
size.height.value.roundToInt(),
),
)?.let { fitted ->
fit.record(fitted)
windowState.size = DpSize(fitted.width.dp, fitted.height.dp)
windowState.position = WindowPosition(fitted.x.dp, fitted.y.dp)
snapshotFlow { windowState.size }.collect { size ->
if (size.width.isSpecified && size.height.isSpecified) {
onResized(size.width.value, size.height.value)
}
}
}
@@ -397,9 +251,6 @@ class ComposeWindowHost(
*/
DisposableEffect(window) {
frame = window
if (isMainWindow) {
WindowPlacementOnScreen.mainWindow = window
}
/*
* The only place the handle is dropped. Clearing it again in
* the thread's finally block looks like a belt to this brace,
@@ -408,12 +259,7 @@ class ComposeWindowHost(
* the late write would wipe a live handle and bring back the
* dead button this whole method exists to remove.
*/
onDispose {
frame = null
if (WindowPlacementOnScreen.mainWindow === window) {
WindowPlacementOnScreen.mainWindow = null
}
}
onDispose { frame = null }
}
CompositionLocalProvider(
@@ -451,126 +297,6 @@ class ComposeWindowHost(
}
}
/**
* How far a window must have gone from where it was opened to count as moved by the
* operator.
*
* Not zero, because window managers nudge a new window on their own — by the decoration,
* or to keep it clear of a panel — and that nudge would otherwise be remembered as the
* operator's choice. The window would then stop following the main window to another
* screen, and on X11 reopening it would drift by the decoration each time.
*/
internal const val MOVE_TOLERANCE_PX = 48
/** How long a window must rest after a move before it is cut down to a smaller screen. */
private const val FIT_SETTLE_MILLIS = 500L
/**
* What the host last cut a window down to, so the size and position it set itself are not
* taken for the operator's. Each is consumed by the first report that follows: a matching
* report is the fit echoing back, any other report is the operator's and clears it, so a
* stale record cannot swallow a later resize or move. Only touched on the window's event
* thread.
*/
internal class FitRecord {
private var pendingPosition: Point? = null
private var pendingWidth = 0
private var pendingHeight = 0
private var sizePending = false
fun record(bounds: java.awt.Rectangle) {
pendingPosition = bounds.location
pendingWidth = bounds.width
pendingHeight = bounds.height
sizePending = true
}
/**
* Whether [now] is the fitted position reported back. Compared with the move
* tolerance, because dp rounding under display scaling and a window manager's nudge
* both shift it slightly.
*/
fun takePosition(now: Point): Boolean {
val pending = pendingPosition ?: return false
pendingPosition = null
return !movedAway(pending, now)
}
/** Whether this size is the fitted one reported back, allowing for dp rounding. */
fun takeSize(width: Int, height: Int): Boolean {
if (!sizePending) {
return false
}
sizePending = false
return abs(width - pendingWidth) <= SIZE_ROUNDING_PX && abs(height - pendingHeight) <= SIZE_ROUNDING_PX
}
private companion object {
const val SIZE_ROUNDING_PX = 2
}
}
/** Whether [now] is far enough from [opened] to be the operator's move rather than a nudge. */
internal fun movedAway(opened: Point, now: Point): Boolean =
abs(now.x - opened.x) > MOVE_TOLERANCE_PX || abs(now.y - opened.y) > MOVE_TOLERANCE_PX
/**
* Tells the operator's moves of one opened window from the window manager's, fed with every
* floating position the window reports.
*
* - **Placed by the host** ([placedAt] not null): the anchor is that placement. Reports
* within [MOVE_TOLERANCE_PX] of it are the window manager's nudge and are ignored. This
* also holds when the window was reopened at its remembered spot, so a decoration nudge
* cannot make it drift a little further on every opening.
* - **Once a report has left the tolerance**, the operator is moving the window, and from
* then on every report is taken — including a move back to near where it opened, which
* would otherwise leave the stale far-away spot remembered.
* - **Not placed by the host** (opened before the main window existed): the platform picks
* the spot, and it may report a provisional position before its real placement. So the
* anchor is not trusted until it has settled: every report replaces it until one lands
* within the tolerance of the previous one. A window manager's placement jump is
* therefore a new anchor, not a move. The price: a single far jump by the operator
* before any second report — a keyboard "move to other monitor" right after opening —
* is taken as the anchor too, and the window then simply opens on the main window's
* screen next time, which is the default anyway. A time-based settle was the
* alternative; it would need a clock in a rule that is otherwise pure, and a drag
* produces a stream of close reports that settles the anchor on its own.
*
* @param placedAt where the host placed the window, or null when the platform did
*/
internal class MoveTracker(placedAt: Point?) {
private var anchor: Point? = placedAt
private var settled = placedAt != null
private var moving = false
/**
* @param now the position the window reports
* @return the position to remember as the operator's choice, or null to remember nothing
*/
fun observe(now: Point): Point? {
if (moving) {
return now
}
val current = anchor
if (!settled || current == null) {
if (current != null && !movedAway(current, now)) {
settled = true
} else {
anchor = now
}
return null
}
if (movedAway(current, now)) {
moving = true
return now
}
return null
}
}
/**
* The application icon, so these windows are not the only ones in the taskbar showing
* the default Java cup. Null when the resource is missing rather than failing: a window
@@ -15,8 +15,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
@@ -242,7 +240,7 @@ class EditableListState(
* reject it and put the stored value back.
*
* @param onChanged called after every committed change — add, edit, removal, move.
* The settings tabs stage the list here; applying the draft fills the roster.
* The lists write through, so this is where the roster is filled.
* @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
* tables opened an alert here.
* @param onAdd replaces the default "insert the template at the top" behaviour.
@@ -302,23 +300,6 @@ fun DataTable(
}
}
/*
* Inside the settings window a row still being edited is committed before the
* draft is applied: a click on "Apply/Close prefs" leaves the focus in the row,
* see SettingsDraft.registerTypedEntry. Only an edited row; committing an
* untouched one would report a change that was never made. A refusal is reported
* by commit() through onRefused, so the registration answers null.
*/
val settingsDraft = LocalSettingsDraft.current
val pendingEdit = rememberUpdatedState { if (draft != entry.text) commit() }
DisposableEffect(settingsDraft, entry.key) {
val registration = settingsDraft?.registerTypedEntry {
pendingEdit.value()
null
}
onDispose { registration?.close() }
}
val isSelected = state.selectedIndex == index
/*
@@ -275,14 +275,7 @@ class DataTableState<T>(
var contentRevision by mutableStateOf(0)
private set
/**
* Redraws the rows and filters them again. The filter reads mutable row fields (a
* station's QRB, worked and QRV flags, its last activity), so a cached [visibleRows]
* from before the change could keep a row the filter now excludes, or hide one it
* now lets through -- in the list and in the station map, which draws the same rows.
*/
fun forceRedraw() {
invalidateOrder()
contentRevision++
}
@@ -306,7 +299,6 @@ class DataTableState<T>(
*/
val currentRows = rows
val currentFilter = rowFilter
contentRevision // read for the subscription: forceRedraw drops the cache
val currentColumn = sortedColumnId
val ascending = sortAscending
@@ -54,15 +54,14 @@ object DirectedMessageColumns {
),
DataColumn(
id = "airscout",
title = { kst4contest.view.i18n.CurrentStrings.get().columnAirplane },
title = "AP [minutes / pot%]",
value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) },
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
comparator = compareBy(AirplaneCell.order) { it.sender?.airPlaneReflectInfo },
weight = 1.4f,
),
DataColumn(
id = "category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
title = "Category",
value = { message ->
message.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
@@ -33,12 +33,10 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -113,25 +111,10 @@ object Form {
* every prefix of a valid entry, so the draft lives here until the operator leaves
* the field or presses Enter.
*
* Only an edited draft is committed: drawing the field, or entering and leaving it
* without typing, commits nothing, and neither does Enter on an unchanged value.
* A field removed while it holds the focus -- its tab switched away, its window
* closed -- commits a typed entry once: Compose reports the cleared focus to the
* field as a focus loss.
*
* @param stored the value stored when the caller composed; the draft starts from it
* @param stored the value currently stored; the draft falls back to it when an
* entry is refused, which is what those fields did with setText
* @param commit returns null when the value was accepted, otherwise the reason
* @param onRefused shown to the operator; the JavaFX fields opened an alert
* @param isUnchanged whether the draft names the stored value. Textual by default,
* looser where two spellings mean one value, as with colours. It must agree
* with what [commit] stores: a draft that commit would store as `c` has to count
* as unchanged against `c`, or leaving the field after Enter commits it again.
* @param current the value in force right now, read when the field commits: inside the
* settings window the value staged in the draft, otherwise the stored one. The
* typed text is judged against it, and falls back to it on a refusal (what the
* JavaFX fields did with setText). Required, because [stored] alone goes stale
* when it comes from a plain getter: nothing need recompose the caller after a
* commit, so after Enter it can still name the old value.
*/
@Composable
fun committed(
@@ -139,67 +122,24 @@ object Form {
stored: String,
commit: (String) -> String?,
onRefused: (String) -> Unit,
isUnchanged: (draft: String, stored: String) -> Boolean = { typed, now -> typed == now },
current: () -> String,
) {
var draft by remember(stored) { mutableStateOf(stored) }
/*
* Whether the field has held the focus since it was last committed. onFocusChanged
* also fires when the modifier is first attached, with "not focused"; reading that
* as a focus loss committed every such field the moment its tab was opened. Only a
* change from focused to unfocused is the operator leaving the field. A plain holder,
* not state: nothing is drawn from it.
*/
val hadFocus = remember { booleanArrayOf(false) }
fun commitDraft() {
/*
* An untouched draft is no edit. Committing it anyway would turn a shipped colour
* into an override of itself, restart the beacon timer, or report a stored value
* the rule refuses although the operator only passed through the field.
*/
if (isUnchanged(draft, current())) return
val refusal = commit(draft)
if (refusal != null) {
onRefused(refusal)
draft = current()
draft = stored
}
}
/*
* Inside the settings window the draft can commit this field without a focus change:
* a click on "Apply/Close prefs" may leave the focus in the field, and the entry would
* otherwise reach the draft only when the closing window removed the field. Through
* an updated state, because commitDraft closes over this composition's parameters.
* A refusal is reported by commitDraft itself through onRefused, as on focus loss, so
* the registration answers null and the caller sees the refusal as the notice.
*/
val settingsDraft = LocalSettingsDraft.current
val latestCommit = rememberUpdatedState(::commitDraft)
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry {
latestCommit.value()
null
}
onDispose { entry?.close() }
}
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focus ->
if (focus.isFocused) {
hadFocus[0] = true
} else if (hadFocus[0]) {
hadFocus[0] = false
commitDraft()
}
}
.onFocusChanged { focus -> if (!focus.isFocused) commitDraft() }
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && event.key == Key.Enter) {
commitDraft()
@@ -243,7 +183,7 @@ object Form {
.heightIn(min = Density.FIELD_MIN_HEIGHT),
) {
Text(
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
draft.value?.let(describe) ?: "not selected",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
)
@@ -287,7 +227,7 @@ object Form {
modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
) {
Text(
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
draft.value?.let(describe) ?: "not selected",
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -324,53 +264,34 @@ object Form {
}
@Composable
fun int(label: String, value: Int, onChange: (Int) -> Unit) =
number(label, value, { it.trim().toIntOrNull() }, onChange)
@Composable
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) =
number(label, value, { it.trim().replace(',', '.').toDoubleOrNull() }, onChange)
/**
* The number field behind [int] and [decimal].
*
* The text follows the stored value only when that value is not what the operator's own
* text already says. A settings draft is Compose state, so the value an entry stages comes
* straight back as [value]; resetting the text to `value.toString()` then would turn a
* typed "1" into "1.0" before the next digit, and "135,5" could never be typed.
*/
@Composable
private fun <N> number(label: String, value: N, parse: (String) -> N?, onChange: (N) -> Unit) {
// The last text typed. A plain holder: it only seeds the text below, nothing draws it.
val typed = remember { arrayOf(value.toString()) }
var draft by remember(value) {
mutableStateOf(if (parse(typed[0]) == value) typed[0] else value.toString())
}
/*
* Text that does not parse stages nothing, so the draft still holds the last valid
* value while the field shows something else in red. Applying then would put a value
* into effect the operator no longer sees; the field refuses instead, naming itself.
*/
val strings = kst4contest.view.i18n.LocalStrings.current
val settingsDraft = LocalSettingsDraft.current
val refusal = rememberUpdatedState {
if (parse(draft) == null) strings.settingsInvalidEntry(label, draft) else null
}
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
onDispose { entry?.close() }
}
fun int(label: String, value: Int, onChange: (Int) -> Unit) {
var draft by remember(value) { mutableStateOf(value.toString()) }
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { entered ->
typed[0] = entered
draft = entered
parse(entered)?.let(onChange)
onValueChange = { typed ->
draft = typed
typed.trim().toIntOrNull()?.let(onChange)
},
isError = parse(draft) == null,
isError = draft.trim().toIntOrNull() == null,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) {
var draft by remember(value) { mutableStateOf(value.toString()) }
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { typed ->
draft = typed
typed.trim().replace(',', '.').toDoubleOrNull()?.let(onChange)
},
isError = draft.trim().replace(',', '.').toDoubleOrNull() == null,
modifier = Modifier.fillMaxWidth(),
)
}
@@ -205,8 +205,6 @@ private fun MessageColumn(
ownQrgMain = state.ownQrgMain,
ownQrgSecond = state.ownQrgSecond,
antennaQtf = state.antennaQtf,
antennaQtf2 = state.antennaQtf2,
so2rEnabled = state.surroundings.so2rEnabled,
modifier = Modifier.fillMaxWidth(),
)
},
@@ -1,7 +1,6 @@
package kst4contest.view.compose
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory
/** How big the main window opens. */
data class MainWindowSize(val heightDp: Double, val widthDp: Double)
@@ -57,75 +56,30 @@ object MainWindowFrame {
fun title(chatState: String, profileName: String?): String =
if (profileName.isNullOrBlank()) chatState else "$chatState - $profileName"
/**
* What the title's first half says.
*
* The live connection line while the chat is usable -- exactly what the JavaFX title
* carried -- and a wording about the attempt otherwise. The connection manager's own
* detail ("session is authenticated and synchronized") is a diagnostic for the status
* bar, not a title: it says nothing about category, call or traffic.
*
* @param state the connection state, null before the first push
* @param onlineLine the line built by [onlineChatState], asked for only while online
* @return the chat half of the title
*/
fun chatState(state: On4KstConnectionState?, onlineLine: () -> String): String =
if (state == On4KstConnectionState.ONLINE) onlineLine() else offlineChatState(state)
/**
* The connection line while the chat is usable, as the JavaFX title had it:
* "Connected to: 2: 144/432 MHz as DO1JKS (Johannes 70/2) in JN59MU (106 users online,
* 106 shown), 14 messages total."
*
* Counts are passed as text so MessageFormat does not group them by locale. Without a
* main category there is nothing to name, so the line falls back to the offline wording
* for ONLINE instead of reading "Connected to: as ...".
*
* @param mainCategory the main chat category, null when none is configured
* @param secondCategory the second category, null when it is not logged in
* @param callSign the login callsign
* @param name the login name of the main category
* @param locator the login locator
* @param usersOnline every station in the chat
* @param shown the stations the filters let through
* @param messagesTotal every chat message held, the global list behind the public tab
*/
fun onlineChatState(
mainCategory: ChatCategory?,
secondCategory: ChatCategory?,
callSign: String?,
name: String?,
locator: String?,
usersOnline: Int,
shown: Int,
messagesTotal: Int,
): String {
if (mainCategory == null) {
return offlineChatState(On4KstConnectionState.ONLINE)
}
val strings = kst4contest.view.i18n.CurrentStrings.get()
val categories = if (secondCategory == null) {
mainCategory.toString()
} else {
strings.titleBothCategories(mainCategory.toString(), secondCategory.toString())
}
return strings.titleOnline(
categories,
callSign.orEmpty(),
name.orEmpty(),
locator.orEmpty(),
usersOnline.toString(),
shown.toString(),
messagesTotal.toString(),
)
}
/**
* What the title says while the chat is not usable.
*
* The wording separates a scheduled reconnect from a dead session, which is the
* difference between waiting and doing something about it.
*/
/**
* What the title's first half says.
*
* The live connection detail while the chat is usable -- which is the whole status line,
* exactly what the JavaFX title carried -- and a wording about the attempt otherwise. A
* blank detail falls through to the offline wording rather than leaving the title empty.
*
* @param state the connection state, null before the first push
* @param detail the status line the controller pushes
* @return the chat half of the title
*/
fun chatState(state: On4KstConnectionState?, detail: String?): String =
if (state == On4KstConnectionState.ONLINE && !detail.isNullOrBlank()) {
detail
} else {
offlineChatState(state)
}
fun offlineChatState(state: On4KstConnectionState?): String =
when (state ?: On4KstConnectionState.DISCONNECTED) {
On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().titleConnectionLost
@@ -16,7 +16,7 @@ import androidx.compose.runtime.setValue
*/
object MainWindowHost {
private val host = ComposeWindowHost("main-window", isMainWindow = true)
private val host = ComposeWindowHost("main-window")
@JvmStatic
val isOpen: Boolean
@@ -111,29 +111,14 @@ object MainWindowHost {
* Read inside the composition, so the title follows the connection the way
* the JavaFX one did: "Connected to: 2: 144/432 MHz as DN5PW in JO50JP
* (73 users online, 73 shown), 29 messages total." A captured string would
* say "disconnected" for the life of the session. The counts are Compose
* state, so the title moves with every station and message. visibleRows
* costs nothing extra: it is cached per change, and the station table
* reads the same list anyway.
* say "disconnected" for the life of the session.
*/
val state = currentState
val surroundings = currentState?.surroundings
MainWindowFrame.title(
chatState = if (state == null) {
MainWindowFrame.offlineChatState(null)
} else MainWindowFrame.chatState(state.surroundings.connectionState) {
val prefs = state.prefs
MainWindowFrame.onlineChatState(
mainCategory = prefs.getLoginChatCategoryMain(),
secondCategory = prefs.getLoginChatCategorySecond()
.takeIf { prefs.isLoginToSecondChatEnabled() },
callSign = prefs.getStn_loginCallSign(),
name = prefs.getStn_loginNameMainCat(),
locator = prefs.getStn_loginLocatorMainCat(),
usersOnline = state.stations.rows.size,
shown = state.stations.visibleRows.size,
messagesTotal = state.publicMessages.rows.size,
)
},
chatState = MainWindowFrame.chatState(
surroundings?.connectionState,
surroundings?.connectionDetail,
),
profileName = profileName,
)
},
@@ -75,14 +75,13 @@ class MainWindowState(
val snippets = RosterMirror(prefs.getLst_txtSnipList())
/**
* The four small fields beside the send controls. Mirrored rather than read straight
* from the preferences, because all of them move without the operator touching them: the
* The three small fields beside the send controls. Mirrored rather than read straight
* from the preferences, because all three move without the operator touching them: the
* rotator writes the heading, and the frequency follower writes the own QRG.
*/
val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat())
val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat())
val antennaQtf = ObservedValue(prefs.getActualQTF())
val antennaQtf2 = ObservedValue(prefs.getActualQTF2())
/**
* Stops following the preferences. Called when this state is dropped — a profile switch
@@ -93,7 +92,6 @@ class MainWindowState(
ownQrgMain.release()
ownQrgSecond.release()
antennaQtf.release()
antennaQtf2.release()
}
companion object {
@@ -163,13 +161,4 @@ class MainWindowSurroundings(private val menu: MainMenuState = MainMenuState())
var settingsWindowOpen: Boolean by mutableStateOf(false)
var monitorWindowOpen: Boolean by mutableStateOf(false)
/**
* Whether SO2R is switched on. Compose state, because the preference is not: the
* application pushes the stored value in at start-up and after every settings Apply.
*/
var so2rEnabled: Boolean by mutableStateOf(false)
/** SO2R with rotator 2 enabled, i.e. antenna 2 can be turned; pushed in like [so2rEnabled]. */
var secondAntennaOffered: Boolean by mutableStateOf(false)
}
@@ -51,7 +51,7 @@ object MonitorColumns {
*/
DataColumn(
id = "category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
title = "Category",
value = { message ->
message.sender?.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
@@ -61,7 +61,7 @@ object MonitorWindow {
onResized: (Float, Float) -> Unit,
) {
host.show(
title = { kst4contest.view.i18n.CurrentStrings.get().monitorTitle },
title = { "Cluster & QSO of the other" },
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore,
@@ -75,9 +75,8 @@ object MonitorWindow {
}
}
/** Internal rather than private so the window's body can be drawn headless in a test. */
@Composable
internal fun MonitorContent(
private fun MonitorContent(
clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>,
) {
@@ -97,7 +96,7 @@ internal fun MonitorContent(
HorizontalDivider(thickness = Density.HAIRLINE)
Text(
kst4contest.view.i18n.LocalStrings.current.messagesQsoOfTheOther,
"QSO of the other",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 4.dp),
)
@@ -123,7 +123,7 @@ object OperatorProfilePickerWindow {
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL)
dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle
dialog.size = DIALOG_SIZE
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
dialog.setLocationRelativeTo(null)
/* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
/*
@@ -1,39 +0,0 @@
package kst4contest.view.compose
import java.awt.Point
import java.awt.Rectangle
import kst4contest.model.ChatPreferences
/**
* The stored window positions of one profile, gated by the operator's opt-in.
*
* The opt-in is read on every call, so applying the setting takes effect without a
* restart: off means nothing is restored and nothing is written.
*
* @param requestSave asks the layout autosave to write the preferences file
*/
class PreferencesWindowPositionMemory(
private val prefs: ChatPreferences,
private val requestSave: Runnable,
) : WindowPositionMemory {
override fun load(windowId: String): StoredWindowPosition? {
if (!prefs.isGuiOptions_rememberWindowPositions()) {
return null
}
val p = prefs.getWindowPosition(windowId) ?: return null
return StoredWindowPosition(Point(p[0], p[1]), Rectangle(p[2], p[3], p[4], p[5]))
}
override fun save(windowId: String, stored: StoredWindowPosition) {
if (!prefs.isGuiOptions_rememberWindowPositions()) {
return
}
prefs.setWindowPosition(
windowId,
stored.position.x, stored.position.y,
stored.screen.x, stored.screen.y, stored.screen.width, stored.screen.height,
)
requestSave.run()
}
}
@@ -26,7 +26,7 @@ object PublicMessageColumns {
DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.sender?.name.orEmpty() }),
DataColumn(
id = "last-qrg",
title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrg },
title = "Last QRG",
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
weight = 0.8f,
),
@@ -38,7 +38,7 @@ object PublicMessageColumns {
),
DataColumn(
id = "category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
title = "Category",
value = { message ->
message.sender?.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
@@ -16,13 +16,13 @@ object SelectedStationMessageColumns {
),
DataColumn(
id = "call-tx",
title = { kst4contest.view.i18n.CurrentStrings.get().columnSender },
title = "Sender",
value = { MonitorColumns.callSignOf(it.sender) },
weight = 0.8f,
),
DataColumn(
id = "call-rx",
title = { kst4contest.view.i18n.CurrentStrings.get().columnReceiver },
title = "Receiver",
value = { MonitorColumns.callSignOf(it.receiver) },
weight = 0.8f,
),
@@ -127,7 +127,7 @@ fun SelectedStationPanel(
}
}
Form.check(strings.selectedNotQrvAll, activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
Form.check("all", activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
state.tagNotQrvAll(it)
}
}
@@ -183,17 +183,7 @@ fun SelectedStationPanel(
Form.section(strings.selectedLookUpAndShow) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
Form.button(strings.selectedShowOnMap, onClick = onShowOnMap)
Form.button(
strings.selectedTurnAntenna(state.selected?.callSignRaw.orEmpty()),
enabled = state.canTurnAntenna,
) { state.turnAntenna() }
if (state.showsSecondAntenna) {
Form.button(
strings.selectedTurnAntenna2(state.selected?.callSignRaw.orEmpty()),
enabled = state.canTurnSecondAntenna,
) { state.turnSecondAntenna() }
}
Form.button("🗺 Show on map", onClick = onShowOnMap)
Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) {
openInBrowser.accept(state.qrzComUrl())
}
@@ -54,12 +54,6 @@ enum class SelectedMessageFilter(val label: String) {
* `propagateNotQrvStateToActiveMembers` does
* @param createSked takes the station, the minutes, the band and the mode; returns
* whether it was accepted
* @param turnAntennaTo turns the antenna to an azimuth in degrees, which is what
* `ChatController.rotateTo` does; null where no rotator can be driven
* @param turnSecondAntennaTo the same for antenna 2 (rotator 2); null where it cannot be driven
* @param secondAntennaOffered whether SO2R with rotator 2 is configured right now; called on
* every draw, so the supplied lambda has to read Compose state for the button to
* follow a settings Apply
*/
class SelectedStationState(
private val propagateNotQrv: (ChatMember) -> Unit,
@@ -69,9 +63,6 @@ class SelectedStationState(
private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { },
private val detectBands: (ChatMember) -> String = { "" },
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
private val turnAntennaTo: ((Double) -> Unit)? = null,
private val turnSecondAntennaTo: ((Double) -> Unit)? = null,
private val secondAntennaOffered: () -> Boolean = { false },
) {
var selected: ChatMember? by mutableStateOf(null)
@@ -112,30 +103,6 @@ class SelectedStationState(
val canLookUp: Boolean
get() = !selected?.callSign.isNullOrBlank()
/** Needs a rotator to drive and a finite heading to the selected station. */
val canTurnAntenna: Boolean
get() = turnAntennaTo != null && selected?.getQTFdirection()?.isFinite() == true
/** Turns the antenna to the selected station's heading; does nothing when it cannot. */
fun turnAntenna() {
val heading = selected?.getQTFdirection() ?: return
if (heading.isFinite()) turnAntennaTo?.invoke(heading)
}
/** SO2R with rotator 2 is configured, so the second turn button is offered at all. */
val showsSecondAntenna: Boolean
get() = turnSecondAntennaTo != null && secondAntennaOffered()
/** Needs the button to be offered and a finite heading to the selected station. */
val canTurnSecondAntenna: Boolean
get() = showsSecondAntenna && selected?.getQTFdirection()?.isFinite() == true
/** Turns antenna 2 to the selected station's heading; does nothing when it cannot. */
fun turnSecondAntenna() {
val heading = selected?.getQTFdirection() ?: return
if (heading.isFinite() && showsSecondAntenna) turnSecondAntennaTo?.invoke(heading)
}
fun select(member: ChatMember?) {
if (selected?.callSignRaw != member?.callSignRaw) {
skedBand = member?.let { resolveDefaultSkedBand(it) }
@@ -1,247 +0,0 @@
package kst4contest.view.compose
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateMapOf
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/**
* The edits made in the settings window that have not been applied yet.
*
* One draft exists per opened settings window and is handed to every tab state. A tab
* state stages a change here instead of writing ChatPreferences, and reads its values
* back through [valueOf], so the window shows what the operator entered while the running
* session still uses what is stored. "Apply/Close prefs" and "Save settings" call [apply];
* closing the window and choosing to discard calls [discard].
*
* Why a list of pending writes rather than a copy of ChatPreferences: a setting is more
* than its value. Several settings carry an effect on the running session -- the beacon
* timer restarts, a UDP listener rebinds, the DX cluster server starts -- and that effect
* has to stay attached to the setting it belongs to, so that applying runs exactly what an
* immediate edit ran, once per changed setting.
*
* The order [apply] keeps is part of the contract, see there. The design and the list of
* settings that deliberately bypass the draft are in
* `docs/superpowers/specs/2026-10-16-settings-apply-drafts-design.md`.
*
* Not thread-safe, and it need not be: the settings window is drawn on the AWT event
* thread, and every caller -- a control, a button, the close request -- runs there.
* Open for the tests, which use a draft that applies every change at once to pin the
* rules of a tab state without the draft in between.
*/
open class SettingsDraft {
private class Change(
val value: Any?,
val write: () -> Unit,
val effectKey: String?,
val effect: (() -> Unit)?,
)
/*
* Snapshot state, so a composition reading a value through valueOf is redrawn when that
* value is staged. The tab states are plain facades; this is what makes them observable.
*/
private val changes = mutableStateMapOf<String, Change>()
/*
* The order in which settings were first changed. The state map keeps no order, and
* apply() must not run effects in hash order.
*/
private val order = mutableListOf<String>()
private val typedEntries = mutableListOf<() -> String?>()
/** Whether anything waits to be applied. Compose state: a composition reading it follows. */
val hasPendingChanges: Boolean
get() = changes.isNotEmpty()
/** The staged value of [key], or the stored one read from [live]. */
@Suppress("UNCHECKED_CAST")
fun <T> valueOf(key: String, live: () -> T): T {
val change = changes[key] ?: return live()
return change.value as T
}
/**
* Records a change of the setting [key]. A later stage of the same key replaces the value
* and keeps the position. Staging the stored value removes the change: a box ticked and
* unticked again leaves nothing to apply and nothing to ask about on closing.
*
* Only valid values may be staged. The fields refuse an invalid entry before it gets
* here, as they always did.
*
* @param live reads the stored value
* @param write stores the value; called by [apply]
* @param effectKey settings with the same key share one effect, which [apply] runs once.
* Defaults to [key].
* @param effect what the running session needs after the write, run by [apply] after all
* writes
*/
open fun <T> stage(
key: String,
value: T,
live: () -> T,
write: (T) -> Unit,
effectKey: String? = null,
effect: (() -> Unit)? = null,
) {
if (value == live()) {
changes.remove(key)
order.remove(key)
return
}
if (!changes.containsKey(key)) {
order += key
}
changes[key] = Change(value, { write(value) }, effectKey ?: key, effect)
}
/**
* Writes every change, then runs every effect, then forgets what was written.
*
* Writes come first, in the order the settings were first changed, so that each effect
* sees the finished state: enabling the Win-Test listener and moving its port in one go
* would otherwise restart the listener on the old port. Effects follow in the same order,
* each effect key once: two restarts of one socket in quick succession can fail on the
* port the first one has not released yet.
*
* Every write and every effect runs on its own: one that throws is recorded and the rest
* still run, so a socket that cannot be rebound does not leave the beacon timer or the
* next setting unapplied. A change whose write failed stays in the draft, and its effect
* does not run -- it would act on a value that was never stored. A failed effect's write
* did happen, so that change is gone from the draft like every other.
*
* Typed entries still held by a field are not committed here; the caller does that first
* and decides on a refusal before anything is applied, see [commitTypedEntries].
*
* @return what could not be done; empty when everything went through
*/
fun apply(): ApplyOutcome {
val staged = order.mapNotNull { key -> changes[key]?.let { key to it } }
val failedWrites = mutableListOf<String>()
val failedKeys = HashSet<String>()
staged.forEach { (key, change) ->
try {
change.write()
} catch (failure: Exception) {
failedWrites += describe(key, failure)
failedKeys += key
}
}
val failedEffects = mutableListOf<String>()
val ran = HashSet<String?>()
staged.forEach { (key, change) ->
val effect = change.effect ?: return@forEach
if (key in failedKeys || !ran.add(change.effectKey)) return@forEach
try {
effect()
} catch (failure: Exception) {
failedEffects += describe(change.effectKey ?: key, failure)
}
}
staged.forEach { (key, _) ->
if (key !in failedKeys) {
changes.remove(key)
order.remove(key)
}
}
return ApplyOutcome(failedWrites, failedEffects)
}
/** Forgets every change without writing anything. */
fun discard() {
clear()
}
/**
* Registers a field that holds typed text the draft has not seen yet: a field that commits
* on leaving it (Form.committed), a list row being edited, or a field whose text does not
* parse and therefore staged nothing.
*
* Clicking a button does not reliably take the focus from a text field, so without this an
* entry typed into such a field and followed straight by "Apply/Close prefs" would be
* committed only when the closing window removed the field -- into a draft nobody applies
* any more. And a field showing invalid text in red would let Apply go ahead with the last
* valid value, which the operator no longer sees.
*
* @param commit commits what the field holds and returns null, or returns the reason it
* cannot be accepted -- worded for the operator, naming the field
* @return closing it unregisters the field
*/
fun registerTypedEntry(commit: () -> String?): AutoCloseable {
typedEntries += commit
return AutoCloseable { typedEntries.remove(commit) }
}
/**
* Commits what the registered fields hold, so it is staged, found unchanged or refused.
*
* @return the refusals, empty when every field was accepted
*/
fun commitTypedEntries(): List<String> =
typedEntries.toList().mapNotNull { it() }
private fun describe(key: String, failure: Exception): String =
key.substringAfterLast('.') + ": " + (failure.message ?: failure.javaClass.simpleName)
private fun clear() {
changes.clear()
order.clear()
}
}
/**
* What [SettingsDraft.apply] could not do, each entry worded as "setting: reason".
*
* @param failedWrites settings that could not be stored; they stay in the draft
* @param failedEffects effects that failed after their settings were stored
*/
class ApplyOutcome(val failedWrites: List<String>, val failedEffects: List<String>) {
val failures: List<String>
get() = failedWrites + failedEffects
val isClean: Boolean
get() = failures.isEmpty()
}
/**
* A tab-state property whose reads go through [this] draft and whose writes are staged in it.
*
* The key is the owning class and the property name, so two tabs cannot collide and a
* property needs no key spelled out beside it.
*
* @param read reads the stored value
* @param write stores a value; runs on apply
* @param effectKey see [SettingsDraft.stage]
* @param effect see [SettingsDraft.stage]
*/
fun <T> SettingsDraft.setting(
read: () -> T,
effectKey: String? = null,
effect: (() -> Unit)? = null,
write: (T) -> Unit,
): ReadWriteProperty<Any, T> = object : ReadWriteProperty<Any, T> {
private fun keyOf(owner: Any, property: KProperty<*>) =
(owner::class.simpleName ?: "settings") + "." + property.name
override fun getValue(thisRef: Any, property: KProperty<*>): T =
valueOf(keyOf(thisRef, property), read)
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
stage(keyOf(thisRef, property), value, read, write, effectKey, effect)
}
}
/**
* The draft of the settings window being drawn, or null outside it. Form.committed registers
* its typed entry here; the tab screen tests draw a tab alone and need none.
*/
val LocalSettingsDraft = compositionLocalOf<SettingsDraft?> { null }
@@ -8,7 +8,6 @@ import androidx.compose.runtime.setValue
import kst4contest.controller.ChatController
import kst4contest.controller.ActiveOperatorProfile
import kst4contest.controller.OperatorProfileManagementService
import kst4contest.controller.On4KstConnectionState
import kst4contest.controller.OperatorProfilePaths
import kst4contest.model.ChatMember
import kst4contest.model.OperatorProfile
@@ -37,7 +36,6 @@ import kst4contest.view.compose.tabs.TrxSynchTab
import kst4contest.view.compose.tabs.TrxSynchTabState
import kst4contest.view.compose.tabs.WorkedDatabaseTab
import kst4contest.view.compose.tabs.WorkedDatabaseTabState
import kst4contest.view.compose.tabs.repairStoredSettings
/**
* The work the settings window needs from the main window.
@@ -58,13 +56,6 @@ interface SettingsHost {
*/
fun refreshShortcutButtonsFromSettings()
/**
* Pushes the stored SO2R switch and the rotator 2 switch into the main window's Compose
* state, so the QTF2 field and the "Turn ant2" button follow an Apply. The
* implementation has to hop onto the UI thread; the settings window runs on its own.
*/
fun refreshSo2rState()
/** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */
fun refreshTextSnippetContextMenusFromSettings()
@@ -122,12 +113,6 @@ interface SettingsHost {
* place, and the foreign-write coverage test can see it. A tab that only wrote a
* preference and skipped its session call would look correct in the file and do
* nothing until the next restart.
*
* @param connectionState the session state as Compose state reads it; the Station tab
* locks the login fields from it. The controller's own getter is not observable,
* so a tab reading it would stay on whatever the link was when the window opened.
* @param draft the edits of this opening of the window; every tab state stages into it,
* and the button bar applies it
*/
fun buildSettingsTabs(
controller: ChatController,
@@ -135,42 +120,32 @@ fun buildSettingsTabs(
notices: SettingsNotices,
paletteStore: PaletteStore,
languageStore: kst4contest.view.i18n.LanguageStore,
connectionState: () -> On4KstConnectionState,
draft: SettingsDraft,
): List<SettingsTab> {
val prefs = controller.chatPreferences
// Once, before any tab reads: see repairStoredSettings for why not inside the tabs.
repairStoredSettings(prefs, host::detectWintestBroadcastAddress)
val stationState = StationTabState(
prefs,
draft,
applyMainCategory = { category -> controller.setChatCategoryMain(category) },
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
connectionState = connectionState,
applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() },
)
val notificationState = NotificationTabState(
prefs,
draft,
controller.lstNotify_QSOSniffer_sniffedCallSignList,
applyDxClusterServer = {
applyDxClusterServerEnabled = { enabled ->
/*
* Starting a server that nothing can reach yet is pointless, so the JavaFX
* checkbox only touched the server while the session was logged in; the
* flag alone decides what happens at the next login. Restarted rather than
* started when enabled: the same call then binds a changed port, and a server
* that was not running is simply started.
* flag alone decides what happens at the next login.
*/
if (controller.isConnectedAndLoggedIn) {
if (prefs.isNotify_dxClusterServerEnabled()) {
controller.restartDxClusterServerIfEnabled()
} else {
controller.stopDxClusterServer()
}
if (enabled) controller.startDxClusterServerIfEnabled() else controller.stopDxClusterServer()
}
},
applyDxClusterServerPort = {
if (controller.isConnectedAndLoggedIn && prefs.isNotify_dxClusterServerEnabled()) {
controller.restartDxClusterServerIfEnabled()
}
},
broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) },
@@ -178,14 +153,12 @@ fun buildSettingsTabs(
val shortcutsState = ShortcutsTabState(
prefs,
draft,
refreshShortcutButtons = host::refreshShortcutButtonsFromSettings,
refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings,
)
val beaconState = BeaconTabState(
prefs,
draft,
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
restartBeaconTimer = { controller.restartBeaconTimer() },
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
@@ -194,9 +167,9 @@ fun buildSettingsTabs(
val logSynchState = LogSynchTabState(
prefs,
draft,
restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
stopWintestListener = { controller.stopWintestUdpListener() },
detectWintestBroadcastAddress = host::detectWintestBroadcastAddress,
)
val workedDatabaseState = WorkedDatabaseTabState(
@@ -231,14 +204,14 @@ fun buildSettingsTabs(
* so a tidier order or a shorter title would be a regression, not an improvement.
*/
return listOf(
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, draft, applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() }, applyOwnQrgFollower = host::applyOwnQrgFollower)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs, draft)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, host::applyOwnQrgFollower)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabNotification }) { NotificationTab(notificationState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabShortcuts }) { ShortcutsTab(shortcutsState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabBeacon }) { BeaconTab(beaconState, reportRefusal) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, draft, host::applyDebugModeToFile)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, host::applyDebugModeToFile)) },
/*
* Selecting this tab re-read the database in the JavaFX window; the list is a
* snapshot and would otherwise show what was true when the window opened.
@@ -247,7 +220,7 @@ fun buildSettingsTabs(
{ kst4contest.view.i18n.CurrentStrings.get().tabWorkedDatabase },
onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() },
) { WorkedDatabaseTab(workedDatabaseState) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, draft, languageStore)) },
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, languageStore)) },
/*
* Appended last on purpose so no established tab position shifts, the reason
* the JavaFX tab gave for the same placement.
@@ -284,18 +257,18 @@ fun buildSettingsTabs(
*/
private fun deliverTestSpot(controller: ChatController, spot: ChatMember): String? {
if (!controller.isConnectedAndLoggedIn || !controller.chatPreferences.isNotify_dxClusterServerEnabled()) {
return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNeedsConnection
return "Connect KST4Contest and enable the local DX Cluster server first."
}
val server = controller.dxClusterServer
?: return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNeedsConnection
?: return "Connect KST4Contest and enable the local DX Cluster server first."
if (!server.hasConnectedClients()) {
return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNoClient
return "No DX Cluster client is connected to KST4Contest."
}
if (!server.broadcastSingleDXClusterEntryToLoggers(spot)) {
return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNotDelivered
return "The test spot could not be delivered."
}
return null
@@ -311,19 +284,11 @@ private fun categoryName(category: kst4contest.model.ChatCategory?, fallback: St
* Built here because it needs the controller for its state and the host for its
* actions, and because it belongs with the tabs it sits under. The `close` action comes
* from the window itself: only the window can end its own Compose application.
*
* Three buttons apply the [draft] before they act. "Apply/Close prefs" applies and closes,
* "Save settings" applies and writes the file, and Connect applies before it connects: the
* session would otherwise use the stored server or second-chat switch rather than the one
* the tab shows. A field that refuses what it holds stops the button before anything is
* applied, so the operator sees why instead of a closed window or a saved file; the X still
* offers Discard. A write or effect that fails is reported in a problem notice.
*/
fun settingsButtons(
controller: ChatController,
host: SettingsHost,
notices: SettingsNotices,
draft: SettingsDraft,
): @Composable (close: () -> Unit) -> Unit {
val barState = ConnectionBarState(controller.chatPreferences, controller)
@@ -339,14 +304,24 @@ fun settingsButtons(
ConnectionBar(
state = barState,
onConnect = {
if (applyChecked(draft, notices)) {
val refusal = host.connectFromSettings()
if (refusal != null) notices.problem(refusal)
}
val refusal = host.connectFromSettings()
if (refusal != null) notices.problem(refusal)
revision++
},
onSave = { applyAndSave(draft, notices, host::savePreferencesFromSettings) },
onApplyAndClose = { if (applyChecked(draft, notices)) close() },
onSave = {
val target = host.savePreferencesFromSettings()
if (target == null) {
notices.problem(
"The configuration file could not be written. The settings stay " +
"active for this session."
)
} else {
notices.info(
"Settings are stored as default to the xml config file:\n" + target
)
}
},
onApplyAndClose = close,
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
onDisconnectOnly = {
host.disconnectOnlyFromSettings()
@@ -355,74 +330,3 @@ fun settingsButtons(
)
}
}
/**
* Commits what the fields still hold and tells whether all of it was accepted. On a refusal
* the reason is the notice on screen and nothing has been applied yet. The operator corrects
* the field, or discards everything through the window's X, whose question offers Discard
* even while a field refuses (see SettingsQuestion).
*
* A problem notice already standing counts as a refusal too. The click on a button can
* itself take the focus from a field, which then commits -- and is refused, reporting it --
* before the button's action runs. A problem notice from earlier cannot be standing here:
* its dialog covers the window, so nothing in it could have been clicked.
*/
internal fun typedEntriesAccepted(draft: SettingsDraft, notices: SettingsNotices): Boolean {
val refusals = draft.commitTypedEntries()
if (refusals.isNotEmpty()) {
notices.problem(refusals.joinToString("\n"))
return false
}
return notices.current !is SettingsNotice.Problem
}
/**
* Checks the typed entries, then applies [draft], and tells whether the caller may go on:
* false on a refusal (nothing applied) or when a write or an effect failed (the rest
* applied, the failures named in a problem notice).
*/
internal fun applyChecked(draft: SettingsDraft, notices: SettingsNotices): Boolean {
if (!typedEntriesAccepted(draft, notices)) return false
val outcome = draft.apply()
if (!outcome.isClean) {
notices.problem(applyFailedText(outcome))
return false
}
return true
}
/**
* The work of "Save settings": check the typed entries, apply the draft, write the file, say
* where it went. The window stays open.
*
* A failed effect does not stop the file: its setting was stored, and the file should hold
* it. A failed write does, because the file would then miss what the window shows.
*
* @param save writes the preferences and returns the file, or null when it could not
*/
internal fun applyAndSave(draft: SettingsDraft, notices: SettingsNotices, save: () -> String?) {
if (!typedEntriesAccepted(draft, notices)) return
val outcome = draft.apply()
if (outcome.failedWrites.isNotEmpty()) {
notices.problem(applyFailedText(outcome))
return
}
val strings = kst4contest.view.i18n.CurrentStrings.get()
val target = save()
when {
target == null -> notices.problem(
listOfNotNull(
outcome.takeIf { !it.isClean }?.let(::applyFailedText),
strings.settingsWriteFailed,
).joinToString("\n\n")
)
!outcome.isClean -> notices.problem(applyFailedText(outcome) + "\n\n" + strings.settingsWrittenTo(target))
else -> notices.info(strings.settingsWrittenTo(target))
}
}
private fun applyFailedText(outcome: ApplyOutcome): String =
kst4contest.view.i18n.CurrentStrings.get().settingsApplyFailed(outcome.failures.joinToString("; "))
@@ -11,7 +11,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.HorizontalDivider
@@ -30,16 +29,12 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.awt.EventQueue
import kst4contest.view.i18n.LocalStrings
/**
* One tab of the settings window: a title and the content to show.
@@ -109,90 +104,6 @@ class SettingsNotices {
}
}
/**
* The question about edits not applied yet, asked when the window closes, the profile is
* switched or the client exits.
*
* The decision is taken in [settle], on the calling event thread, not in the window's next
* frame: a minimised window may draw no frames, and an exit from the main window would then
* wait for the settings window to be restored. Only the dialog itself needs a frame, and the
* window is raised before it is needed. Compose state, so the open window shows the question.
*/
class SettingsQuestion {
/** What a shown question goes on with. */
class Asked(
/** Run after Apply or Discard; null means close the window, which is what the X wants. */
val then: (() -> Unit)?,
/**
* Why Apply cannot be offered: a field holds text it refuses. Discard and Back still
* work, so the operator is never trapped behind a field he only wants to get rid of.
*/
val refusal: String?,
)
/** The question being shown, or null. */
var asking: Asked? by mutableStateOf(null)
private set
/** Counts the closes asked for with nothing to settle; the window closes on each. */
var closeRequests by mutableStateOf(0)
private set
/**
* Settles the pending edits of [draft]: runs [nothingPending] at once when nothing waits
* and no field refuses what it holds, and shows the question otherwise.
*
* While a question is shown a further request is ignored. Replacing its continuation would
* turn an exit the operator is being asked about into a mere close of the window -- or the
* other way round.
*
* @return true when a question is shown now
*/
fun settle(
draft: SettingsDraft,
notices: SettingsNotices,
then: (() -> Unit)?,
nothingPending: () -> Unit,
): Boolean {
if (asking != null) return true
val refusals = draft.commitTypedEntries()
// A refusal a field reported on its own focus loss is standing as the notice.
val standing = notices.current as? SettingsNotice.Problem
val refusal = refusals.takeIf { it.isNotEmpty() }?.joinToString("\n") ?: standing?.message
if (refusal == null && !draft.hasPendingChanges) {
nothingPending()
return false
}
// The reason moves into the question, so two dialogs do not stand on top of each other.
if (standing != null) notices.dismiss()
asking = Asked(then, refusal)
return true
}
/** The X of the window: close when nothing waits, ask otherwise. */
fun settleClose(draft: SettingsDraft, notices: SettingsNotices) {
settle(draft, notices, then = null, nothingPending = { closeRequests++ })
}
/** The question was answered; called before acting on the answer. */
fun answered() {
asking = null
}
}
/**
* Wraps [action] so it runs at most once, however often it is invoked: a continuation of a
* profile switch or an exit must not run twice because a dialog was clicked twice.
*/
internal fun runOnce(action: Runnable): Runnable {
val ran = java.util.concurrent.atomic.AtomicBoolean(false)
return Runnable { if (ran.compareAndSet(false, true)) action.run() }
}
/**
* The settings window.
*
@@ -201,11 +112,9 @@ internal fun runOnce(action: Runnable): Runnable {
* most once — a second instance would let two windows write the same preferences
* against each other.
*
* The controls stage their edits in a [SettingsDraft]; the button bar applies it. Closing
* the window, switching the operator profile or leaving the client while edits are waiting
* first asks whether to apply or discard them, or to go back, which cancels the close, the
* switch or the exit ([requestClose], [settleThen]). Only [close] -- the teardown that
* follows -- and a JVM shutdown hook drop them without asking.
* Saving does not collect values. Every control writes into ChatPreferences as it
* changes; the button bar only persists, which is what the JavaFX "Save settings"
* button did.
*/
object SettingsWindow {
@@ -226,67 +135,11 @@ object SettingsWindow {
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
/** What the open window was built with, if one is open. */
private class OpenWindow(
val draft: SettingsDraft,
val notices: SettingsNotices,
val question: SettingsQuestion,
)
@Volatile
private var openWith: OpenWindow? = null
/**
* Closes the window and waits for it to be gone; see ComposeWindowHost.close. Edits not
* applied yet are dropped, so this is the teardown after [settleThen] has asked, not the
* operator's close; that one goes through [requestClose].
*/
/** Closes the window and waits for it to be gone; see ComposeWindowHost.close. */
@JvmStatic
fun close() = host.close()
/**
* Closes the window the way its X does: at once when nothing waits to be applied,
* otherwise after asking.
*/
@JvmStatic
fun requestClose() {
val open = openWith
if (open == null || !host.isOpen) return
if (open.question.settle(open.draft, open.notices, then = null, nothingPending = { host.close() })) {
host.raise()
}
}
/**
* Runs [action] once the window's pending edits are settled: at once when the window is
* closed or nothing waits, otherwise after the operator chose Apply or Discard in the
* window. Back cancels -- [action] never runs. For the profile switch and the exit, so
* that neither drops edits the operator has not decided about. While a question is
* already shown the request is ignored, see [SettingsQuestion.settle].
*
* Must be called on the event thread, and never waits there. The decision is taken here
* and now; with something to ask the window is raised -- de-iconified if minimised, so it
* draws again -- and the question set as Compose state. The action then runs from the
* dialog's callback, posted to the event queue, so whatever it opens -- the switch
* confirmation, the window teardown -- does not run inside the click handler. It runs at
* most once.
*/
@JvmStatic
fun settleThen(action: Runnable) {
val once = runOnce(action)
val open = openWith
if (open == null || !host.isOpen) {
once.run()
return
}
if (open.question.settle(open.draft, open.notices, then = { EventQueue.invokeLater(once) }, nothingPending = once::run)) {
host.raise()
}
}
/**
* @param draft the edits of this opening of the window; the tabs and the buttons were
* built with the same one
* @param buttons the bottom button row; it receives the action that closes the
* window, because only the window itself can end its Compose application
*/
@@ -294,7 +147,6 @@ object SettingsWindow {
fun show(
tabs: List<SettingsTab>,
notices: SettingsNotices,
draft: SettingsDraft,
darkMode: Boolean,
baseFontSizeSp: Float,
/**
@@ -312,17 +164,8 @@ object SettingsWindow {
onResized: (Float, Float) -> Unit,
buttons: @Composable (close: () -> Unit) -> Unit,
) {
if (host.isOpen) {
// Raises the open window; the new draft belongs to no window and is dropped.
host.raise()
return
}
val question = SettingsQuestion()
openWith = OpenWindow(draft, notices, question)
host.show(
title = { kst4contest.view.i18n.CurrentStrings.get().settingsWindowTitle },
title = { "Change Client Settings" },
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
paletteStore = paletteStore,
@@ -330,99 +173,66 @@ object SettingsWindow {
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
// The X is clicked in a window that is showing, so its frames run.
onCloseRequest = { question.settleClose(draft, notices) },
) { close ->
SettingsContent(tabs, notices, buttons, close, draft, question)
SettingsContent(tabs, notices, buttons, close)
}
}
}
/**
* @param draft the edits waiting to be applied. Provided to the tabs, so a field still holding
* a typed entry can be committed into it before an apply.
* @param question the question about the pending edits, and the closes asked for without one
*/
@Composable
internal fun SettingsContent(
tabs: List<SettingsTab>,
notices: SettingsNotices,
buttons: @Composable (close: () -> Unit) -> Unit,
close: () -> Unit,
draft: SettingsDraft = SettingsDraft(),
question: SettingsQuestion = SettingsQuestion(),
) {
var selected by remember { mutableStateOf(0) }
val closes = question.closeRequests
LaunchedEffect(closes) {
if (closes > 0) close()
}
CompositionLocalProvider(LocalSettingsDraft provides draft) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SettingsTabStrip(
tabs = tabs,
selectedIndex = selected,
onSelect = { index ->
selected = index
tabs[index].onSelected?.invoke()
},
)
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SettingsTabStrip(
tabs = tabs,
selectedIndex = selected,
onSelect = { index ->
selected = index
tabs[index].onSelected?.invoke()
},
)
/*
* One scroll region for the whole window, with a scrollbar beside it. The
* tabs used to scroll each for themselves, and none of them could show a
* bar: a tab does not know the window it sits in, so content simply ended
* mid-form with nothing to say that more was below.
*/
val scroll = rememberScrollState()
/*
* One scroll region for the whole window, with a scrollbar beside it. The
* tabs used to scroll each for themselves, and none of them could show a
* bar: a tab does not know the window it sits in, so content simply ended
* mid-form with nothing to say that more was below.
*/
val scroll = rememberScrollState()
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(end = Density.SCROLLBAR_GUTTER),
) {
tabs.getOrNull(selected)?.content?.invoke()
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(scroll),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
style = LocalScrollbarStyle.current.copy(
/* Muted on purpose: a bar in the accent colour shouts. */
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(end = Density.SCROLLBAR_GUTTER),
) {
tabs.getOrNull(selected)?.content?.invoke()
}
buttons(close)
VerticalScrollbar(
adapter = rememberScrollbarAdapter(scroll),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
style = LocalScrollbarStyle.current.copy(
/* Muted on purpose: a bar in the accent colour shouts. */
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
}
}
}
question.asking?.let { asked ->
val proceed = asked.then ?: close
PendingChangesDialog(
refusal = asked.refusal,
onApply = {
question.answered()
// A refusal or a failure stops here, with the reason shown; nothing goes on.
if (applyChecked(draft, notices)) proceed()
},
onDiscard = {
question.answered()
draft.discard()
proceed()
},
onBack = { question.answered() },
)
buttons(close)
}
}
notices.current?.let { notice ->
@@ -431,57 +241,17 @@ internal fun SettingsContent(
title = {
Text(
when (notice) {
is SettingsNotice.Info -> kst4contest.view.i18n.LocalStrings.current.settingsNoticeInfo
is SettingsNotice.Problem -> kst4contest.view.i18n.LocalStrings.current.settingsNoticeProblem
is SettingsNotice.Info -> "Info"
is SettingsNotice.Problem -> "Settings"
}
)
},
text = { Text(notice.message) },
confirmButton = { TextButton(onClick = notices::dismiss) { Text(kst4contest.view.i18n.LocalStrings.current.dialogOk) } },
confirmButton = { TextButton(onClick = notices::dismiss) { Text("OK") } },
)
}
}
/**
* Asks what becomes of the edits not applied yet when the operator closes the window,
* switches the profile or exits: apply them, discard them, or go back to the window.
* Dismissing it goes back -- nothing is lost by a stray click beside it.
*
* @param refusal set when a field holds text it refuses: shown in the dialog, and Apply is
* disabled. Discard and Back stay available, so a field the operator only wants to get
* rid of never blocks the close, the switch or the exit.
*/
@Composable
internal fun PendingChangesDialog(
refusal: String?,
onApply: () -> Unit,
onDiscard: () -> Unit,
onBack: () -> Unit,
) {
val strings = LocalStrings.current
AlertDialog(
onDismissRequest = onBack,
title = { Text(strings.settingsPendingTitle) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(strings.settingsPendingQuestion)
// Why Apply is not offered; Discard and Back still are.
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
TextButton(onClick = onApply, enabled = refusal == null) { Text(strings.settingsPendingApply) }
},
dismissButton = {
Row {
TextButton(onClick = onDiscard) { Text(strings.settingsPendingDiscard) }
TextButton(onClick = onBack) { Text(strings.settingsPendingBack) }
}
},
)
}
/**
* The row of tab buttons.
*
@@ -26,19 +26,6 @@ object StationColumns {
/** The heading the ten worked columns sit under. */
const val WORKED_GROUP = "worked"
/**
* The identity a station row is drawn and selected under: the callsign and the chat
* category. The category is part of it because the same callsign can be in two chats at
* once, and AGENTS.md makes the category part of the chat identity; keyed by callsign
* alone, the two rows collided (fixed in 21251377).
*
* Kept here rather than inline at the one call site so the station list can be tested
* with the key it really uses. The text is exactly what the inline Java expression
* produced, including "null" for a missing callsign.
*/
fun rowKey(member: ChatMember): String =
"${member.callSign}|${member.chatCategory?.categoryNumber ?: ""}"
fun all(
tropoOf: (ChatMember) -> String,
priorityScoreOf: (ChatMember) -> Double,
@@ -65,16 +52,15 @@ object StationColumns {
add(DataColumn(id = "qrg", title = "QRG", value = { it.frequency?.get().orEmpty() }, weight = 0.8f))
add(DataColumn(
id = "airscout",
title = { kst4contest.view.i18n.CurrentStrings.get().columnAirplane },
title = "AP [minutes / pot%]",
value = { AirplaneCell.format(it.airPlaneReflectInfo) },
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
comparator = compareBy(AirplaneCell.order) { it.airPlaneReflectInfo },
weight = 1.4f,
))
add(DataColumn(id = "tropo", title = "Tropo", value = tropoOf, weight = 0.7f))
add(DataColumn(
id = "score",
title = { kst4contest.view.i18n.CurrentStrings.get().columnScore },
title = "Score",
value = { priorityScoreOf(it).toInt().toString() },
comparator = compareBy { priorityScoreOf(it) },
weight = 0.6f,
@@ -113,7 +99,7 @@ object StationColumns {
add(DataColumn(id = "not-qrv", title = "NOT QRV @", value = { qrvBands(it, activeBands) }, weight = 1.6f))
add(DataColumn(
id = "category",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
title = "Category",
value = { member ->
member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }.orEmpty()
},
@@ -48,19 +48,11 @@ fun StationFilterBar(
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
/*
* ROW 1: Reset | QRB max + min | QTF | bearings
*
* It wraps (#21): with both QRB filters the line no longer fits a narrow station
* pane, and a plain row cut the bearing buttons off at the right edge. Each group
* stays together on its line; only the QRB pair and the bearings may break up
* inside their group when even that group is wider than the pane.
*/
FlowRow(
modifier = Modifier.fillMaxWidth(),
// ROW 1: Reset | QRB | QTF + bearings
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
itemVerticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.CenterVertically,
) {
// Reset filters
FilterButton(strings.filterReset, on = false, isAccent = true) {
@@ -68,105 +60,65 @@ fun StationFilterBar(
onChanged()
}
// QRB maximum + minimum (#21): together they make a range, so they stay one group
FilterGroup {
GroupSeparator()
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
FilterGroup {
CompactCheckbox(
checked = filters.maxQrbEnabled,
onCheckedChange = {
filters.maxQrbEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
onValueChange = {
val parsed = parseQrbKm(it)
if (parsed != null) {
filters.maxQrbKm = parsed
if (filters.maxQrbEnabled) onChanged()
} else if (it.isEmpty()) {
filters.maxQrbKm = 0.0
if (filters.maxQrbEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
}
FilterGroup {
CompactCheckbox(
checked = filters.minQrbEnabled,
onCheckedChange = {
filters.minQrbEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQrbAbove, style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.minQrbKm.rem(1) == 0.0) filters.minQrbKm.toInt().toString() else filters.minQrbKm.toString(),
onValueChange = {
val parsed = parseQrbKm(it)
if (parsed != null) {
filters.minQrbKm = parsed
if (filters.minQrbEnabled) onChanged()
} else if (it.isEmpty()) {
filters.minQrbKm = 0.0
if (filters.minQrbEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
}
}
}
VerticalSeparator()
// QTF checkbox + field
FilterGroup {
GroupSeparator()
CompactCheckbox(
checked = filters.qtfEnabled,
onCheckedChange = {
filters.qtfEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
onValueChange = {
val parsed = it.toDoubleOrNull()
if (parsed != null) {
filters.qtfDegrees = parsed
filters.bearing = null
if (filters.qtfEnabled) onChanged()
} else if (it.isEmpty()) {
filters.qtfDegrees = 0.0
filters.bearing = null
if (filters.qtfEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
Text("± BW/2", style = MaterialTheme.typography.bodySmall)
}
// Bearings
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
Bearing.entries.forEach { bearing ->
FilterButton(bearing.name, on = filters.bearing == bearing) {
filters.toggleBearing(bearing)
onChanged()
// QRB checkbox + field
CompactCheckbox(
checked = filters.maxQrbEnabled,
onCheckedChange = {
filters.maxQrbEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
onValueChange = {
val parsed = it.toDoubleOrNull()
if (parsed != null) {
filters.maxQrbKm = parsed
if (filters.maxQrbEnabled) onChanged()
} else if (it.isEmpty()) {
filters.maxQrbKm = 0.0
if (filters.maxQrbEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
VerticalSeparator()
// QTF checkbox + field + bearings
CompactCheckbox(
checked = filters.qtfEnabled,
onCheckedChange = {
filters.qtfEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
onValueChange = {
val parsed = it.toDoubleOrNull()
if (parsed != null) {
filters.qtfDegrees = parsed
filters.bearing = null
if (filters.qtfEnabled) onChanged()
} else if (it.isEmpty()) {
filters.qtfDegrees = 0.0
filters.bearing = null
if (filters.qtfEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
Text("± BW/2", style = MaterialTheme.typography.bodySmall)
Bearing.entries.forEach { bearing ->
FilterButton(bearing.name, on = filters.bearing == bearing) {
filters.toggleBearing(bearing)
onChanged()
}
}
}
@@ -283,31 +235,6 @@ private fun FilterButton(label: String, on: Boolean, isAccent: Boolean = false,
}
}
/** Controls that belong together and move to the next line of a wrapping row as one. */
@Composable
private fun FilterGroup(content: @Composable () -> Unit) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
content()
}
}
/**
* The group divider for a wrapping row. A fixed height, because [VerticalSeparator] fills
* the row's intrinsic height, which a line of a flow row does not offer.
*/
@Composable
private fun GroupSeparator() {
Box(
modifier = Modifier
.width(Density.HAIRLINE)
.height(18.dp)
.background(MaterialTheme.colorScheme.outline),
)
}
/** Thin vertical separator matching JavaFX's group dividers. */
@Composable
private fun VerticalSeparator() {
@@ -18,7 +18,6 @@ enum class StationFilter {
NEW_BANDS,
AIRSCOUT_NEXT_5_MIN,
MAX_QRB,
MIN_QRB,
QTF,
SEARCH_TEXT,
HIDE_INACTIVE,
@@ -34,17 +33,6 @@ enum class StationFilter {
HIDE_WORKED_3,
}
/**
* What a QRB field of the filter bar makes of the text typed into it: a distance in km, or
* `null` when the text is none.
*
* `toDouble` alone also reads "NaN" and "Infinity", and a pasted "Infinity" as the minimum
* hid every station while "NaN" let every one through. A negative distance means nothing
* either. All of these are refused, so the field keeps its last valid value (#21).
*/
internal fun parseQrbKm(text: String): Double? =
text.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 }
/** The bands the "hide worked" row offers, under the labels the bar shows. */
enum class WorkedBandFilter(val label: String, val filter: StationFilter, val modelBand: kst4contest.model.Band) {
B_50("50", StationFilter.HIDE_WORKED_50, kst4contest.model.Band.B_50),
@@ -111,16 +99,6 @@ class StationFilterState {
/** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */
var maxQrbKm: Double by mutableStateOf(0.0)
var minQrbEnabled: Boolean
get() = isOn(StationFilter.MIN_QRB)
set(value) = switch(StationFilter.MIN_QRB, value)
/**
* Only meaningful while [minQrbEnabled]. Exclusive: a station exactly this far away is
* hidden, so that "QRB > 300" and "QRB <= 300" split the list without overlap (#21).
*/
var minQrbKm: Double by mutableStateOf(0.0)
var qtfEnabled: Boolean
get() = isOn(StationFilter.QTF)
set(value) = switch(StationFilter.QTF, value)
@@ -152,23 +130,6 @@ class StationFilterState {
fun isOn(filter: StationFilter): Boolean = switched[filter] == true
/**
* Whether a station [qrb] km away passes the distance filters that are switched on.
*
* Both on make a range: `min < qrb <= max`. A station without a known distance is
* hidden as soon as either is on — `null` is unknown, not zero, and an unknown distance
* can be in neither range.
*/
fun acceptsQrb(qrb: Double?): Boolean {
val max = isOn(StationFilter.MAX_QRB)
val min = isOn(StationFilter.MIN_QRB)
if (!max && !min) return true
if (qrb == null) return false
if (max && qrb > maxQrbKm) return false
if (min && qrb <= minQrbKm) return false
return true
}
fun setHideWorkedOn(band: WorkedBandFilter, hide: Boolean) = switch(band.filter, hide)
fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter)
@@ -1,353 +0,0 @@
package kst4contest.view.compose
import java.awt.GraphicsEnvironment
import java.awt.Point
import java.awt.Rectangle
import java.awt.Toolkit
import java.awt.Window
/**
* One connected screen: its whole area and the part a window may use.
*
* @param bounds the screen in the shared desktop coordinate space. A screen left of or
* above the primary one has negative coordinates.
* @param usable [bounds] minus task bars, docks and panels
*/
data class ScreenArea(val bounds: Rectangle, val usable: Rectangle)
/**
* A window position kept across restarts, together with the screen it was on.
*
* @param position the window's top-left corner in desktop coordinates
* @param screen the bounds of the screen the window was on when it was stored
*/
data class StoredWindowPosition(val position: Point, val screen: Rectangle)
/**
* Where window positions are kept across restarts, when the operator opted in.
*
* The application sets it per profile; [load] answers null and [save] does nothing while
* the opt-in is off, so the hosts need not know about the setting.
*/
interface WindowPositionMemory {
fun load(windowId: String): StoredWindowPosition?
fun save(windowId: String, stored: StoredWindowPosition)
}
/**
* Where a secondary window opens, decided without touching AWT (issue #59).
*
* The rule: a window the operator moved during this session reopens where it was moved to,
* as long as its title bar can still be reached on a connected screen — shifted just far
* enough to lie wholly within that screen's usable area. Otherwise it opens centred on the
* screen the main window is on. Either way its size is cut down to the usable area of the
* screen it lands on, so no button sits past the edge.
*
* The cut-down size is only the opening size. The host does not write it back, so the
* stored size survives a stay on a smaller screen; a resize the operator makes there is
* an ordinary resize and is saved.
*
* Before this, every secondary window got the platform's default position, which on a
* multi-monitor desk is the primary screen — the station map opened on a screen the
* operator was not looking at, or on one that was switched off.
*
* Pure on purpose: the rule is the part that can be wrong, and it needs screens that no
* test machine has. [WindowPlacementOnScreen] feeds it the real screens.
*/
object WindowPlacement {
/**
* How much of the window's top edge counts as the title bar. A remembered position
* is only kept when this strip can be grabbed, because a window whose title bar is
* off-screen cannot be moved back by the operator.
*/
const val TITLE_BAR_HEIGHT = 32
/**
* How wide a piece of the title bar must be visible to count as reachable. Narrower
* windows need their whole width.
*/
const val MIN_VISIBLE_TITLE_WIDTH = 100
/**
* Where the window opens and how big it is.
*
* @param screens the connected screens; empty when there is no display
* @param mainWindow the main window's frame bounds, null while it does not exist
* @param remembered where the operator moved this window to during the session, or null
* @param width the size the window asks for
* @param height the size the window asks for
* @return the frame bounds to open with, or null to leave placement to the platform —
* which is the behaviour before the main window exists
*/
fun place(
screens: List<ScreenArea>,
mainWindow: Rectangle?,
remembered: Point?,
width: Int,
height: Int,
): Rectangle? {
if (screens.isEmpty()) {
return null
}
if (remembered != null) {
val screen = screenShowingTitleBar(screens, remembered, width)
if (screen != null) {
return keptWithin(usableArea(screen), remembered, width, height)
}
}
if (mainWindow == null) {
return null
}
return centredOn(usableArea(screenOf(screens, mainWindow)), width, height)
}
/**
* Where a window opens from a position stored in an earlier run.
*
* Only on the very screen it was stored on: same place in the desktop, same size. A
* changed layout -- a monitor unplugged, rearranged or switched to another resolution --
* makes the stored position meaningless, and the caller falls back to the session rule.
* On that screen the window is kept within the usable area, like a session position.
*
* @return the frame bounds to open with, or null when the stored screen is not connected
*/
fun restore(screens: List<ScreenArea>, stored: StoredWindowPosition, width: Int, height: Int): Rectangle? {
val screen = screens.firstOrNull { it.bounds == stored.screen } ?: return null
return keptWithin(usableArea(screen), stored.position, width, height)
}
/**
* The window cut down to the usable area of the screen it is on, or null when it fits.
*
* For a window that is already open and was moved onto a smaller screen -- a laptop
* panel, a 640x480 field monitor. Opening sizes are cut by [place] and [restore]; this
* covers the move. Shifted the least distance into the usable area, like [keptWithin].
*/
fun fitted(screens: List<ScreenArea>, window: Rectangle): Rectangle? {
if (screens.isEmpty()) {
return null
}
val usable = usableArea(screenOf(screens, window))
if (window.width <= usable.width && window.height <= usable.height) {
return null
}
return keptWithin(usable, window.location, window.width, window.height)
}
/**
* A minimum size no larger than the smallest usable area: AWT enforces the minimum even when it does
* not fit the screen, which leaves the window's edges unreachable.
*/
fun minimumWithin(screens: List<ScreenArea>, width: Int, height: Int): java.awt.Dimension {
if (screens.isEmpty()) {
return java.awt.Dimension(width, height)
}
val areas = screens.map(::usableArea)
return java.awt.Dimension(
clamp(width, areas.minOf { it.width }),
clamp(height, areas.minOf { it.height }),
)
}
/**
* The window centred on [usable], cut down to fit it.
*/
fun centredOn(usable: Rectangle, width: Int, height: Int): Rectangle {
val w = clamp(width, usable.width)
val h = clamp(height, usable.height)
return Rectangle(
usable.x + (usable.width - w) / 2,
usable.y + (usable.height - h) / 2,
w,
h,
)
}
/**
* The window at [position], cut down to fit [usable] and shifted the least distance
* that puts it wholly inside. The cut comes first, so the shift always has room.
*/
fun keptWithin(usable: Rectangle, position: Point, width: Int, height: Int): Rectangle {
val w = clamp(width, usable.width)
val h = clamp(height, usable.height)
return Rectangle(
position.x.coerceIn(usable.x, usable.x + usable.width - w),
position.y.coerceIn(usable.y, usable.y + usable.height - h),
w,
h,
)
}
/**
* The screen a window is on: the one holding its centre, else the one it overlaps
* most, else the first. The centre decides first because a window straddling two
* screens is "on" the one the operator sees most of it on, and the centre says so
* without an area comparison in the common case.
*/
fun screenOf(screens: List<ScreenArea>, window: Rectangle): ScreenArea {
val centre = Point(window.x + window.width / 2, window.y + window.height / 2)
screens.firstOrNull { it.bounds.contains(centre) }?.let { return it }
return screens.maxByOrNull { overlapArea(it.bounds, window) }
?.takeIf { overlapArea(it.bounds, window) > 0 }
?: screens.first()
}
/**
* The screen on which the title bar of a window at [position] can be grabbed, or null
* when it cannot be grabbed on any — the screen was unplugged, or the window was
* pushed past an edge.
*/
private fun screenShowingTitleBar(
screens: List<ScreenArea>,
position: Point,
width: Int,
): ScreenArea? {
val titleBar = Rectangle(position.x, position.y, width.coerceAtLeast(1), TITLE_BAR_HEIGHT)
val neededWidth = minOf(MIN_VISIBLE_TITLE_WIDTH, titleBar.width)
val neededHeight = TITLE_BAR_HEIGHT / 2
return screens
.filter {
val visible = usableArea(it).intersection(titleBar)
!visible.isEmpty && visible.width >= neededWidth && visible.height >= neededHeight
}
.maxByOrNull { overlapArea(usableArea(it), titleBar) }
}
/**
* The usable area, or the whole screen when the insets leave nothing — a broken inset
* report must not shrink every window to a single pixel.
*/
private fun usableArea(screen: ScreenArea): Rectangle =
if (screen.usable.isEmpty) screen.bounds else screen.usable
private fun clamp(wanted: Int, available: Int): Int =
wanted.coerceAtMost(available).coerceAtLeast(1)
private fun overlapArea(a: Rectangle, b: Rectangle): Long {
val overlap = a.intersection(b)
return if (overlap.isEmpty) 0L else overlap.width.toLong() * overlap.height
}
}
/**
* The AWT side of [WindowPlacement]: the real screens and the real main window.
*
* Kept thin so that what no headless test can see is only reading the screens and
* handing them over. Call it on the AWT event thread, like everything that touches a
* window.
*/
object WindowPlacementOnScreen {
/**
* The main window's frame, published by its host while it is open. Read only for its
* bounds; nothing here moves it.
*/
@Volatile
var mainWindow: Window? = null
/**
* The positions kept across restarts, set by the application for the active profile.
* Null in tests and before startup, which means nothing is restored or stored.
*/
@JvmStatic
@Volatile
var memory: WindowPositionMemory? = null
/**
* Where a window opens from its stored position, or null when none applies.
*/
fun restore(windowId: String, width: Int, height: Int): Rectangle? {
val stored = memory?.load(windowId) ?: return null
return WindowPlacement.restore(connectedScreens(), stored, width, height)
}
/**
* The minimum size a window may enforce, capped to the smallest connected screen. The
* smallest rather than the current one, because the window may be moved there later
* and must then still be cut down to fit.
*/
fun minimumSizeOnScreens(width: Int, height: Int): java.awt.Dimension =
WindowPlacement.minimumWithin(connectedScreens(), width, height)
/**
* The window cut down to the screen it is now on, or null when it fits.
*/
fun fitted(window: Rectangle): Rectangle? = WindowPlacement.fitted(connectedScreens(), window)
/**
* Stores the position the operator moved a window to, with the screen it is now on.
*/
fun store(windowId: String, position: Point, width: Int, height: Int) {
val target = memory ?: return
val screens = connectedScreens()
if (screens.isEmpty()) {
return
}
val screen = WindowPlacement.screenOf(screens, Rectangle(position.x, position.y, width, height))
target.save(windowId, StoredWindowPosition(position, screen.bounds))
}
/** The connected screens, the default one first. Empty without a display. */
fun connectedScreens(): List<ScreenArea> = runCatching {
if (GraphicsEnvironment.isHeadless()) {
return emptyList()
}
val environment = GraphicsEnvironment.getLocalGraphicsEnvironment()
val toolkit = Toolkit.getDefaultToolkit()
val default = environment.defaultScreenDevice
environment.screenDevices
.sortedByDescending { it == default }
.map { device ->
val configuration = device.defaultConfiguration
val bounds = configuration.bounds
val insets = toolkit.getScreenInsets(configuration)
ScreenArea(
bounds = bounds,
usable = Rectangle(
bounds.x + insets.left,
bounds.y + insets.top,
bounds.width - insets.left - insets.right,
bounds.height - insets.top - insets.bottom,
),
)
}
}.getOrDefault(emptyList())
/**
* The main window's bounds, or null while there is no main window on screen.
*/
fun mainWindowBounds(): Rectangle? =
mainWindow?.takeIf { it.isDisplayable && it.isShowing }?.bounds
/**
* Where a secondary window opens, from the live screens and main window.
*
* @return null to leave placement to the platform
*/
fun place(remembered: Point?, width: Int, height: Int): Rectangle? =
WindowPlacement.place(connectedScreens(), mainWindowBounds(), remembered, width, height)
/**
* Centres a dialog on the main window's screen, cut down to fit.
*
* Before the main window exists — the profile picker at startup — the dialog is
* centred the way it always was, on the primary screen.
*/
@JvmStatic
fun centreOnMainWindowScreen(dialog: Window) {
val placed = place(null, dialog.width, dialog.height)
if (placed == null) {
dialog.setLocationRelativeTo(null)
} else {
dialog.bounds = placed
}
}
}
@@ -1,6 +1,5 @@
package kst4contest.view.compose.map
import kst4contest.view.compose.WindowPlacementOnScreen
import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@@ -11,7 +10,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -61,14 +59,10 @@ object StationMapWindow {
heightDp = heightDp,
onResized = onResized,
) { _ ->
val strings = LocalStrings.current
val window = this.window
/*
* Never larger than the smallest screen: AWT enforces a minimum size even past
* the edge, and some operators work on 640x480.
*/
androidx.compose.runtime.LaunchedEffect(window) {
window?.minimumSize = WindowPlacementOnScreen.minimumSizeOnScreens(900, 600)
}
androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) }
val tileFetcher = remember { TileFetcher() }
/*
@@ -80,37 +74,6 @@ object StationMapWindow {
onDispose { tileFetcher.close() }
}
Content(mainWindowState = mainWindowState, tileFetcher = tileFetcher)
} // host.show content
}
/**
* The window's body, without the window.
*
* Split out of [show] so it can be drawn headless in a test, with a tile fetcher that
* stays off the network and the user's tile cache, a roster that is not the running
* application's, and a [MapState] the test can read. The defaults are exactly what
* [show] used inline before the split.
*
* @param chatMembers the roster a marker click looks its station up in; a marker stands
* for a raw callsign, and this is where its category variants come from
* @param mapState where the map is looking
* @param pathAnalysis asked each time the selection changes; null means there is no
* running application, and then the map neither pans nor analyses, exactly as
* when no chat controller existed before the split
*/
@Composable
internal fun Content(
mainWindowState: MainWindowState,
tileFetcher: TileFetcher,
chatMembers: () -> List<kst4contest.model.ChatMember> = {
ApplicationRuntimeLauncher.getCurrent()?.chatController?.lst_chatMemberList?.snapshot().orEmpty()
},
mapState: MapState = remember { MapState() },
pathAnalysis: () -> MapPathAnalysis? = ::runtimePathAnalysis,
) {
val strings = LocalStrings.current
/*
* The fetcher is plain Java state, so nothing about it recomposes on its
* own. Polled rather than wired through a callback: this drives one line of
@@ -136,19 +99,7 @@ object StationMapWindow {
val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg
val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault
/*
* The markers are the stations the list shows, not all of them (#21): the
* JavaFX map took the sorted, filtered list (StationMapBridge.refreshNow), and
* the status line below already says "filtered view active". A selected
* station that a filter hides gets no marker either, as it did there.
*/
val markers by remember(
mainWindowState.stations.rows,
mainWindowState.stations.rowFilter,
mainWindowState.stations.contentRevision,
selectedMember,
mainWindowState.activeBands,
) {
val markers by remember(mainWindowState.stations.rows, mainWindowState.stations.contentRevision, selectedMember, mainWindowState.activeBands) {
derivedStateOf {
val builder = MapCallsignRawSnapshotBuilder()
val activeBands = mainWindowState.activeBands
@@ -159,7 +110,7 @@ object StationMapWindow {
}
builder.buildSnapshots(
mainWindowState.stations.visibleRows,
mainWindowState.stations.rows,
selectedMember,
bandSet
)
@@ -167,33 +118,19 @@ object StationMapWindow {
}
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) }
val mapState = remember { MapState() }
// Read through updated state so a caller passing a new lambda is never served a stale one.
val currentPathAnalysis by rememberUpdatedState(pathAnalysis)
/*
* Whether the selected station has a marker. A filter can hide it (#21), and a
* station without a locator never has one; then there is nothing to analyse.
*/
val selectedHasMarker = selectedMember != null &&
markers.any { it.callSignRaw() == selectedMember.callSignRaw }
LaunchedEffect(selectedMember, selectedHasMarker) {
LaunchedEffect(selectedMember) {
if (selectedMember == null) {
pathAnalysisResult = null
return@LaunchedEffect
}
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
if (selectedSnapshot == null) {
// No marker: no request, and no "Loading" for a station the map does not show.
pathAnalysisResult = null
return@LaunchedEffect
}
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
if (chatController != null) {
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
val analysis = currentPathAnalysis()
if (analysis != null) {
if (selectedSnapshot.hasUsablePosition()) {
if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) {
/*
* Pan to the station and leave the zoom alone. The Leaflet map
* did exactly this (focusCallsignRaw) and touched the zoom only
@@ -214,11 +151,17 @@ object StationMapWindow {
pathAnalysisResult = PathAnalysisResult.loading(
prefs.stn_loginLocatorMainCat,
selectedSnapshot.locator6(),
selectedSnapshot.callSignRaw()
selectedSnapshot?.locator6() ?: "",
selectedSnapshot?.callSignRaw() ?: ""
)
analysis.request(selectedMember, selectedSnapshot) { result ->
val overrideBand: kst4contest.model.Band? = null
chatController.reachabilityService.requestPathAnalysisForMap(
selectedMember,
selectedSnapshot,
overrideBand
) { result ->
pathAnalysisResult = result
}
}
@@ -334,8 +277,10 @@ object StationMapWindow {
maxQrbKm = maxQrbKm,
darkMode = isDarkTheme,
onMarkerClicked = { snapshot ->
val variants = chatMembers()
.filter { it.callSignRaw == snapshot.callSignRaw() }
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
val variants = chatController?.lst_chatMemberList?.snapshot()
?.filter { it.callSignRaw == snapshot.callSignRaw() }
.orEmpty()
/*
* A marker stands for a raw callsign, not a category. Keep the
* category already selected when it is one of the variants.
@@ -353,7 +298,7 @@ object StationMapWindow {
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
}
},
modifier = Modifier.fillMaxSize().testTag(MAP_CANVAS_TAG)
modifier = Modifier.fillMaxSize()
)
MapOverlayControls(
@@ -416,10 +361,8 @@ object StationMapWindow {
} // main layout column
} // window column
} // Surface
} // Content
/** Marks the map canvas so a test can click a marker on it. */
internal const val MAP_CANVAS_TAG = "station-map-canvas"
} // host.show content
}
@JvmStatic
fun applyDarkMode(darkMode: Boolean) {
@@ -454,34 +397,3 @@ object StationMapWindow {
}
}
}
/**
* Asks for the path analysis of the station selected on the map.
*
* The seam between the map window and the running application: the window pans to the
* station and starts the analysis only when there is one to ask, which a test supplies
* without building a chat controller.
*/
internal fun interface MapPathAnalysis {
fun request(
member: kst4contest.model.ChatMember,
snapshot: kst4contest.view.map.MapCallsignRawSnapshot?,
onResult: (PathAnalysisResult) -> Unit,
)
}
/**
* The running application's reachability service, or null before there is one.
*
* Looked up on every call, as the window did inline: a profile switch replaces the runtime.
* No band override -- the map always analyses on the automatically resolved band.
*/
private fun runtimePathAnalysis(): MapPathAnalysis? {
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController ?: return null
return MapPathAnalysis { member, snapshot, onResult ->
chatController.reachabilityService.requestPathAnalysisForMap(member, snapshot, null) { result ->
onResult(result)
}
}
}
@@ -16,8 +16,8 @@ import kst4contest.view.compose.Form
/**
* The Airscout tab: the UDP link to AirScout and how the queried frequency is chosen.
*
* Labels and defaults are the JavaFX ones. The closing note says that the server
* identifier, the client identifier and the frequency mode take effect when applied while a
* Labels, defaults and the closing note are the JavaFX ones, including that the server
* identifier, the client identifier and the frequency mode take effect at once while a
* changed UDP port needs a reconnect.
*
* The identifier, port and band fields keep what the operator typed and mark it as an
@@ -1,16 +1,14 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* The Airscout tab as a typed facade over ChatPreferences.
*
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them.
* Every property writes through immediately and there is deliberately no apply(), the
* way the JavaFX controls behaved.
*
* Three properties are guarded before they are staged, and the guard is not decoration. The
* Three properties are guarded before the write, and the guard is not decoration. The
* ChatPreferences setters do not keep the previous value on bad input, they substitute
* the factory default: an empty identifier becomes "AS"/"KST", a port outside 1..65535
* becomes 9872, a band value that is not a positive number becomes "1440000". The
@@ -21,48 +19,27 @@ import kst4contest.view.compose.setting
* The accepted ranges are the JavaFX ones, unchanged: AirScout ports and the band
* value are protocol values and neither the defaults nor the bounds are touched.
*/
class AirscoutTabState(private val prefs: ChatPreferences, private val draft: SettingsDraft) {
class AirscoutTabState(private val prefs: ChatPreferences) {
var airScout_asUDPListenerEnabled: Boolean by draft.setting(
{ prefs.isAirScout_asUDPListenerEnabled() },
) { prefs.setAirScout_asUDPListenerEnabled(it) }
/*
* The drafted values behind the guarded properties below. Only a valid, normalised value
* is staged, so neither the draft nor apply can hand the setters what they would replace
* with the factory default.
*/
private var serverName: String by draft.setting(
{ prefs.getAirScout_asServerNameString() },
) { prefs.setAirScout_asServerNameString(it) }
private var clientName: String by draft.setting(
{ prefs.getAirScout_asClientNameString() },
) { prefs.setAirScout_asClientNameString(it) }
private var communicationPort: Int by draft.setting(
{ prefs.getAirScout_asCommunicationPort() },
) { prefs.setAirScout_asCommunicationPort(it) }
private var bandString: String by draft.setting(
{ prefs.getAirScout_asBandString() },
) { prefs.setAirScout_asBandString(it) }
var airScout_asUDPListenerEnabled: Boolean
get() = prefs.isAirScout_asUDPListenerEnabled()
set(value) { prefs.setAirScout_asUDPListenerEnabled(value) }
/** Logical identifier of the target AirScout server; invalid input is ignored. */
var airScout_asServerNameString: String
get() = serverName
get() = prefs.getAirScout_asServerNameString()
set(value) {
if (isValidIdentifier(value)) {
serverName = value.trim()
prefs.setAirScout_asServerNameString(value.trim())
}
}
/** Identifier of this KST4Contest instance; invalid input is ignored. */
var airScout_asClientNameString: String
get() = clientName
get() = prefs.getAirScout_asClientNameString()
set(value) {
if (isValidIdentifier(value)) {
clientName = value.trim()
prefs.setAirScout_asClientNameString(value.trim())
}
}
@@ -72,10 +49,10 @@ class AirscoutTabState(private val prefs: ChatPreferences, private val draft: Se
* that changes here.
*/
var airScout_asCommunicationPort: Int
get() = communicationPort
get() = prefs.getAirScout_asCommunicationPort()
set(value) {
if (isValidPort(value)) {
communicationPort = value
prefs.setAirScout_asCommunicationPort(value)
}
}
@@ -83,9 +60,9 @@ class AirscoutTabState(private val prefs: ChatPreferences, private val draft: Se
* Pick the AirScout frequency per station instead of forcing one value. Uses the
* station's current QRG first, then station-name and chat-category evidence.
*/
var airScout_autoBandSelectionEnabled: Boolean by draft.setting(
{ prefs.isAirScout_autoBandSelectionEnabled() },
) { prefs.setAirScout_autoBandSelectionEnabled(it) }
var airScout_autoBandSelectionEnabled: Boolean
get() = prefs.isAirScout_autoBandSelectionEnabled()
set(value) { prefs.setAirScout_autoBandSelectionEnabled(value) }
/**
* The forced AirScout band value, a protocol number such as 1440000 for 144 MHz.
@@ -93,9 +70,9 @@ class AirscoutTabState(private val prefs: ChatPreferences, private val draft: Se
* value produced in the JavaFX field.
*/
var airScout_asBandString: String
get() = bandString
get() = prefs.getAirScout_asBandString()
set(value) {
parseBandValue(value)?.let { bandString = it }
parseBandValue(value)?.let { prefs.setAirScout_asBandString(it) }
}
/**
@@ -31,7 +31,6 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
stored = state.beaconTextMainCat,
commit = state::commitBeaconTextMainCat,
onRefused = onRefused,
current = { state.beaconTextMainCat },
)
}
@@ -44,7 +43,6 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
stored = state.beaconTextSecondCat,
commit = state::commitBeaconTextSecondCat,
onRefused = onRefused,
current = { state.beaconTextSecondCat },
)
}
@@ -54,12 +52,6 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
stored = state.beaconIntervalMinutes.toString(),
commit = state::commitBeaconInterval,
onRefused = onRefused,
/*
* Compared as the number commitBeaconInterval stores, so " 9" or "09" after
* Enter does not count as a second edit and restart the timer again.
*/
isUnchanged = { typed, now -> typed.trim().toIntOrNull()?.toString() == now },
current = { state.beaconIntervalMinutes.toString() },
)
}
}
@@ -2,9 +2,6 @@ package kst4contest.view.compose.tabs
import kst4contest.controller.ChatController
import kst4contest.model.ChatPreferences
import kst4contest.view.i18n.CurrentStrings
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* State of the beacon tab: the two CQ templates and the one interval they share.
@@ -12,57 +9,34 @@ import kst4contest.view.compose.setting
* The two commit methods are the ports of `applyBeaconTextSetting` and
* `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they
* return the refusal text instead, so the tab decides how to show it and a test can
* read it. An accepted value is staged in the window's [SettingsDraft] and takes effect
* when the operator applies it; a refused one never gets there.
* read it.
*
* @param validateBeaconTemplate throws IllegalArgumentException with the reason,
* the contract of `ChatController.validateBeaconTemplate`.
* @param restartBeaconTimer called when a changed interval is applied; the JavaFX code
* restarted the shared timer there and nowhere else.
* @param restartBeaconTimer called only after the interval actually changed; the
* JavaFX code restarted the shared timer there and nowhere else.
*/
class BeaconTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val validateBeaconTemplate: (String) -> Unit,
private val restartBeaconTimer: () -> Unit,
val mainCategoryName: String,
val secondCategoryName: String,
) {
var bcn_beaconsEnabledMainCat: Boolean by draft.setting(
{ prefs.isBcn_beaconsEnabledMainCat() },
) { prefs.setBcn_beaconsEnabledMainCat(it) }
var bcn_beaconsEnabledMainCat: Boolean
get() = prefs.isBcn_beaconsEnabledMainCat()
set(value) { prefs.setBcn_beaconsEnabledMainCat(value) }
var bcn_beaconsEnabledSecondCat: Boolean by draft.setting(
{ prefs.isBcn_beaconsEnabledSecondCat() },
) { prefs.setBcn_beaconsEnabledSecondCat(it) }
private var textMainCat: String by draft.setting(
{ prefs.getBcn_beaconTextMainCat() },
) { prefs.setBcn_beaconTextMainCat(it) }
private var textSecondCat: String by draft.setting(
{ prefs.getBcn_beaconTextSecondCat() },
) { prefs.setBcn_beaconTextSecondCat(it) }
/*
* Both stored intervals written together, as applySharedBeaconInterval did. The
* second-category value is legacy XML which nothing reads any more, but it is written
* to keep the two in step.
*/
private var interval: Int by draft.setting(
{ prefs.getBcn_beaconIntervalInMinutesMainCat() },
effect = { restartBeaconTimer() },
) { minutes ->
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
}
var bcn_beaconsEnabledSecondCat: Boolean
get() = prefs.isBcn_beaconsEnabledSecondCat()
set(value) { prefs.setBcn_beaconsEnabledSecondCat(value) }
val beaconTextMainCat: String
get() = textMainCat
get() = prefs.getBcn_beaconTextMainCat()
val beaconTextSecondCat: String
get() = textSecondCat
get() = prefs.getBcn_beaconTextSecondCat()
/**
* The interval both categories share, never below the minimum.
@@ -75,16 +49,16 @@ class BeaconTabState(
val beaconIntervalMinutes: Int
get() = maxOf(
ChatController.MIN_BEACON_INTERVAL_MINUTES,
interval,
prefs.getBcn_beaconIntervalInMinutesMainCat(),
)
/** @return null when staged, otherwise the reason it was refused. */
/** @return null when stored, otherwise the reason it was refused. */
fun commitBeaconTextMainCat(template: String): String? =
commitBeaconText(template) { textMainCat = it }
commitBeaconText(template) { prefs.setBcn_beaconTextMainCat(it) }
/** @return null when staged, otherwise the reason it was refused. */
/** @return null when stored, otherwise the reason it was refused. */
fun commitBeaconTextSecondCat(template: String): String? =
commitBeaconText(template) { textSecondCat = it }
commitBeaconText(template) { prefs.setBcn_beaconTextSecondCat(it) }
private fun commitBeaconText(template: String, store: (String) -> Unit): String? {
try {
@@ -95,7 +69,7 @@ class BeaconTabState(
* so a refused template that replaced a working one would silently stop
* the beacon instead of reporting anything.
*/
return CurrentStrings.get().beaconInvalidMessage(refusal.message.orEmpty())
return "The beacon message is invalid: " + refusal.message
}
store(template)
@@ -103,18 +77,28 @@ class BeaconTabState(
}
/**
* Stages the shared interval for both categories; applying it restarts the timer.
* Applies the shared interval to both categories and restarts the timer.
*
* @return null when staged, otherwise the reason it was refused.
* @return null when applied, otherwise the reason it was refused.
*/
fun commitBeaconInterval(entered: String): String? {
val minutes = entered.trim().toIntOrNull()
if (minutes == null || minutes < ChatController.MIN_BEACON_INTERVAL_MINUTES) {
return CurrentStrings.get().beaconIntervalTooShort(ChatController.MIN_BEACON_INTERVAL_MINUTES.toString())
return "Enter a whole beacon interval of at least " +
ChatController.MIN_BEACON_INTERVAL_MINUTES + " minute."
}
interval = minutes
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
/*
* The second-category value is legacy XML which nothing reads any more, but
* it is written to keep the two in step, exactly as applySharedBeaconInterval
* did.
*/
prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
restartBeaconTimer()
return null
}
}
@@ -119,13 +119,6 @@ fun ColoursTab(
stored = row.hex,
commit = { typed -> state.set(row.role, typed) },
onRefused = reportRefusal,
/*
* A colour is compared the way PaletteOverrides normalises it -- trimmed,
* case ignored -- so retyping the shown colour in lower case is no edit
* and does not turn it into an override of itself.
*/
isUnchanged = { typed, now -> typed.trim().equals(now.trim(), ignoreCase = true) },
current = { state.roles().single { it.role == row.role }.hex },
)
}
}
@@ -12,14 +12,13 @@ import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.AwtWindow
import java.awt.FileDialog
@@ -37,7 +36,8 @@ import java.io.File
* back on bad input. Writing through on every keystroke without that guard would be
* worse than the old behaviour, because the ChatPreferences setters for the AirScout
* identifiers, the AirScout port and the AirScout band value substitute the factory
* default on bad input instead of keeping the previous value.
* default on bad input instead of keeping the previous value. It also reports focus
* loss, which is where a port change may rebind a socket.
* - [FilePathRow] is the read-only path plus "Choose..." button of the Log synch tab.
*/
@Composable
@@ -47,6 +47,7 @@ internal fun GuardedTextField(
enabled: Boolean = true,
accepts: (String) -> Boolean = { true },
onAccepted: (String) -> Unit,
onCommit: () -> Unit = {},
) {
/*
* Keyed on the stored value: the draft is only reset when the stored value really
@@ -54,21 +55,7 @@ internal fun GuardedTextField(
* value such as a trailing space that is trimmed away, leaves the typed text alone.
*/
var draft by remember(value) { mutableStateOf(value) }
/*
* A refused entry stages nothing, so the draft keeps the last accepted value while the
* field shows the refused text in red. Inside the settings window the field refuses the
* apply instead of letting that hidden value go into effect.
*/
val strings = kst4contest.view.i18n.LocalStrings.current
val settingsDraft = kst4contest.view.compose.LocalSettingsDraft.current
val refusal = rememberUpdatedState {
if (accepts(draft)) null else strings.settingsInvalidEntry(label, draft)
}
DisposableEffect(settingsDraft) {
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
onDispose { entry?.close() }
}
var hadFocus by remember { mutableStateOf(false) }
LabelledRow(label) {
CompactTextField(
@@ -81,7 +68,12 @@ internal fun GuardedTextField(
},
enabled = enabled,
isError = !accepts(draft),
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().onFocusChanged { focusState ->
if (hadFocus && !focusState.isFocused) {
onCommit()
}
hadFocus = focusState.isFocused
},
)
}
}
@@ -111,7 +103,7 @@ internal fun FilePathRow(
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Form.button(kst4contest.view.i18n.LocalStrings.current.formChoose) { choosing = true }
Form.button("Choose...") { choosing = true }
}
}
@@ -25,31 +25,31 @@ fun GuiOptionsTab(state: GuiOptionsTabState) {
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Form.section(strings.guiStartupDesign) {
Form.check(strings.guiStartInEveningDesign, state.gUI_darkModeActiveByDefault) {
Form.check("Start in the evening design", state.gUI_darkModeActiveByDefault) {
state.gUI_darkModeActiveByDefault = it
}
}
Form.flowingSection(strings.guiStartupFilters) {
Form.check(strings.guiFilterPublic, state.guiOptions_defaultFilterPublicMsgs) {
Form.check("Public messages", state.guiOptions_defaultFilterPublicMsgs) {
state.guiOptions_defaultFilterPublicMsgs = it
}
Form.check(strings.guiFilterPmToMe, state.guiOptions_defaultFilterPmToMe) {
Form.check("Private messages to me", state.guiOptions_defaultFilterPmToMe) {
state.guiOptions_defaultFilterPmToMe = it
}
Form.check(strings.guiFilterPmToOthers, state.guiOptions_defaultFilterPmToOther) {
Form.check("Private messages to others", state.guiOptions_defaultFilterPmToOther) {
state.guiOptions_defaultFilterPmToOther = it
}
Form.check(strings.guiFilterNothing, state.guiOptions_defaultFilterNothing) {
Form.check("No filter", state.guiOptions_defaultFilterNothing) {
state.guiOptions_defaultFilterNothing = it
}
}
Form.flowingSection(strings.guiBandColumnHints) {
Form.check(strings.guiHintFreshCall, state.guiOptions_showFreshCallHintInBandColumns) {
Form.check("Mark a callsign seen for the first time", state.guiOptions_showFreshCallHintInBandColumns) {
state.guiOptions_showFreshCallHintInBandColumns = it
}
Form.check(strings.guiHintLargeGridWorked, state.guiOptions_showGrossFieldWorkedHintInBandColumns) {
Form.check("Mark a worked large grid field", state.guiOptions_showGrossFieldWorkedHintInBandColumns) {
state.guiOptions_showGrossFieldWorkedHintInBandColumns = it
}
}
@@ -76,12 +76,5 @@ fun GuiOptionsTab(state: GuiOptionsTabState) {
)
}
/* Appended after the language for the same reason: no section position moves. */
Form.section(strings.guiWindows) {
Form.check(strings.guiRememberWindowPositions, state.guiOptions_rememberWindowPositions) {
state.guiOptions_rememberWindowPositions = it
}
}
}
}
@@ -1,34 +1,42 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
import kst4contest.view.i18n.LanguageStore
/**
* The GUI tab as a typed facade over ChatPreferences.
*
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them. The language is the exception: it switches at once, because the switch is
* itself what the operator wants to see.
* Writes through immediately and has no apply(), like every settings tab: the
* JavaFX controls wrote as they changed and "Save settings" only persisted.
*/
class GuiOptionsTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val languageStore: LanguageStore,
) {
var gUI_darkModeActiveByDefault: Boolean by draft.setting({ prefs.isGUI_darkModeActiveByDefault() }) { prefs.setGUI_darkModeActiveByDefault(it) }
var gUI_darkModeActiveByDefault: Boolean
get() = prefs.isGUI_darkModeActiveByDefault()
set(value) { prefs.setGUI_darkModeActiveByDefault(value) }
var guiOptions_defaultFilterNothing: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterNothing() }) { prefs.setGuiOptions_defaultFilterNothing(it) }
var guiOptions_defaultFilterNothing: Boolean
get() = prefs.isGuiOptions_defaultFilterNothing()
set(value) { prefs.setGuiOptions_defaultFilterNothing(value) }
var guiOptions_defaultFilterPmToMe: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToMe() }) { prefs.setGuiOptions_defaultFilterPmToMe(it) }
var guiOptions_defaultFilterPmToMe: Boolean
get() = prefs.isGuiOptions_defaultFilterPmToMe()
set(value) { prefs.setGuiOptions_defaultFilterPmToMe(value) }
var guiOptions_defaultFilterPmToOther: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToOther() }) { prefs.setGuiOptions_defaultFilterPmToOther(it) }
var guiOptions_defaultFilterPmToOther: Boolean
get() = prefs.isGuiOptions_defaultFilterPmToOther()
set(value) { prefs.setGuiOptions_defaultFilterPmToOther(value) }
var guiOptions_defaultFilterPublicMsgs: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPublicMsgs() }) { prefs.setGuiOptions_defaultFilterPublicMsgs(it) }
var guiOptions_defaultFilterPublicMsgs: Boolean
get() = prefs.isGuiOptions_defaultFilterPublicMsgs()
set(value) { prefs.setGuiOptions_defaultFilterPublicMsgs(value) }
var guiOptions_showFreshCallHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showFreshCallHintInBandColumns() }) { prefs.setGuiOptions_showFreshCallHintInBandColumns(it) }
var guiOptions_showFreshCallHintInBandColumns: Boolean
get() = prefs.isGuiOptions_showFreshCallHintInBandColumns()
set(value) { prefs.setGuiOptions_showFreshCallHintInBandColumns(value) }
/**
* The interface language, as the picker offers it: a language code, or the empty string
@@ -42,8 +50,8 @@ class GuiOptionsTabState(
get() = languageStore.stored
set(value) { languageStore.use(value) }
var guiOptions_rememberWindowPositions: Boolean by draft.setting({ prefs.isGuiOptions_rememberWindowPositions() }) { prefs.setGuiOptions_rememberWindowPositions(it) }
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns() }) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(it) }
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean
get() = prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns()
set(value) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(value) }
}
@@ -17,8 +17,8 @@ import kst4contest.view.compose.Form
* The Log synch tab: how worked callsigns reach KST4Contest — by polling a log file, by
* UDP from N1MM+/QARTEST/UCXLog/DXLog, and by the Win-Test network.
*
* The controls stage their edits in the window's draft (see LogSynchTabState); the file
* chooser writes the chosen path at once.
* No confirmation callback: LogSynchTabState writes through to ChatPreferences, the way
* the JavaFX controls did. "Save settings" persists, it does not collect.
*
* Labels, units and default hints are the JavaFX ones. The Win-Test port field is
* disabled while its listener is off, as it was there.
@@ -75,8 +75,9 @@ fun LogSynchTab(state: LogSynchTabState) {
value = state.logsynch_wintestNetworkPort.toString(),
enabled = state.logsynch_wintestNetworkListenerEnabled,
accepts = { it.trim().toIntOrNull() != null },
// The socket is rebound when the port is applied, not per digit.
onAccepted = { state.logsynch_wintestNetworkPort = it.trim().toInt(); revision++ },
// Rebinding the socket belongs to the finished entry, not to every digit.
onCommit = { state.commitWintestNetworkPort() },
)
GuardedTextField(
@@ -1,24 +1,23 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* The Log synch tab as a typed facade over ChatPreferences.
*
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them. The log file path is the exception: it changes through a file chooser,
* an action, and is written when chosen.
* Every property writes through immediately, and there is deliberately no apply().
* That mirrors the JavaFX window it replaces: its controls each wrote into
* ChatPreferences the moment they changed, and its two buttons only persisted
* ("Save settings" calls writePreferencesToXmlFile) or closed the window.
*
* One thing this tab controls is not a setting at all but an effect on the running session,
* and no coverage test can see it because it is not a ChatPreferences setter: the Win-Test
* UDP listener is started, stopped or rebound when its switch or its port is applied -- once
* per apply, see [applyWintestListener]. It is taken as callbacks rather than from a
* ChatController so the rules stay testable without opening a database.
* Two things this tab controlled are not settings at all but effects on the running
* session, and no coverage test can see them because they are not ChatPreferences
* setters. They are taken as callbacks rather than from a ChatController so the rules
* stay testable without opening a database:
*
* The broadcast address the JavaFX window auto-detected while building the pane is detected
* by repairStoredSettings now, before the window is built, not by this state.
* - the Win-Test UDP listener is started and stopped with its setting;
* - the Win-Test broadcast address is auto-detected while it is still the catch-all
* default, which the JavaFX window did while building the settings pane.
*
* Ports, transports and framing are untouched: the accepted values, the defaults and
* the moment a socket is rebound are the ones the JavaFX window had.
@@ -26,17 +25,23 @@ import kst4contest.view.compose.setting
* @param restartWintestListener rebinds the Win-Test UDP listener; maps to
* ChatController.restartWintestUdpListenerIfEnabled()
* @param stopWintestListener maps to ChatController.stopWintestUdpListener()
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null
* when none could be determined; maps to detectPreferredWintestBroadcastAddress()
*/
class LogSynchTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val restartWintestListener: () -> Unit,
private val stopWintestListener: () -> Unit,
private val detectWintestBroadcastAddress: () -> String?,
) {
var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean by draft.setting(
{ prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled() },
) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(it) }
init {
autoDetectBroadcastAddressWhileStillDefault()
}
var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean
get() = prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled()
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(value) }
/**
* The log file polled for worked callsigns. The JavaFX tab showed it read-only and
@@ -48,73 +53,75 @@ class LogSynchTabState(
get() = prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(value) }
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean by draft.setting(
{ prefs.isLogsynch_ucxUDPWkdCallListenerEnabled() },
) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(it) }
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean
get() = prefs.isLogsynch_ucxUDPWkdCallListenerEnabled()
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(value) }
/**
* Shared UDP port for QSO and TRX messages, default 12060. The JavaFX field
* accepted any number here and rebound nothing — that listener comes up at start —
* so neither a range nor a restart is added.
*/
var logsynch_ucxUDPWkdCallListenerPort: Int by draft.setting(
{ prefs.getLogsynch_ucxUDPWkdCallListenerPort() },
) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(it) }
var logsynch_ucxUDPWkdCallListenerPort: Int
get() = prefs.getLogsynch_ucxUDPWkdCallListenerPort()
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(value) }
var logsynch_wintestNetworkListenerEnabled: Boolean by draft.setting(
{ prefs.isLogsynch_wintestNetworkListenerEnabled() },
effectKey = WINTEST_LISTENER,
effect = ::applyWintestListener,
) { prefs.setLogsynch_wintestNetworkListenerEnabled(it) }
var logsynch_wintestNetworkListenerEnabled: Boolean
get() = prefs.isLogsynch_wintestNetworkListenerEnabled()
set(value) {
prefs.setLogsynch_wintestNetworkListenerEnabled(value)
if (value) restartWintestListener() else stopWintestListener()
}
/**
* UDP port for the Win-Test listener, default 9871. The socket is rebound when the port
* is applied, not per keystroke: rebinding per digit would bind and unbind a UDP port
* for every number on the way to the intended one.
* UDP port for the Win-Test listener, default 9871. The setting is written at once
* like every other, but the socket is only rebound by [commitWintestNetworkPort] —
* the JavaFX field did that on focus loss, and rebinding per keystroke would bind
* and unbind a UDP port for every digit on the way to the intended one.
*/
var logsynch_wintestNetworkPort: Int by draft.setting(
{ prefs.getLogsynch_wintestNetworkPort() },
effectKey = WINTEST_LISTENER,
effect = ::applyWintestListener,
) { prefs.setLogsynch_wintestNetworkPort(it) }
var logsynch_wintestNetworkPort: Int
get() = prefs.getLogsynch_wintestNetworkPort()
set(value) { prefs.setLogsynch_wintestNetworkPort(value) }
/*
* The two trimmed values are staged trimmed, so the draft holds what will be stored and a
* trailing space does not count as a change.
*/
private var stationNameOfKst: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkStationNameOfKST() },
) { prefs.setLogsynch_wintestNetworkStationNameOfKST(it) }
private var broadcastAddress: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkBroadcastAddress() },
) { prefs.setLogsynch_wintestNetworkBroadcastAddress(it) }
/** Applies the entered port to the running listener, if there is one. */
fun commitWintestNetworkPort() {
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) {
restartWintestListener()
}
}
/** The name KST4Contest uses as source of Win-Test SKED packets. */
var logsynch_wintestNetworkStationNameOfKST: String
get() = stationNameOfKst
set(value) { stationNameOfKst = value.trim() }
get() = prefs.getLogsynch_wintestNetworkStationNameOfKST()
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfKST(value.trim()) }
/** UDP broadcast address used for sending to Win-Test. */
var logsynch_wintestNetworkBroadcastAddress: String
get() = broadcastAddress
set(value) { broadcastAddress = value.trim() }
get() = prefs.getLogsynch_wintestNetworkBroadcastAddress()
set(value) { prefs.setLogsynch_wintestNetworkBroadcastAddress(value.trim()) }
/**
* Brings the Win-Test listener in line with the applied switch and port: rebound while
* enabled, stopped otherwise. The switch and the port share it, so applying both runs it
* once and binds the new port; a port changed while the listener is off meets a listener
* that is not running, and stopping that does nothing.
* Replaces the catch-all default 255.255.255.255 with the detected subnet
* broadcast address. An address the operator or a previous detection chose is
* left alone, and a failed detection keeps the default: guessing an address here
* would send SKED packets somewhere nobody is listening.
*/
private fun applyWintestListener() {
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) restartWintestListener() else stopWintestListener()
private fun autoDetectBroadcastAddressWhileStillDefault() {
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() != DEFAULT_BROADCAST_ADDRESS) {
return
}
val detected = try {
detectWintestBroadcastAddress()
} catch (probeFailure: RuntimeException) {
null
}
if (detected != null && detected.isNotBlank()) {
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
}
}
companion object {
/** The stored value that still means "not configured for this network". */
const val DEFAULT_BROADCAST_ADDRESS: String = "255.255.255.255"
/** The effect key the Win-Test switch and port share. */
private const val WINTEST_LISTENER = "wintestListener"
}
}
@@ -1,8 +1,6 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* State of the message-handling tab.
@@ -12,44 +10,42 @@ import kst4contest.view.compose.setting
* (Kst4ContestApplication:12004 and :12039). Exposing one property per pair keeps
* that: a tab that wrote only the main category would leave the second chat on its
* previous value, which no part of the window ever displayed.
*
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them.
*/
class MessageHandlingTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val applyDebugModeToFile: (Boolean) -> Unit = {},
) {
/** Drives both category flags, as the single JavaFX checkbox did. */
var autoAnswerEnabled: Boolean by draft.setting(
{ prefs.isMessageHandling_autoAnswerEnabled() },
) { enabled ->
prefs.setMessageHandling_autoAnswerEnabled(enabled)
prefs.setMessageHandling_autoAnswerEnabledSecondCat(enabled)
}
var autoAnswerEnabled: Boolean
get() = prefs.isMessageHandling_autoAnswerEnabled()
set(value) {
prefs.setMessageHandling_autoAnswerEnabled(value)
prefs.setMessageHandling_autoAnswerEnabledSecondCat(value)
}
/** Drives both category texts, as the single JavaFX text field did. */
var autoAnswerText: String by draft.setting(
{ prefs.getMessageHandling_autoAnswerTextMainCat() },
) { text ->
prefs.setMessageHandling_autoAnswerTextMainCat(text)
prefs.setMessageHandling_autoAnswerTextSecondCat(text)
}
var autoAnswerText: String
get() = prefs.getMessageHandling_autoAnswerTextMainCat()
set(value) {
prefs.setMessageHandling_autoAnswerTextMainCat(value)
prefs.setMessageHandling_autoAnswerTextSecondCat(value)
}
/** Independent of the automatic answer; its own checkbox in the JavaFX window. */
var autoAnswerToQRGRequestEnabled: Boolean by draft.setting(
{ prefs.isMessageHandling_autoAnswerToQRGRequestEnabled() },
) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(it) }
var autoAnswerToQRGRequestEnabled: Boolean
get() = prefs.isMessageHandling_autoAnswerToQRGRequestEnabled()
set(value) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(value) }
/**
* Records the whole session to file. Beyond the preference it opens or closes the
* error-log handler and the history recorder, which only the application can sequence;
* [applyDebugModeToFile] carries that out in the order the JavaFX checkbox kept.
*/
var debugModeToFileEnabled: Boolean by draft.setting(
{ prefs.isMessageHandling_debugModeToFileEnabled() },
effect = { applyDebugModeToFile(prefs.isMessageHandling_debugModeToFileEnabled()) },
) { prefs.setMessageHandling_debugModeToFileEnabled(it) }
var debugModeToFileEnabled: Boolean
get() = prefs.isMessageHandling_debugModeToFileEnabled()
set(value) {
prefs.setMessageHandling_debugModeToFileEnabled(value)
applyDebugModeToFile(value)
}
}
@@ -21,7 +21,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.testTag
import kst4contest.model.Band
import kst4contest.view.compose.DataTable
import kst4contest.view.compose.Form
@@ -77,18 +76,12 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
stored = state.notify_dxclusterServerPort.toString(),
commit = state::commitDxClusterServerPort,
onRefused = onRefused,
// Compared as the number commitDxClusterServerPort stores.
isUnchanged = { typed, now -> typed.trim().toIntOrNull()?.toString() == now },
current = { state.notify_dxclusterServerPort.toString() },
)
Form.committed(
label = strings.notificationSpotterCallsign,
stored = state.notify_DXCSrv_SpottersCallSign,
commit = state::commitSpotterCallSign,
onRefused = onRefused,
// commitSpotterCallSign stores the callsign trimmed and in upper case.
isUnchanged = { typed, now -> typed.trim().uppercase(java.util.Locale.ROOT) == now },
current = { state.notify_DXCSrv_SpottersCallSign },
)
/*
* Shows the band prefix, as the JavaFX ComboBox did: the prefix is what the
@@ -141,7 +134,6 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
onValueChange = { entered = it },
singleLine = true,
label = { Text(strings.notificationCallsign) },
modifier = Modifier.testTag(MONITORED_CALLSIGN_FIELD_TAG),
)
}
},
@@ -157,6 +149,3 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
)
}
}
/** Marks the callsign field of the add-to-monitoring dialog, so a test can type into it. */
internal const val MONITORED_CALLSIGN_FIELD_TAG = "monitored-callsign-field"
@@ -9,144 +9,137 @@ import kst4contest.view.GuiUtils
import kst4contest.view.compose.CommitOutcome
import kst4contest.view.compose.EditableListState
import kst4contest.view.compose.EntryRule
import kst4contest.view.i18n.CurrentStrings
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
import java.util.Locale
/**
* State of the notification tab: sounds, band-upgrade hints, the local DX Cluster
* server and the QSO monitoring list.
*
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them; the monitoring list too. The test spot is an action and is sent at once.
*
* Two of these reach past the preferences into the running session, which the settings
* coverage test cannot see, so they are constructor callbacks rather than plain
* properties: bringing the server in line with its switch and port, and sending the test
* spot.
* Three of these reach past the preferences into the running session, which the
* settings coverage test cannot see, so they are constructor callbacks rather than
* plain properties: starting and stopping the server, restarting it after a port
* change, and sending the test spot.
*
* @param monitoredRoster `ChatController.getLstNotify_QSOSniffer_sniffedCallSignList`;
* it lives on the controller, not in the preferences, which is why it is
* passed separately.
* @param applyDxClusterServer brings the running server in line with the applied switch
* and port: restarted while enabled, stopped otherwise. The switch and the port
* share it, so one apply restarts the server once, on the new port.
* @param applyDxClusterServerEnabled starts or stops the server, the work the
* JavaFX checkbox did beyond writing the flag.
* @param applyDxClusterServerPort restarts the server; called only when the port
* actually changed, as the JavaFX field did.
* @param broadcastTestSpot delivers the spot and returns null on success or the
* reason it could not be delivered.
*/
class NotificationTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val monitoredRoster: SimpleRoster<String>,
private val applyDxClusterServer: () -> Unit,
private val applyDxClusterServerEnabled: (Boolean) -> Unit,
private val applyDxClusterServerPort: (Int) -> Unit,
private val broadcastTestSpot: (ChatMember) -> String?,
) {
var notify_playSimpleSounds: Boolean by draft.setting(
{ prefs.isNotify_playSimpleSounds() },
) { prefs.setNotify_playSimpleSounds(it) }
var notify_playSimpleSounds: Boolean
get() = prefs.isNotify_playSimpleSounds()
set(value) { prefs.setNotify_playSimpleSounds(value) }
var notify_playCWCallsignsOnRxedPMs: Boolean by draft.setting(
{ prefs.isNotify_playCWCallsignsOnRxedPMs() },
) { prefs.setNotify_playCWCallsignsOnRxedPMs(it) }
var notify_playCWCallsignsOnRxedPMs: Boolean
get() = prefs.isNotify_playCWCallsignsOnRxedPMs()
set(value) { prefs.setNotify_playCWCallsignsOnRxedPMs(value) }
var notify_playVoiceCallsignsOnRxedPMs: Boolean by draft.setting(
{ prefs.isNotify_playVoiceCallsignsOnRxedPMs() },
) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(it) }
var notify_playVoiceCallsignsOnRxedPMs: Boolean
get() = prefs.isNotify_playVoiceCallsignsOnRxedPMs()
set(value) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(value) }
var notify_bandUpgradeHintOnLogEnabled: Boolean by draft.setting(
{ prefs.isNotify_bandUpgradeHintOnLogEnabled() },
) { prefs.setNotify_bandUpgradeHintOnLogEnabled(it) }
var notify_bandUpgradeHintOnLogEnabled: Boolean
get() = prefs.isNotify_bandUpgradeHintOnLogEnabled()
set(value) { prefs.setNotify_bandUpgradeHintOnLogEnabled(value) }
var notify_bandUpgradePriorityBoostEnabled: Boolean by draft.setting(
{ prefs.isNotify_bandUpgradePriorityBoostEnabled() },
) { prefs.setNotify_bandUpgradePriorityBoostEnabled(it) }
private var dxClusterServerEnabled: Boolean by draft.setting(
{ prefs.isNotify_dxClusterServerEnabled() },
effectKey = DX_CLUSTER_SERVER,
effect = { applyDxClusterServer() },
) { prefs.setNotify_dxClusterServerEnabled(it) }
private var dxClusterServerPort: Int by draft.setting(
{ prefs.getNotify_dxclusterServerPort() },
effectKey = DX_CLUSTER_SERVER,
effect = { applyDxClusterServer() },
) { prefs.setNotify_dxclusterServerPort(it) }
private var spotterCallSign: String by draft.setting(
{ prefs.getNotify_DXCSrv_SpottersCallSign().get() },
) { prefs.setNotify_DXCSrv_SpottersCallSign(it) }
var notify_bandUpgradePriorityBoostEnabled: Boolean
get() = prefs.isNotify_bandUpgradePriorityBoostEnabled()
set(value) { prefs.setNotify_bandUpgradePriorityBoostEnabled(value) }
val notify_dxClusterServerEnabled: Boolean
get() = dxClusterServerEnabled
get() = prefs.isNotify_dxClusterServerEnabled()
val notify_dxclusterServerPort: Int
get() = dxClusterServerPort
get() = prefs.getNotify_dxclusterServerPort()
val notify_DXCSrv_SpottersCallSign: String
get() = spotterCallSign
get() = prefs.getNotify_DXCSrv_SpottersCallSign().get()
/*
* Stored as a prefix string, so an unreadable value is possible. It is shown as 144 here
* and written back as 144 by repairStoredSettings before the window is built -- never
* from this read, which runs during composition.
/**
* The band a spot falls back to when its frequency carries no band information.
*
* Stored as a prefix string, so an unreadable value is possible; reading it
* repairs the stored value to 144, which is what the JavaFX window did when it
* built the combo box (Kst4ContestApplication:11349).
*/
private var fallbackBand: Band by draft.setting(
{ Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) ?: Band.B_144 },
) { prefs.setNotify_optionalFrequencyPrefix(it.prefix) }
/** The band a spot falls back to when its frequency carries no band information. */
var notify_optionalFrequencyPrefix: Band
get() = fallbackBand
set(value) { fallbackBand = value }
get() {
val stored = Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get())
if (stored == null) {
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
return Band.B_144
}
return stored
}
set(value) { prefs.setNotify_optionalFrequencyPrefix(value.prefix) }
val monitoredCallSigns = EditableListState(
monitoredRoster.snapshot(),
rule = EntryRule { candidate, _, others -> judgeMonitoredCallSign(candidate, others) },
)
/** Stages the server switch; applying it starts or stops the server. */
/** Writes the flag and lets the running session start or stop the server. */
fun enableDxClusterServer(enabled: Boolean) {
dxClusterServerEnabled = enabled
prefs.setNotify_dxClusterServerEnabled(enabled)
applyDxClusterServerEnabled(enabled)
}
/**
* @return null when staged, otherwise the reason the port was refused.
* @return null when applied, otherwise the reason the port was refused.
*/
fun commitDxClusterServerPort(entered: String): String? {
val previousPort = prefs.getNotify_dxclusterServerPort()
val port = entered.trim().toIntOrNull()
if (port == null || port < 1 || port > 65535) {
return CurrentStrings.get().notificationInvalidPort(entered.trim())
return "\"" + entered.trim() + "\" is not a valid TCP port. " +
"Enter a value from 1 to 65535."
}
prefs.setNotify_dxclusterServerPort(port)
/*
* The stored port staged again is no change, so it restarts nothing: restarting on
* an unchanged port would drop a connected logger for nothing, and the JavaFX field
* guarded the restart the same way.
* Restarting on an unchanged port would drop a connected logger for nothing;
* the JavaFX field guarded the restart the same way.
*/
dxClusterServerPort = port
if (port != previousPort) {
applyDxClusterServerPort(port)
}
return null
}
/**
* @return null when staged, otherwise the reason the callsign was refused.
* @return null when stored, otherwise the reason the callsign was refused.
*/
fun commitSpotterCallSign(entered: String): String? {
val spotterCallSign = entered.trim().uppercase(Locale.ROOT)
if (!GuiUtils.isCallSignSyntax(spotterCallSign)) {
return CurrentStrings.get().notificationInvalidSpotter(spotterCallSign)
return "\"" + spotterCallSign + "\" is not a valid spotter callsign."
}
this.spotterCallSign = spotterCallSign
prefs.setNotify_DXCSrv_SpottersCallSign(spotterCallSign)
return null
}
/**
* Appends a callsign to the monitoring list and stages the list.
* Appends a callsign to the monitoring list and fills the roster at once, as the
* JavaFX dialog did.
*
* @return null when added, otherwise the reason it was refused.
*/
@@ -158,7 +151,7 @@ class NotificationTabState(
}
if (verdict !is CommitOutcome.Stored) {
return CurrentStrings.get().notificationInvalidCallsign
return "Please enter a valid callsign."
}
/*
@@ -170,14 +163,9 @@ class NotificationTabState(
return null
}
/** Stages the list; applying it fills the monitoring roster. */
/** Fills the monitoring roster from the list. */
fun commitMonitoredCallSigns() {
draft.stage(
MONITORED_CALL_SIGNS,
monitoredCallSigns.texts.toList(),
live = { monitoredRoster.snapshot() },
write = { monitoredCallSigns.commitTo(monitoredRoster) },
)
monitoredCallSigns.commitTo(monitoredRoster)
}
/**
@@ -214,20 +202,13 @@ class NotificationTabState(
)
if (baseCall == null || !GuiUtils.isCallSignSyntax(baseCall)) {
return CommitOutcome.Refused(CurrentStrings.get().notificationInvalidCallsign)
return CommitOutcome.Refused("Please enter a valid callsign.")
}
if (others.any { it != null && it.equals(baseCall, ignoreCase = true) }) {
return CommitOutcome.Refused(CurrentStrings.get().notificationAlreadyMonitored)
return CommitOutcome.Refused("This base callsign is already in the monitoring list.")
}
return CommitOutcome.Stored(baseCall)
}
private companion object {
/** The effect key the server switch and port share. */
const val DX_CLUSTER_SERVER = "dxClusterServer"
const val MONITORED_CALL_SIGNS = "NotificationTabState.monitoredCallSigns"
}
}
@@ -341,7 +341,7 @@ private fun ProfileDialogs(
)
}
},
confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text(strings.dialogApply) } },
confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text("Apply") } },
dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } },
)
}
@@ -6,7 +6,6 @@ import androidx.compose.runtime.setValue
import kst4contest.controller.OperatorProfileManagementService
import kst4contest.model.OperatorProfile
import kst4contest.model.OperatorProfileSelection
import kst4contest.view.i18n.CurrentStrings
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -58,7 +57,7 @@ class ProfilesTabState(
}
val activeProfileName: String
get() = activeSelection()?.profile?.displayName ?: CurrentStrings.get().profilesUnknown
get() = activeSelection()?.profile?.displayName ?: "unknown"
val preferencesPath: String
get() = activeSelection()?.preferencesAbsolutePath ?: ""
@@ -91,7 +90,9 @@ class ProfilesTabState(
}
val created = service.createProfile(name.trim(), sharedWorkedDatabase)
?: return ProfileNotice.Error(CurrentStrings.get().profilesCreateFailed)
?: return ProfileNotice.Error(
"The profile could not be created. The profile registry could not be written."
)
refresh()
selected = created
@@ -100,7 +101,10 @@ class ProfilesTabState(
* Said explicitly because a new profile that cannot log in looks broken
* otherwise: callsign and password are deliberately not copied or invented.
*/
return ProfileNotice.Info(CurrentStrings.get().profilesCreatedWithoutCredentials(created.displayName))
return ProfileNotice.Info(
"The profile \"" + created.displayName + "\" was created without callsign and " +
"password. Enter them on the Station tab after switching to it."
)
}
fun duplicateSelected(name: String): ProfileNotice? {
@@ -111,7 +115,7 @@ class ProfilesTabState(
}
val duplicate = service.duplicateProfile(profile, name)
?: return ProfileNotice.Error(CurrentStrings.get().profilesDuplicateFailed)
?: return ProfileNotice.Error("The profile could not be duplicated.")
refresh()
selected = duplicate
@@ -120,7 +124,7 @@ class ProfilesTabState(
/** The suggested name of a duplicate, the one the JavaFX dialog pre-filled. */
fun duplicateNameSuggestion(): String =
selected?.let { CurrentStrings.get().profilesCopyOf(it.displayName) } ?: ""
selected?.let { "Copy of " + it.displayName } ?: ""
fun renameSelected(name: String): ProfileNotice? {
val profile = selected ?: return notASelection()
@@ -138,16 +142,20 @@ class ProfilesTabState(
val profile = selected ?: return notASelection()
if (profile.isRootProfile) {
return ProfileNotice.Error(CurrentStrings.get().profilesRootNotDeletable)
return ProfileNotice.Error(
"The default profile uses the files of the installation itself and cannot be deleted."
)
}
if (isActive(profile)) {
return ProfileNotice.Error(CurrentStrings.get().profilesActiveNotDeletable)
return ProfileNotice.Error(
"The profile currently in use cannot be deleted. Switch to another profile first."
)
}
if (!service.deleteProfile(profile)) {
refresh()
return ProfileNotice.Error(CurrentStrings.get().profilesDeleteFailed)
return ProfileNotice.Error("The profile could not be deleted.")
}
refresh()
@@ -156,18 +164,22 @@ class ProfilesTabState(
/** The folder the deletion confirmation names, and what happens to worked stations. */
fun describeDeletion(profile: OperatorProfile): String =
CurrentStrings.get().profilesDeletionFolder(service.getProfileDirectory(profile).toString()) + "\n\n" +
"The following folder is removed permanently:\n" +
service.getProfileDirectory(profile) + "\n\n" +
if (profile.isSharedWorkedDatabase) {
CurrentStrings.get().profilesDeletionKeepsShared
"The common station worked stations are not touched."
} else {
CurrentStrings.get().profilesDeletionRemovesOwn
"The worked stations of this profile are deleted as well."
}
fun changeWorkedDataModeOfSelected(sharedWorkedDatabase: Boolean): ProfileNotice? {
val profile = selected ?: return notASelection()
if (profile.isRootProfile) {
return ProfileNotice.Error(CurrentStrings.get().profilesRootAlwaysShared)
return ProfileNotice.Error(
"The default profile always uses the common station worked stations, because " +
"that database is the one of the installation itself."
)
}
if (sharedWorkedDatabase == profile.isSharedWorkedDatabase) {
@@ -178,7 +190,7 @@ class ProfilesTabState(
refresh()
if (isActive(profile)) {
return ProfileNotice.Info(CurrentStrings.get().profilesChangeAfterSwitch)
return ProfileNotice.Info("The change takes effect after switching to this profile again.")
}
return null
@@ -188,7 +200,7 @@ class ProfilesTabState(
val profile = selected ?: return notASelection()
if (isActive(profile)) {
return ProfileNotice.Info(CurrentStrings.get().profilesAlreadyActive)
return ProfileNotice.Info("This profile is already active.")
}
requestActivation(profile)
@@ -205,7 +217,7 @@ class ProfilesTabState(
}
private fun notASelection(): ProfileNotice =
ProfileNotice.Info(CurrentStrings.get().profilesSelectFirst)
ProfileNotice.Info("Select a profile in the table first.")
companion object {
@@ -218,9 +230,9 @@ class ProfilesTabState(
*/
fun describeWorkedDataMode(profile: OperatorProfile): String =
if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
CurrentStrings.get().profilesSharedDatabase
"common station database"
} else {
CurrentStrings.get().profilesOwnDatabase
"own database"
}
/** Blank for a profile never used, rather than an epoch date. */
@@ -2,15 +2,15 @@ package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.EditableListState
import kst4contest.view.compose.SettingsDraft
/**
* State of the shortcuts-and-snippets tab: the two single-column lists from
* `grdPnlShorts`.
*
* Every committed change of a list is staged in the window's [SettingsDraft]. Applying
* it fills the roster and rebuilds what is built from it -- the shortcut buttons above the
* message field, the snippet menus -- once per apply, however many edits went before.
* Both lists write through to their roster on every committed change. The JavaFX
* tables did the same — their Add buttons called `mutate` on the roster directly —
* and the shortcut buttons above the message field are rebuilt from the roster, so
* a list that only wrote on Save would leave those buttons stale.
*
* @param refreshShortcutButtons rebuilds the button row above the message field,
* as `refreshShortcutButtons` did after every edit commit and move.
@@ -19,7 +19,6 @@ import kst4contest.view.compose.SettingsDraft
*/
class ShortcutsTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val refreshShortcutButtons: () -> Unit,
private val refreshTextSnippetContextMenus: () -> Unit,
) {
@@ -28,26 +27,16 @@ class ShortcutsTabState(
val snippets = EditableListState(prefs.getLst_txtSnipList().snapshot())
/** Stages the shortcut list; applying it fills the roster and rebuilds the buttons. */
/** Fills the shortcut roster and rebuilds the buttons built from it. */
fun commitShortcuts() {
draft.stage(
SHORTCUTS,
shortcuts.texts.toList(),
live = { prefs.getLst_txtShortCutBtnList().snapshot() },
write = { shortcuts.commitTo(prefs.getLst_txtShortCutBtnList()) },
effect = refreshShortcutButtons,
)
shortcuts.commitTo(prefs.getLst_txtShortCutBtnList())
refreshShortcutButtons()
}
/** Stages the snippet list; applying it fills the roster and rebuilds the menus. */
/** Fills the snippet roster and rebuilds the context menus built from it. */
fun commitSnippets() {
draft.stage(
SNIPPETS,
snippets.texts.toList(),
live = { prefs.getLst_txtSnipList().snapshot() },
write = { snippets.commitTo(prefs.getLst_txtSnipList()) },
effect = refreshTextSnippetContextMenus,
)
snippets.commitTo(prefs.getLst_txtSnipList())
refreshTextSnippetContextMenus()
}
companion object {
@@ -58,8 +47,5 @@ class ShortcutsTabState(
*/
const val NEW_ENTRY_TEMPLATE =
"CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"
private const val SHORTCUTS = "ShortcutsTabState.shortcuts"
private const val SNIPPETS = "ShortcutsTabState.snippets"
}
}
@@ -11,13 +11,11 @@ import kst4contest.view.compose.Form
/**
* The Station tab: own station, server, active bands and path-analysis defaults.
*
* The controls stage their edits in the window's draft (see StationTabState); only the
* login credentials write through at once.
*
* @param onRefused shows why a committed entry was refused (the own ground height)
* No confirmation callback: StationTabState writes through to ChatPreferences, the
* way the JavaFX controls did. "Save settings" persists, it does not collect.
*/
@Composable
fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
fun StationTab(state: StationTabState) {
val strings = LocalStrings.current
Column(
@@ -96,39 +94,12 @@ fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
Form.int(strings.stationRotatorPort, state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it }
}
/*
* Only with SO2R, as stored: Form.check and Form.int have no disabled state, and a
* rotator 2 switch without SO2R would do nothing. Switching SO2R on shows the block
* after Apply.
*/
if (state.so2rEnabled) {
Form.section(strings.stationRotator2Section) {
Form.check(strings.stationRotator2Enabled, state.stn_pstRotator2Enabled) { state.stn_pstRotator2Enabled = it }
Form.text(strings.stationRotator2Host, state.stn_pstRotator2Host) { state.stn_pstRotator2Host = it }
Form.int(strings.stationRotator2Port, state.stn_pstRotator2Port) { state.stn_pstRotator2Port = it }
}
}
Form.section(strings.stationPathSection) {
Form.decimal(strings.stationOwnTxPower, state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it }
Form.decimal(strings.stationOwnAntennaGain, state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it }
Form.decimal(strings.stationOwnAntennaHeight, state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = it }
/*
* Committed on Enter or on leaving the field, not per keystroke: the stored
* value reads back as "250.0", and a per-keystroke write would feed that back
* into the field while the operator is still typing.
*/
Form.committed(
label = strings.stationOwnHeightAsl,
stored = state.stn_pathAnalysisOwnHeightAslText,
commit = state::commitOwnHeightAsl,
onRefused = onRefused,
isUnchanged = StationTabState::isOwnHeightAslUnchanged,
current = { state.stn_pathAnalysisOwnHeightAslText },
)
Form.decimal(strings.stationTargetTxPower, state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it }
Form.decimal(strings.stationTargetAntennaGain, state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = it }
Form.check(strings.stationTargetHighestPoint, state.stn_pathAnalysisDxHighestPointInSquare) { state.stn_pathAnalysisDxHighestPointInSquare = it }
Form.text(strings.stationDemDirectory, state.stn_pathAnalysisDemRootDirectory) { state.stn_pathAnalysisDemRootDirectory = it }
}
}
@@ -1,32 +1,24 @@
package kst4contest.view.compose.tabs
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.view.compose.Form
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
import kst4contest.view.i18n.CurrentStrings
/**
* The Station tab as a typed facade over ChatPreferences.
*
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them (Apply/Close prefs, Save settings). The login credentials are the exception
* and still write through at once: callsign, names, locator, password and both chat
* categories. They are locked while a session exists ([loginFieldsEnabled]), and without
* one they take effect at the next login anyway, so holding them back would only make
* Connect use something other than what the tab shows.
* Every property writes through immediately, and there is deliberately no apply().
* That mirrors the JavaFX window it replaces: its controls each wrote into
* ChatPreferences the moment they changed, and its two buttons only persisted
* ("Save settings" calls writePreferencesToXmlFile) or closed the window
* ("Apply/Close prefs" just hides it). Buffering edits until a confirmation would
* change behaviour an operator may rely on: today a changed setting takes effect
* in the running session at once, whether or not it is ever written to disk.
*
* The property list was derived mechanically from the setters the JavaFX tab
* called, intersected with the setters ChatPreferences declares.
*/
class StationTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
/**
* Applies the chat category to the running session. Passed in rather than taken
* from a ChatController so the rules stay testable without building a controller,
@@ -34,16 +26,6 @@ class StationTabState(
*/
private val applyMainCategory: (ChatCategory?) -> Unit,
private val applySecondCategory: (ChatCategory?) -> Unit,
/**
* The state of the ON4KST session. A lambda over Compose state, so the login fields
* follow it while the tab is open; the default suits a state built without a session.
*/
private val connectionState: () -> On4KstConnectionState = { On4KstConnectionState.DISCONNECTED },
/**
* Restarts or stops rotator 2 to match the stored settings; shared by this tab and the
* TRX synch tab through [SECOND_ROTATOR], so it runs once per apply.
*/
private val applySecondRotator: () -> Unit = {},
) {
var loginChatCategoryMain: ChatCategory?
@@ -55,25 +37,45 @@ class StationTabState(
get() = prefs.getLoginChatCategorySecond()
set(value) { prefs.setLoginChatCategorySecond(value) }
var stn_antennaBeamWidthDeg: Double by draft.setting({ prefs.getStn_antennaBeamWidthDeg() }) { prefs.setStn_antennaBeamWidthDeg(it) }
var stn_antennaBeamWidthDeg: Double
get() = prefs.getStn_antennaBeamWidthDeg()
set(value) { prefs.setStn_antennaBeamWidthDeg(value) }
var stn_bandActive10G: Boolean by draft.setting({ prefs.isStn_bandActive10G() }) { prefs.setStn_bandActive10G(it) }
var stn_bandActive10G: Boolean
get() = prefs.isStn_bandActive10G()
set(value) { prefs.setStn_bandActive10G(value) }
var stn_bandActive1240: Boolean by draft.setting({ prefs.isStn_bandActive1240() }) { prefs.setStn_bandActive1240(it) }
var stn_bandActive1240: Boolean
get() = prefs.isStn_bandActive1240()
set(value) { prefs.setStn_bandActive1240(value) }
var stn_bandActive144: Boolean by draft.setting({ prefs.isStn_bandActive144() }) { prefs.setStn_bandActive144(it) }
var stn_bandActive144: Boolean
get() = prefs.isStn_bandActive144()
set(value) { prefs.setStn_bandActive144(value) }
var stn_bandActive2300: Boolean by draft.setting({ prefs.isStn_bandActive2300() }) { prefs.setStn_bandActive2300(it) }
var stn_bandActive2300: Boolean
get() = prefs.isStn_bandActive2300()
set(value) { prefs.setStn_bandActive2300(value) }
var stn_bandActive3400: Boolean by draft.setting({ prefs.isStn_bandActive3400() }) { prefs.setStn_bandActive3400(it) }
var stn_bandActive3400: Boolean
get() = prefs.isStn_bandActive3400()
set(value) { prefs.setStn_bandActive3400(value) }
var stn_bandActive432: Boolean by draft.setting({ prefs.isStn_bandActive432() }) { prefs.setStn_bandActive432(it) }
var stn_bandActive432: Boolean
get() = prefs.isStn_bandActive432()
set(value) { prefs.setStn_bandActive432(value) }
var stn_bandActive50: Boolean by draft.setting({ prefs.isStn_bandActive50() }) { prefs.setStn_bandActive50(it) }
var stn_bandActive50: Boolean
get() = prefs.isStn_bandActive50()
set(value) { prefs.setStn_bandActive50(value) }
var stn_bandActive5600: Boolean by draft.setting({ prefs.isStn_bandActive5600() }) { prefs.setStn_bandActive5600(it) }
var stn_bandActive5600: Boolean
get() = prefs.isStn_bandActive5600()
set(value) { prefs.setStn_bandActive5600(value) }
var stn_bandActive70: Boolean by draft.setting({ prefs.isStn_bandActive70() }) { prefs.setStn_bandActive70(it) }
var stn_bandActive70: Boolean
get() = prefs.isStn_bandActive70()
set(value) { prefs.setStn_bandActive70(value) }
var stn_loginCallSign: String
get() = prefs.getStn_loginCallSign()
@@ -95,123 +97,57 @@ class StationTabState(
get() = prefs.getStn_loginPassword()
set(value) { prefs.setStn_loginPassword(value) }
var stn_maxQRBDefault: Double by draft.setting({ prefs.getStn_maxQRBDefault() }) { prefs.setStn_maxQRBDefault(it) }
var stn_maxQRBDefault: Double
get() = prefs.getStn_maxQRBDefault()
set(value) { prefs.setStn_maxQRBDefault(value) }
var stn_on4kstServersDns: String by draft.setting({ prefs.getStn_on4kstServersDns() }) { prefs.setStn_on4kstServersDns(it) }
var stn_on4kstServersDns: String
get() = prefs.getStn_on4kstServersDns()
set(value) { prefs.setStn_on4kstServersDns(value) }
var stn_on4kstServersPort: Int by draft.setting({ prefs.getStn_on4kstServersPort() }) { prefs.setStn_on4kstServersPort(it) }
var stn_on4kstServersPort: Int
get() = prefs.getStn_on4kstServersPort()
set(value) { prefs.setStn_on4kstServersPort(value) }
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi() }) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(it) }
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double
get() = prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi()
set(value) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(value) }
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts() }) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(it) }
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double
get() = prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts()
set(value) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(value) }
var stn_pathAnalysisDemRootDirectory: String by draft.setting({ prefs.getStn_pathAnalysisDemRootDirectory() }) { prefs.setStn_pathAnalysisDemRootDirectory(it) }
var stn_pathAnalysisDemRootDirectory: String
get() = prefs.getStn_pathAnalysisDemRootDirectory()
set(value) { prefs.setStn_pathAnalysisDemRootDirectory(value) }
var stn_pathAnalysisOwnAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaGainDbi() }) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(it) }
var stn_pathAnalysisOwnAntennaGainDbi: Double
get() = prefs.getStn_pathAnalysisOwnAntennaGainDbi()
set(value) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(value) }
var stn_pathAnalysisOwnAntennaHeightMeters: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(it) }
var stn_pathAnalysisOwnAntennaHeightMeters: Double
get() = prefs.getStn_pathAnalysisOwnAntennaHeightMeters()
set(value) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(value) }
var stn_pathAnalysisOwnTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisOwnTxPowerWatts() }) { prefs.setStn_pathAnalysisOwnTxPowerWatts(it) }
var stn_pathAnalysisOwnTxPowerWatts: Double
get() = prefs.getStn_pathAnalysisOwnTxPowerWatts()
set(value) { prefs.setStn_pathAnalysisOwnTxPowerWatts(value) }
/**
* Own ground elevation above sea level in metres, NaN while the elevation model decides.
* Staged like every path-analysis setting; the reachability service reads the stored
* value, so a changed height reaches the analysis only once it is applied.
*/
var stn_pathAnalysisOwnHeightAslMeters: Double by draft.setting({ prefs.getStn_pathAnalysisOwnHeightAslMeters() }) { prefs.setStn_pathAnalysisOwnHeightAslMeters(it) }
var stn_pstRotatorEnabled: Boolean
get() = prefs.isStn_pstRotatorEnabled()
set(value) { prefs.setStn_pstRotatorEnabled(value) }
/** Whether the path ends at the highest point of the remote locator square. */
var stn_pathAnalysisDxHighestPointInSquare: Boolean by draft.setting({ prefs.isStn_pathAnalysisDxHighestPointInSquare() }) { prefs.setStn_pathAnalysisDxHighestPointInSquare(it) }
var stn_pstRotatorHost: String
get() = prefs.getStn_pstRotatorHost()
set(value) { prefs.setStn_pstRotatorHost(value) }
/**
* The entry last committed to the own-height field, as the operator typed it.
*
* [Form.committed] keys its draft text on the stored text, and the staged number reads
* back in its own spelling ("25.0" for "25"). The settings draft is Compose state, so
* the commit itself recomposes the field with the new key, and without this the field
* would swap "25" for "25.0" under the cursor right after Enter, or drop keys typed
* since. Handing back the committed spelling while it still names the same number
* keeps key and text in agreement.
*/
private var committedOwnHeightAslText by mutableStateOf<String?>(null)
var stn_pstRotatorPort: Int
get() = prefs.getStn_pstRotatorPort()
set(value) { prefs.setStn_pstRotatorPort(value) }
/**
* The own ground elevation as its field shows it: empty while the elevation model
* decides. The spelling of the last commit while it still names the drafted number,
* otherwise that number itself (discarded, applied elsewhere, or never committed here).
* Written only through [commitOwnHeightAsl].
*/
val stn_pathAnalysisOwnHeightAslText: String
get() {
val drafted = stn_pathAnalysisOwnHeightAslMeters.let { if (it.isFinite()) it.toString() else "" }
val committed = committedOwnHeightAslText
return if (committed != null && isOwnHeightAslUnchanged(committed, drafted)) committed else drafted
}
/**
* Commits an own ground elevation entry into the draft. Empty hands the elevation back
* to the model; a number in range is staged; anything else is refused and nothing is
* staged.
*
* @return null when staged, otherwise the reason shown to the operator
*/
fun commitOwnHeightAsl(entered: String): String? {
val meters = parseOwnHeightAsl(entered)
?: return CurrentStrings.get().stationInvalidOwnHeightAsl(entered.trim())
stn_pathAnalysisOwnHeightAslMeters = meters
committedOwnHeightAslText = entered.trim()
return null
}
var stn_pstRotatorEnabled: Boolean by draft.setting({ prefs.isStn_pstRotatorEnabled() }) { prefs.setStn_pstRotatorEnabled(it) }
var stn_pstRotatorHost: String by draft.setting({ prefs.getStn_pstRotatorHost() }) { prefs.setStn_pstRotatorHost(it) }
/**
* Refused on apply when it clashes with rotor 2's port as it will be stored, but only
* while rotor 2 is in use (SO2R on and rotor 2 enabled), so a station without SO2R is
* never held back by rotor 2's default port. See [portsClash].
*/
var stn_pstRotatorPort: Int by draft.setting({ prefs.getStn_pstRotatorPort() }) { port ->
if (prefs.isTrxSynch_so2rEnabled() && stn_pstRotator2Enabled) {
require(!portsClash(port, stn_pstRotator2Port)) { CurrentStrings.get().stationRotator2PortClash(port.toString()) }
}
prefs.setStn_pstRotatorPort(port)
}
var stn_pstRotator2Enabled: Boolean by draft.setting(
{ prefs.isStn_pstRotator2Enabled() },
effectKey = SECOND_ROTATOR,
effect = { applySecondRotator() },
) { prefs.setStn_pstRotator2Enabled(it) }
var stn_pstRotator2Host: String by draft.setting(
{ prefs.getStn_pstRotator2Host() },
effectKey = SECOND_ROTATOR,
effect = { applySecondRotator() },
) { prefs.setStn_pstRotator2Host(it) }
/**
* Refused on apply when it is the same as or adjacent to rotator 1's port as it will be
* stored (see [portsClash]). Compared with the drafted value of rotator 1, so changing
* both in one apply is judged by the result, not by the order of the edits.
*/
var stn_pstRotator2Port: Int by draft.setting(
{ prefs.getStn_pstRotator2Port() },
effectKey = SECOND_ROTATOR,
effect = { applySecondRotator() },
) { port ->
require(!portsClash(port, stn_pstRotatorPort)) { CurrentStrings.get().stationRotator2PortClash(port.toString()) }
prefs.setStn_pstRotator2Port(port)
}
/** Whether the rotator 2 block is active; follows the stored SO2R switch. */
val so2rEnabled: Boolean
get() = prefs.isTrxSynch_so2rEnabled()
var stn_qtfDefault: Double by draft.setting({ prefs.getStn_qtfDefault() }) { prefs.setStn_qtfDefault(it) }
var stn_qtfDefault: Double
get() = prefs.getStn_qtfDefault()
set(value) { prefs.setStn_qtfDefault(value) }
/**
* The chat category is held twice: in ChatPreferences and in ChatController.
@@ -228,21 +164,17 @@ class StationTabState(
* pickers it governs. It decides what the Connect button does, so it belongs with
* the login settings and not with the display options.
*/
var loginToSecondChatEnabled: Boolean by draft.setting({ prefs.isLoginToSecondChatEnabled() }) { prefs.setLoginToSecondChatEnabled(it) }
var loginToSecondChatEnabled: Boolean
get() = prefs.isLoginToSecondChatEnabled()
set(value) { prefs.setLoginToSecondChatEnabled(value) }
/**
* Whether the login fields may be edited. The JavaFX window disabled callsign,
* password, name, locator and both category pickers once the session was logged
* in: changing them there would not reach the running connection, so an edit
* would look applied and be ignored until the next login.
*
* Locked for every state but DISCONNECTED: a reconnect wait or a session being
* stopped still belongs to the credentials it was started with. Derived on each read
* rather than held, so a composition reading it follows the connection state the
* moment that changes, provided [connectionState] reads Compose state.
*/
val loginFieldsEnabled: Boolean
get() = connectionState() == On4KstConnectionState.DISCONNECTED
var loginFieldsEnabled: Boolean = true
/**
* The categories the operator can log into, built the way the JavaFX choice box
@@ -278,40 +210,4 @@ class StationTabState(
loginChatCategorySecond = category
applySecondCategory(category)
}
companion object {
/** The effect key of everything that restarts rotator 2; shared with the TRX synch tab. */
const val SECOND_ROTATOR: String = "secondRotator"
/**
* Whether two PSTRotator ports collide: the same port, or one apart because
* PSTRotator answers on port + 1, which would be the other client's local port.
*/
fun portsClash(a: Int, b: Int): Boolean = kotlin.math.abs(a - b) <= 1
/**
* Reads an own ground elevation entry: empty means "elevation model" (NaN), a
* number in the accepted range is taken, anything else is refused with null.
* A comma is read as the decimal separator, as in the other number fields.
*/
fun parseOwnHeightAsl(text: String): Double? {
val trimmed = text.trim()
if (trimmed.isEmpty()) {
return Double.NaN
}
val meters = trimmed.replace(',', '.').toDoubleOrNull() ?: return null
return if (ChatPreferences.isValidOwnHeightAslMeters(meters)) meters else null
}
/**
* Whether a draft names the stored height. Compared as numbers, so "250", "250.0"
* and "250,0" all count as the stored 250.0, and an empty draft as the unset value;
* a draft that does not parse is never unchanged, so leaving the field refuses it.
*/
fun isOwnHeightAslUnchanged(draft: String, stored: String): Boolean {
val typed = parseOwnHeightAsl(draft) ?: return false
val now = parseOwnHeightAsl(stored) ?: return false
return if (typed.isNaN() || now.isNaN()) typed.isNaN() && now.isNaN() else typed == now
}
}
}
@@ -1,41 +0,0 @@
package kst4contest.view.compose.tabs
import kst4contest.model.Band
import kst4contest.model.ChatPreferences
/**
* Corrects stored settings the settings window cannot show as they are, once, before the
* window is built.
*
* These writes go to ChatPreferences at once and not through the draft: they are no edit of
* the operator's, there is nothing to apply or discard, and they are what the JavaFX window
* did while building its panes. They used to happen inside the tab states -- one during a
* read in composition, one in a constructor -- where a write to the preferences is easy to
* miss and runs whenever Compose decides to read. Here they run at one known moment.
*
* - The fallback band of the DX cluster is stored as a prefix string; one no band has is
* repaired to 144 (Kst4ContestApplication:11349 in the JavaFX window).
* - The Win-Test broadcast address is replaced by the detected subnet broadcast address
* while it is still the catch-all default 255.255.255.255. An address the operator or a
* previous detection chose is left alone, and a failed detection keeps the default:
* guessing an address would send SKED packets somewhere nobody is listening.
*
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null when
* none could be determined; maps to detectPreferredWintestBroadcastAddress()
*/
fun repairStoredSettings(prefs: ChatPreferences, detectWintestBroadcastAddress: () -> String?) {
if (Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) == null) {
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
}
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() == LogSynchTabState.DEFAULT_BROADCAST_ADDRESS) {
val detected = try {
detectWintestBroadcastAddress()
} catch (probeFailure: RuntimeException) {
null
}
if (!detected.isNullOrBlank()) {
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
}
}
}
@@ -20,7 +20,8 @@ import kst4contest.view.compose.Form
* QRG update needs an enabled source *and* valid incoming RadioInfo or Win-Test STATUS
* data, so a checked box must not read as "my QRG is being kept current".
*
* The controls stage their edits in the window's draft (see TrxSynchTabState).
* No confirmation callback: TrxSynchTabState writes through to ChatPreferences, the way
* the JavaFX controls did.
*/
@Composable
fun TrxSynchTab(state: TrxSynchTabState) {
@@ -62,24 +63,6 @@ fun TrxSynchTab(state: TrxSynchTabState) {
)
}
Form.section(strings.trxSynchSo2rSection) {
Form.check(
strings.trxSynchSo2rEnabled,
state.trxSynch_so2rEnabled,
) { state.trxSynch_so2rEnabled = it; revision++ }
GuardedTextField(
label = strings.trxSynchWinTestStationRadio2,
value = state.logsynch_wintestNetworkStationNameOfWintestClient2,
enabled = state.trxSynch_so2rEnabled,
onAccepted = {
state.logsynch_wintestNetworkStationNameOfWintestClient2 = it
revision++
},
)
Text(strings.trxSynchSo2rHint)
}
Form.section(strings.trxSynchHintSection) {
Text(strings.trxSynchHint)
}
@@ -1,14 +1,12 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.compose.setting
/**
* The TRX synch tab as a typed facade over ChatPreferences.
*
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
* applies them.
* Every property writes through immediately and there is deliberately no apply(), the
* way the JavaFX controls behaved.
*
* The tab carries the two sources that may update the own QRG. Both live under the
* logsynch_ and trxSynch_ prefixes, but the JavaFX window put both controls on this
@@ -20,42 +18,34 @@ import kst4contest.view.compose.setting
* Win-Test STATUS data. [QRG_SOURCE_HINT_KEY] names the text the tab shows so an operator
* cannot read a checked box as "my QRG is being kept current".
*
* @param applySecondRotator restarts or stops rotator 2 to match the stored SO2R and rotator 2
* switches, once per apply. Declared before [applyOwnQrgFollower] so callers passing the
* follower as a trailing lambda keep binding to it.
* @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled
* and detaches it otherwise, once per apply however many sources changed; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
* and detaches it otherwise; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
* plus the tooltip on the own-QRG field. Not a ChatPreferences setter, so no coverage
* test can see it, and passed in so the rules stay testable without a controller.
*/
class TrxSynchTabState(
private val prefs: ChatPreferences,
private val draft: SettingsDraft,
private val applySecondRotator: () -> Unit = {},
private val applyOwnQrgFollower: (Boolean) -> Unit,
) {
var trxSynch_ucxLogUDPListenerEnabled: Boolean by draft.setting(
{ prefs.isTrxSynch_ucxLogUDPListenerEnabled() },
effectKey = OWN_QRG_FOLLOWER,
effect = ::applyFollower,
) { prefs.setTrxSynch_ucxLogUDPListenerEnabled(it) }
var trxSynch_ucxLogUDPListenerEnabled: Boolean
get() = prefs.isTrxSynch_ucxLogUDPListenerEnabled()
set(value) {
prefs.setTrxSynch_ucxLogUDPListenerEnabled(value)
applyOwnQrgFollower(anyQrgSourceEnabled)
}
var logsynch_wintestQrgSyncEnabled: Boolean by draft.setting(
{ prefs.isLogsynch_wintestQrgSyncEnabled() },
effectKey = OWN_QRG_FOLLOWER,
effect = ::applyFollower,
) { prefs.setLogsynch_wintestQrgSyncEnabled(it) }
var logsynch_wintestQrgSyncEnabled: Boolean
get() = prefs.isLogsynch_wintestQrgSyncEnabled()
set(value) {
prefs.setLogsynch_wintestQrgSyncEnabled(value)
applyOwnQrgFollower(anyQrgSourceEnabled)
}
/** Use the Win-Test pass frequency from STATUS instead of the own QRG. */
var logsynch_wintestUsePassQrg: Boolean by draft.setting(
{ prefs.isLogsynch_wintestUsePassQrg() },
) { prefs.setLogsynch_wintestUsePassQrg(it) }
// Staged trimmed, so the draft holds what will be stored.
private var stationFilter: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1() },
) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(it) }
var logsynch_wintestUsePassQrg: Boolean
get() = prefs.isLogsynch_wintestUsePassQrg()
set(value) { prefs.setLogsynch_wintestUsePassQrg(value) }
/**
* Win-Test station name filter. An empty filter accepts every station, which is
@@ -63,29 +53,8 @@ class TrxSynchTabState(
* way the JavaFX field committed it.
*/
var logsynch_wintestNetworkStationNameOfWintestClient1: String
get() = stationFilter
set(value) { stationFilter = value.trim() }
/** SO2R: radio 2 gets SECONDQRG and, with its own switch, rotator 2. */
var trxSynch_so2rEnabled: Boolean by draft.setting(
{ prefs.isTrxSynch_so2rEnabled() },
effectKey = StationTabState.SECOND_ROTATOR,
effect = { applySecondRotator() },
) { prefs.setTrxSynch_so2rEnabled(it) }
private var stationRadio2: String by draft.setting(
{ prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2() },
) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient2(it) }
/** Win-Test station of radio 2; empty sends nothing to radio 2. Staged trimmed. */
var logsynch_wintestNetworkStationNameOfWintestClient2: String
get() = stationRadio2
set(value) { stationRadio2 = value.trim() }
/* Read from the stored values: it runs on apply, after both switches are written. */
private fun applyFollower() {
applyOwnQrgFollower(prefs.isTrxSynch_ucxLogUDPListenerEnabled() || prefs.isLogsynch_wintestQrgSyncEnabled())
}
get() = prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(value.trim()) }
/**
* True when at least one source may update the own QRG. One source switched off is
@@ -105,8 +74,5 @@ class TrxSynchTabState(
* would read whichever one the tab happened to use.
*/
const val QRG_SOURCE_HINT_KEY: String = "trxSynch.hint"
/** The effect key both QRG sources share. */
private const val OWN_QRG_FOLLOWER = "ownQrgFollower"
}
}
@@ -1,70 +0,0 @@
package kst4contest.testing;
import java.time.Duration;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
/**
* Bounded polling for tests that wait on another thread.
*
* <p>Replaces fixed {@code Thread.sleep} pauses: a condition that is already true returns
* at once, and one that never becomes true fails with a message instead of letting the
* test pass or fail on the speed of the machine.</p>
*/
public final class Await {
private static final long POLL_INTERVAL_MS = 10L;
private Await() {
}
/**
* Polls {@code condition} every 10 ms until it is true.
*
* @param timeout how long to keep polling before failing
* @param condition what must become true
* @throws AssertionError when the timeout elapses first
*/
public static void awaitUntil(Duration timeout, BooleanSupplier condition) {
awaitUntil(timeout, "condition not met within " + timeout, condition);
}
/**
* Polls {@code condition} every 10 ms until it is true.
*
* @param timeout how long to keep polling before failing
* @param message what the failure should say when the timeout elapses
* @param condition what must become true
* @throws AssertionError when the timeout elapses first
*/
public static void awaitUntil(Duration timeout, String message, BooleanSupplier condition) {
awaitUntil(timeout, () -> message, condition);
}
/**
* Polls {@code condition} every 10 ms until it is true.
*
* @param timeout how long to keep polling before failing
* @param message builds the failure message at the moment of failure, so it can
* describe the state that was actually seen
* @param condition what must become true
* @throws AssertionError when the timeout elapses first
*/
public static void awaitUntil(Duration timeout, Supplier<String> message, BooleanSupplier condition) {
long deadline = System.nanoTime() + timeout.toNanos();
while (true) {
if (condition.getAsBoolean()) {
return;
}
if (System.nanoTime() >= deadline) {
throw new AssertionError(message.get());
}
try {
Thread.sleep(POLL_INTERVAL_MS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("interrupted while waiting: " + message.get(), interrupted);
}
}
}
}
@@ -3,13 +3,12 @@ package kst4contest.view;
import org.junit.jupiter.api.Test;
import java.awt.EventQueue;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static kst4contest.testing.Await.awaitUntil;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -18,89 +17,85 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and both ways of being
* wrong are invisible in a build: too eager and the table redraws on every message under
* contest load, never and the table stops updating altogether.</p>
*
* <p>All but one test run on a {@link FakeDelayTimer} the test fires itself, so "it did
* not run" is a fact and not a 400 ms guess. The one exception goes through the real
* Swing timer so the default wiring stays covered.</p>
*/
class CoalescingTriggerTest {
@Test
void aBurstOfTriggersProducesOneRun() {
void aBurstOfTriggersProducesOneRun() throws Exception {
AtomicInteger runs = new AtomicInteger();
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet, timers);
CountDownLatch ran = new CountDownLatch(1);
CoalescingTrigger trigger = new CoalescingTrigger(120, () -> {
runs.incrementAndGet();
ran.countDown();
});
// What a busy chat looks like: many updates, one intended redraw.
for (int i = 0; i < 25; i++) {
trigger.trigger();
}
assertEquals(0, runs.get(), "nothing may run before the delay elapses");
timers.timer().fire();
assertTrue(ran.await(5, TimeUnit.SECONDS), "the burst never produced a run");
Thread.sleep(300);
assertEquals(1, runs.get(), "a burst must coalesce into a single run");
timers.timer().fire();
assertEquals(1, runs.get(), "the timer is one-shot; a burst must not queue a second run");
}
/** The real Swing timer, end to end: the action has to arrive on the event thread. */
@Test
void theActionRunsOnTheEventThread() {
void theActionRunsOnTheEventThread() throws Exception {
AtomicBoolean onEventThread = new AtomicBoolean();
AtomicBoolean ran = new AtomicBoolean();
CountDownLatch ran = new CountDownLatch(1);
CoalescingTrigger trigger = new CoalescingTrigger(60, () -> {
onEventThread.set(EventQueue.isDispatchThread());
ran.set(true);
ran.countDown();
});
trigger.trigger();
awaitUntil(Duration.ofSeconds(5), "the Swing timer never fired", ran::get);
assertTrue(ran.await(5, TimeUnit.SECONDS));
assertTrue(onEventThread.get(), "the action redraws tables and must be on the UI thread");
}
@Test
void aLaterTriggerPostponesTheRun() {
void aLaterTriggerPostponesTheRun() throws Exception {
AtomicInteger runs = new AtomicInteger();
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
CoalescingTrigger trigger = new CoalescingTrigger(250, runs::incrementAndGet, timers);
CoalescingTrigger trigger = new CoalescingTrigger(250, runs::incrementAndGet);
trigger.trigger();
Thread.sleep(150);
trigger.trigger();
Thread.sleep(150);
assertEquals(2, timers.timer().restarts(),
// 300 ms have passed, but the second trigger restarted the wait at 150 ms.
assertEquals(0, runs.get(),
"a new trigger must restart the delay, not let the old one fire");
assertEquals(0, runs.get(), "nothing has elapsed yet, so nothing may have run");
assertEquals(250, timers.delayMs());
}
@Test
void cancellingBeforeTheDelayElapsesSuppressesTheRun() {
void cancellingBeforeTheDelayElapsesSuppressesTheRun() throws Exception {
AtomicInteger runs = new AtomicInteger();
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet, timers);
CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet);
trigger.trigger();
trigger.cancel();
assertFalse(timers.timer().isArmed(), "cancel must stop the timer");
Thread.sleep(400);
timers.timer().fire();
assertEquals(0, runs.get(), "a cancelled trigger must not run; shutdown relies on it");
}
@Test
void triggeringAgainAfterACancelStillWorks() {
void triggeringAgainAfterACancelStillWorks() throws Exception {
AtomicInteger runs = new AtomicInteger();
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
CoalescingTrigger trigger = new CoalescingTrigger(80, runs::incrementAndGet, timers);
CountDownLatch ran = new CountDownLatch(1);
CoalescingTrigger trigger = new CoalescingTrigger(80, () -> {
runs.incrementAndGet();
ran.countDown();
});
trigger.trigger();
trigger.cancel();
trigger.trigger();
assertTrue(timers.timer().isArmed(), "a cancel must not disable the trigger for good");
timers.timer().fire();
assertTrue(ran.await(5, TimeUnit.SECONDS),
"a cancel must not disable the trigger for good");
assertEquals(1, runs.get());
}
}
@@ -1,73 +0,0 @@
package kst4contest.view;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* A {@link DelayTimer} that fires only when the test says so.
*
* <p>Models exactly what the Swing timer promises and nothing about wall time: a
* {@code restart()} arms it, a {@code stop()} disarms it, and {@link #fire()} runs the
* action once if it is armed. That turns "the run did not happen" from a wait-and-hope
* into an assertion.</p>
*/
final class FakeDelayTimer implements DelayTimer {
private final Runnable onFire;
private boolean armed;
private int restarts;
FakeDelayTimer(final Runnable onFire) {
this.onFire = onFire;
}
@Override
public void restart() {
armed = true;
restarts++;
}
@Override
public void stop() {
armed = false;
}
/** What the Swing timer does when the delay elapses: run once, then disarm. */
void fire() {
if (!armed) {
return;
}
armed = false;
onFire.run();
}
boolean isArmed() {
return armed;
}
int restarts() {
return restarts;
}
/** Hands out one fake timer and keeps it for the test to fire. */
static final class Factory implements DelayTimer.Factory {
private FakeDelayTimer created;
private int createdDelayMs;
@Override
public DelayTimer create(final int delayMs, final Runnable onFire) {
created = new FakeDelayTimer(onFire);
createdDelayMs = delayMs;
return created;
}
FakeDelayTimer timer() {
assertNotNull(created, "the class under test did not create its timer");
return created;
}
int delayMs() {
return createdDelayMs;
}
}
}
@@ -4,13 +4,11 @@ import kst4contest.observe.UiDispatcher;
import org.junit.jupiter.api.Test;
import java.awt.EventQueue;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static kst4contest.testing.Await.awaitUntil;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -19,72 +17,59 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and the failure mode is
* silent: a timer that never fires means the operator's column widths are simply not
* saved, with nothing to see until the next start.</p>
*
* <p>The negative checks run on a {@link FakeDelayTimer} the test fires itself, on the
* event thread as the Swing timer would, so "nothing was written" is a fact rather than a
* 1.2 s guess. The first test goes through the real Swing timer so the default wiring
* stays covered.</p>
*/
class LayoutAutosaveTest {
private final UiDispatcher dispatcher = new AwtUiDispatcher();
/** The real Swing timer, end to end, from a thread that is not the event thread. */
@Test
void aRequestFromAForeignThreadEventuallyWrites() {
AtomicBoolean written = new AtomicBoolean();
LayoutAutosave autosave = new LayoutAutosave(() -> written.set(true), dispatcher);
void aRequestFromAForeignThreadEventuallyWrites() throws Exception {
CountDownLatch written = new CountDownLatch(1);
LayoutAutosave autosave = new LayoutAutosave(written::countDown, dispatcher);
autosave.requestSave();
awaitUntil(Duration.ofSeconds(5),
"a save requested off the user-interface thread must still happen",
written::get);
assertTrue(written.await(5, TimeUnit.SECONDS),
"a save requested off the user-interface thread must still happen");
}
@Test
void burstsOfRequestsCollapseIntoOneWrite() throws Exception {
AtomicInteger writes = new AtomicInteger();
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher, timers);
CountDownLatch written = new CountDownLatch(1);
LayoutAutosave autosave = new LayoutAutosave(() -> {
writes.incrementAndGet();
written.countDown();
}, dispatcher);
// What dragging a column divider looks like: many events, one intended save.
EventQueue.invokeAndWait(() -> {
for (int i = 0; i < 20; i++) {
autosave.requestSave();
}
});
assertEquals(20, timers.timer().restarts(), "every request must restart the delay");
assertEquals(0, writes.get(), "nothing may be written before the delay elapses");
for (int i = 0; i < 20; i++) {
autosave.requestSave();
}
EventQueue.invokeAndWait(timers.timer()::fire);
assertTrue(written.await(5, TimeUnit.SECONDS), "the burst never produced a write");
Thread.sleep(300);
assertEquals(1, writes.get(), "a burst must collapse into a single write");
EventQueue.invokeAndWait(timers.timer()::fire);
assertEquals(1, writes.get(), "the timer is one-shot; a burst must not queue a second write");
}
@Test
void cancellingBeforeTheDelayElapsesSuppressesTheWrite() throws Exception {
AtomicInteger writes = new AtomicInteger();
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher, timers);
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher);
EventQueue.invokeAndWait(() -> {
autosave.requestSave();
autosave.cancelPending();
});
assertFalse(timers.timer().isArmed(), "cancelling must stop the timer");
EventQueue.invokeAndWait(timers.timer()::fire);
Thread.sleep(1_200);
assertEquals(0, writes.get(), "a cancelled save must not be written");
}
@Test
void flushingWritesAtOnceAndOnlyOnce() throws Exception {
AtomicInteger writes = new AtomicInteger();
FakeDelayTimer.Factory timers = new FakeDelayTimer.Factory();
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher, timers);
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher);
EventQueue.invokeAndWait(() -> {
autosave.requestSave();
@@ -94,7 +79,5 @@ class LayoutAutosaveTest {
});
assertEquals(1, writes.get(), "flushing a pending save must write exactly once");
assertFalse(timers.timer().isArmed(), "a flush must also stop the pending timer");
assertTrue(timers.timer().restarts() > 0, "the request must have armed the timer first");
}
}
@@ -4,7 +4,6 @@ import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Test
/**
@@ -98,70 +97,6 @@ class AirplaneCellTest {
assertEquals(AirplaneCell.RED_75, AirplaneCell.accentFor("7 (50%) / 12 (75%)")?.foreground)
}
// ---- the order ----------------------------------------------------------
/**
* Issue #87: the column was sorted by its rendered text, so "13 (75%)" came before
* "3 (50%)". These are the thirteen cells of the screenshot in the issue, in the order
* they were shown; sorted, the minutes have to read as numbers.
*/
@Test
fun `the minutes sort as numbers, not as text`() {
val sorted = screenshotCells().sortedWith(AirplaneCell.order)
assertEquals(
listOf(1, 1, 3, 3, 6, 6, 8, 8, 9, 9, 13, 14, 19),
sorted.map { it.risingAirplanes.first().arrivingDurationMinutes },
)
}
/** Same minutes: the better potential first, which is also the one the colour marks. */
@Test
fun `equal minutes put the better potential first`() {
val sorted = screenshotCells().sortedWith(AirplaneCell.order)
assertEquals(
listOf("3 (75%)", "3 (50%)"),
sorted.map { AirplaneCell.format(it) }.filter { it.startsWith("3 ") },
"the screenshot had 3 (50%) above 3 (75%); the order is not just stable",
)
}
/**
* An empty cell (nothing computed) and "nil" (no aeroplane coming) both carry the
* maximum sentinel, so ascending puts them after the last real aeroplane.
*/
@Test
fun `no aeroplane sorts after the last real one`() {
val nothingComputed: AirPlaneReflectionInfo? = null
val nothingComing = reflectionInfo()
val cells = listOf(nothingComputed, nothingComing) + screenshotCells()
val sorted = cells.sortedWith(AirplaneCell.order)
assertEquals(19, sorted[sorted.size - 3]?.risingAirplanes?.first()?.arrivingDurationMinutes)
assertSame(nothingComputed, sorted[sorted.size - 2])
assertSame(nothingComing, sorted[sorted.size - 1])
}
/** Only the first aeroplane, the one the operator reads first, decides. */
@Test
fun `the second aeroplane of a cell does not influence the order`() {
val nineThenOne = reflectionInfo(plane(9, 75), plane(1, 100))
val threeThenTwenty = reflectionInfo(plane(3, 50), plane(20, 50))
val sorted = listOf(nineThenOne, threeThenTwenty).sortedWith(AirplaneCell.order)
assertSame(threeThenTwenty, sorted[0])
assertSame(nineThenOne, sorted[1])
}
/** The AP column of the issue's screenshot, top to bottom: (minutes, potential). */
private fun screenshotCells(): List<AirPlaneReflectionInfo> = listOf(
1 to 75, 1 to 75, 13 to 75, 14 to 50, 19 to 50, 3 to 50, 3 to 75,
6 to 75, 6 to 75, 8 to 75, 8 to 75, 9 to 75, 9 to 75,
).map { (minutes, potential) -> reflectionInfo(plane(minutes, potential)) }
private fun reflectionInfo(vararg planes: AirPlane): AirPlaneReflectionInfo =
AirPlaneReflectionInfo().apply { risingAirplanes = planes.toMutableList() }
@@ -1,69 +0,0 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/** The send line with SO2R: the display-only QTF2 field appears only while SO2R is on. */
@OptIn(ExperimentalTestApi::class)
class ChatInputRowSo2rScreenTest {
@TempDir
lateinit var tempDir: Path
private fun prefs() = ChatPreferences(tempDir.resolve("preferences.xml")).apply {
getActualQTF().set(145.0)
getActualQTF2().set(270.0)
}
private fun chatInputStateFor(prefs: ChatPreferences) = ChatInputState(object : ChatInputActions {
override fun panelSelection(): ChatMember? = null
override fun tableSelection(): ChatMember? = null
override fun scoreSelection(): ChatMember? = null
override fun resolveVariables(template: String, member: ChatMember?): String? = template
override fun isAddressedToOwnCallsign(text: String): Boolean = false
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null
override fun mainCategory(): ChatCategory? = null
override fun recordOutboundCq(text: String) {}
override fun requestRecompute(reason: String) {}
override fun queue(message: ChatMessage) {}
override fun ownQrgMain(): String = prefs.getMYQRGFirstCat().get().orEmpty()
override fun ownQrgSecond(): String = prefs.getMYQRGSecondCat().get().orEmpty()
})
@Test
fun `QTF2 is shown only with SO2R and cannot be edited`() = runComposeUiTest {
val prefs = prefs()
var so2r by mutableStateOf(false)
setContent {
ChatInputRow(
state = chatInputStateFor(prefs),
prefs = prefs,
ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat()),
ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat()),
antennaQtf = ObservedValue(prefs.getActualQTF()),
antennaQtf2 = ObservedValue(prefs.getActualQTF2()),
so2rEnabled = so2r,
)
}
onAllNodesWithText("270.0").assertCountEquals(0)
so2r = true
waitForIdle()
onNodeWithText("270.0").assertExists()
onNodeWithText("270.0").assertIsNotEnabled()
}
}
@@ -322,26 +322,6 @@ class DataTableStateTest {
assertSame(kept, table.selectedRow)
}
/**
* The filter reads fields of the row objects, which change in place: a station's QRB,
* its worked flags, its last activity. forceRedraw is how the application says so, and
* the cached visible rows must be filtered again after it (#21).
*/
@Test
fun `a redraw filters rows again whose fields changed in place`() {
val table = stationTable()
val moving = Station("DN9APW", 100)
table.replaceRows(listOf(moving, Station("DO5AMF", 400)))
table.rowFilter = { it.qrb > 300 }
assertEquals(listOf("DO5AMF"), table.visibleRows.map { it.callSign })
moving.qrb = 500
table.forceRedraw()
assertEquals(listOf("DN9APW", "DO5AMF"), table.visibleRows.map { it.callSign }.sorted(),
"the cached rows from before the change were handed back")
}
}
/**
@@ -6,7 +6,6 @@ import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@@ -99,33 +98,6 @@ class DirectedMessageColumnsTest {
assertEquals("7 (85%)", cell("airscout", message(sender = sender)))
}
/**
* Issue #87: the AP column had no comparator and fell back to its text, so "13 (75%)"
* sorted before "9 (75%)". Same shape as DataTableStateTest's "a column with a
* comparator sorts by it, not by its text", with the real columns.
*/
@Test
fun `airscout sorts numerically`() {
assertNotNull(columns.single { it.id == "airscout" }.comparator, "the AP column sorts by its own key")
val table = DataTableState(columns = columns, rowKey = RowKeys.byReference<ChatMessage>())
table.replaceRows(listOf(
message(sender = memberWithAeroplane("DL13ABC", minutes = 13)),
message(sender = memberWithAeroplane("DL9ABC", minutes = 9)),
))
table.toggleSort("airscout")
assertEquals(listOf("DL9ABC", "DL13ABC"), table.visibleRows.map { it.sender?.callSign },
"9 before 13 numerically; by text it would be the other way round")
}
private fun memberWithAeroplane(call: String, minutes: Int): ChatMember = member(call).apply {
airPlaneReflectInfo = AirPlaneReflectionInfo().apply {
risingAirplanes = mutableListOf(AirPlane().apply { arrivingDurationMinutes = minutes; potential = 75 })
}
}
@Test
fun `the category names the chat the message arrived in`() {
val message = message(sender = member("DL0ABC")).apply { chatCategory = ChatCategory(2) }
@@ -20,8 +20,6 @@ import kst4contest.view.compose.tabs.MessageHandlingTabState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The four ways the drawing layer went wrong while the build was green.
@@ -43,7 +41,7 @@ import java.nio.file.Path
* these were written, because the spec left it open and a net that only works on a
* developer's desk is worth saying so about.
*/
class DrawingLayerRegressionTest(@TempDir private val tempDir: Path) {
class DrawingLayerRegressionTest {
private fun themed(content: @Composable () -> Unit): @Composable () -> Unit = {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { content() }
@@ -54,9 +52,9 @@ class DrawingLayerRegressionTest(@TempDir private val tempDir: Path) {
@OptIn(ExperimentalTestApi::class)
@Test
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
prefs.setMessageHandling_autoAnswerTextMainCat("")
val state = MessageHandlingTabState(prefs, ImmediateDraft())
val state = MessageHandlingTabState(prefs)
// The real tab, not a field in isolation: the fault was in the wiring between them.
setContent(themed { MessageHandlingTab(state) })
@@ -1,54 +0,0 @@
package kst4contest.view.compose
import java.awt.Point
import java.awt.Rectangle
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The size and position the host fitted a window to are not the operator's: the first
* matching report is swallowed, and nothing after it.
*/
class FitRecordTest {
private val fitted = Rectangle(1920, 0, 640, 480)
@Test
fun `nothing counts as fitted before a fit`() {
val fit = FitRecord()
assertFalse(fit.takeSize(640, 480))
assertFalse(fit.takePosition(Point(1920, 0)))
}
@Test
fun `the fit echoing back is recognised despite rounding and a nudge`() {
val fit = FitRecord()
fit.record(fitted)
assertTrue(fit.takeSize(641, 479))
assertTrue(fit.takePosition(Point(1920 + 10, 0 + 28)))
}
@Test
fun `a fit is consumed once, so a later identical resize or move is the operator's`() {
val fit = FitRecord()
fit.record(fitted)
fit.takeSize(640, 480)
fit.takePosition(Point(1920, 0))
assertFalse(fit.takeSize(640, 480))
assertFalse(fit.takePosition(Point(1920, 0)))
}
@Test
fun `an operator report in between clears the record`() {
val fit = FitRecord()
fit.record(fitted)
assertFalse(fit.takeSize(900, 700))
assertFalse(fit.takePosition(Point(100, 100)))
assertFalse(fit.takeSize(640, 480))
}
}
@@ -1,26 +0,0 @@
package kst4contest.view.compose
/**
* A draft that applies every change the moment it is staged.
*
* For the tests that pin a tab state's own rules -- validation, normalisation, which effect
* an edit triggers -- without the draft in between. The draft itself is pinned by
* SettingsDraftTest, and that every drafted setting really waits for Apply by
* DraftedSettingsTableTest. A failed write or effect throws here, so a test cannot pass
* over one unnoticed.
*/
internal class ImmediateDraft : SettingsDraft() {
override fun <T> stage(
key: String,
value: T,
live: () -> T,
write: (T) -> Unit,
effectKey: String?,
effect: (() -> Unit)?,
) {
super.stage(key, value, live, write, effectKey, effect)
val outcome = apply()
check(outcome.isClean) { "apply failed: ${outcome.failures}" }
}
}
@@ -1,323 +0,0 @@
package kst4contest.view.compose
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.PaletteRole
import kst4contest.view.i18n.LanguageStore
import kst4contest.view.i18n.LocalStrings
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.Locale
/**
* The in-window menu row, drawn and clicked (#107).
*
* [MainMenuModelTest] covers the bar as data. This covers what the operator actually meets:
* a title that opens, an item that runs its own action, a label that follows the state while
* the row is on screen. Two regressions are the reason it exists:
*
* - `fe4215bc` (Remove JavaFX, in `main` as `2a5c89b9`): the sixteen menu actions still hung
* on JavaFX `MenuItem`s that had been deleted, so picking them in the Compose menu did
* nothing. Found by a GUI acceptance run, never by the build. They were moved onto their
* own `menuAction…` methods behind [MainMenuActions].
* - `83e23f55` (Draw the menu bar in Compose, in `main` as `2a5c89b9`): Compose's `MenuBar`
* is a Swing `JMenuBar` that no theme reaches, so Linux showed the default Metal menu above
* a themed window. The row is now Compose and reads the stylesheet palette.
*
* Headless: only a composition, no window.
*/
@OptIn(ExperimentalTestApi::class)
class MainMenuRowScreenTest {
private class RecordingActions : MainMenuActions {
val calls = mutableListOf<String>()
override fun connect() { calls += "connect" }
override fun disconnect() { calls += "disconnect" }
override fun switchOperatorProfile() { calls += "switchOperatorProfile" }
override fun exitApplication() { calls += "exitApplication" }
override fun setQrgAsNameInChat() { calls += "setQrgAsNameInChat" }
override fun toggleAwayState() { calls += "toggleAwayState" }
override fun toggleSettingsWindow() { calls += "toggleSettingsWindow" }
override fun toggleMonitorWindow() { calls += "toggleMonitorWindow" }
override fun toggleStationMap() { calls += "toggleStationMap" }
override fun useDarkDesign() { calls += "useDarkDesign" }
override fun useDefaultDesign() { calls += "useDefaultDesign" }
override fun openDonationPage() { calls += "openDonationPage" }
override fun openHomepage() { calls += "openHomepage" }
override fun openNewsgroup() { calls += "openNewsgroup" }
override fun openChangelog() { calls += "openChangelog" }
override fun openOv3tDonationPage() { calls += "openOv3tDonationPage" }
override fun contactAuthor() { calls += "contactAuthor" }
override fun showAbout() { calls += "showAbout" }
}
private val rowTag = "menu-row"
private fun ComposeUiTest.showRow(
state: MainMenuState,
actions: MainMenuActions,
rowState: MainMenuRowState = MainMenuRowState(),
settingsOpen: () -> Boolean = { false },
monitorOpen: () -> Boolean = { false },
) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Kst4ContestMenuRow(
state = state,
actions = actions,
settingsWindowOpen = settingsOpen(),
monitorWindowOpen = monitorOpen(),
rowState = rowState,
modifier = Modifier.testTag(rowTag),
)
}
}
}
private fun onlineState() = MainMenuState().apply {
connectionState = On4KstConnectionState.ONLINE
connectLabel = "Connect to 144/432 MHz"
}
/**
* Every item, picked through the drawn row, reaches its own action and nobody else's.
* This is the `fe4215bc` fault in the form the operator saw it: the item was there and
* picking it did nothing.
*/
@Test
fun everyItemPickedInTheRowRunsItsOwnAction() = runComposeUiTest {
val actions = RecordingActions()
val state = onlineState()
val rowState = MainMenuRowState()
showRow(state, actions, rowState)
// Online, so Disconnect is usable and Connect is not; Connect has its own test below.
val expected = listOf(
Triple("File", "Disconnect", "disconnect"),
Triple("File", "Switch operator profile...", "switchOperatorProfile"),
Triple("File", "Exit + disconnect", "exitApplication"),
Triple("Options", "Set QRG as name in Chat (main category)", "setQrgAsNameInChat"),
Triple("Options", "Show me as AWAY FROM chat!", "toggleAwayState"),
Triple("Options", "Show options", "toggleSettingsWindow"),
Triple("Windows", "Show cluster / stranger QSOs", "toggleMonitorWindow"),
Triple("Windows", "Show / hide station map", "toggleStationMap"),
Triple("Windows", "Use dark mode design", "useDarkDesign"),
Triple("Windows", "Use default mode design", "useDefaultDesign"),
Triple("Info", "Donate for kst4Contest development via PayPal", "openDonationPage"),
Triple("Info", "Donate for OV3T´s plane feed service", "openOv3tDonationPage"),
Triple("Info", "Visit DARC X08-Homepage", "openHomepage"),
Triple("Info", "Join kst4Contest newsgroup", "openNewsgroup"),
Triple("Info", "Contact the author using default mail app", "contactAuthor"),
Triple("Info", "About...", "showAbout"),
)
expected.forEach { (menu, item, action) ->
onNodeWithText(menu).performClick()
awaitNode(inPopup(item)).performClick()
awaitGone(inPopup(item))
assertEquals(action, actions.calls.lastOrNull(), "'$menu > $item' ran the wrong action")
assertEquals(null, rowState.openMenu, "'$menu' stayed down after '$item' was picked")
}
assertEquals(
expected.size,
actions.calls.size,
"an item ran more than one action, or one ran none",
)
}
/** Offline, Connect works; once an attempt runs it is greyed out and Disconnect works. */
@Test
fun theConnectItemsFollowTheConnectionStateWhileTheMenuIsDown() = runComposeUiTest {
val actions = RecordingActions()
val state = MainMenuState().apply { connectLabel = "Connect to 144/432 MHz" }
showRow(state, actions)
onNodeWithText("File").performClick()
awaitNode(inPopup("Connect to 144/432 MHz")).assertIsEnabled()
onNode(inPopup("Disconnect")).assertIsNotEnabled()
onNode(inPopup("Connect to 144/432 MHz")).performClick()
awaitGone(inPopup("Connect to 144/432 MHz"))
assertEquals(listOf("connect"), actions.calls, "Connect did not run while offline")
onNodeWithText("File").performClick()
awaitNode(inPopup("Connect to 144/432 MHz"))
// The state changes from elsewhere -- the connection callback -- with the menu down.
runOnIdle { state.connectionState = On4KstConnectionState.CONNECTING }
waitForIdle()
onNodeWithText("Connect to 144/432 MHz").assertIsNotEnabled()
onNodeWithText("Disconnect").assertIsEnabled()
onNodeWithText("Connect to 144/432 MHz").performClick()
waitForIdle()
assertEquals(listOf("connect"), actions.calls, "a disabled item ran its action")
}
/**
* The away item's label is the only place the AFK state is shown at all. JavaFX flipped
* it inside the click handler, which left it wrong whenever the state changed from
* elsewhere; here it must follow the state, drawn.
*/
@Test
fun theAwayLabelFollowsTheStateAndNotTheClick() = runComposeUiTest {
val actions = RecordingActions()
val state = onlineState()
showRow(state, actions)
onNodeWithText("Options").performClick()
waitForIdle()
onNodeWithText("Show me as AWAY FROM chat!").performClick()
waitForIdle()
// The click alone changes nothing: the action did not touch the state.
onNodeWithText("Options").performClick()
waitForIdle()
onNodeWithText("Show me as AWAY FROM chat!").assertExists()
runOnIdle { state.awayFromChat = true }
waitForIdle()
onNodeWithText("Show me as ACTIVE in chat!").assertExists()
onNodeWithText("Show me as AWAY FROM chat!").assertDoesNotExist()
}
/** Both toggles name the next action, and follow windows that close on their own. */
@Test
fun theWindowTogglesFollowWhetherTheWindowIsOpen() = runComposeUiTest {
var monitorOpen by mutableStateOf(false)
showRow(onlineState(), RecordingActions(), monitorOpen = { monitorOpen })
onNodeWithText("Windows").performClick()
waitForIdle()
onNodeWithText("Show cluster / stranger QSOs").assertExists()
runOnIdle { monitorOpen = true }
waitForIdle()
onNodeWithText("Hide cluster / stranger QSOs").assertExists()
onNodeWithText("Show cluster / stranger QSOs").assertDoesNotExist()
}
/** A second click on an open title closes it again, the way a menu bar behaves. */
@Test
fun clickingAnOpenTitleClosesIt() = runComposeUiTest {
val rowState = MainMenuRowState()
showRow(onlineState(), RecordingActions(), rowState)
onNodeWithText("Info").performClick()
waitForIdle()
assertEquals("Info", rowState.openMenu)
onNodeWithText("About...").assertExists()
onNodeWithText("Info").performClick()
waitForIdle()
assertEquals(null, rowState.openMenu)
onNodeWithText("About...").assertDoesNotExist()
}
/**
* The `83e23f55` fault: the bar must be drawn in the palette, and a change of the palette
* must reach it while it is on screen. Measured on the rendered row, to the right of the
* last title, where nothing but the row's own background is drawn.
*/
@Test
fun theRowIsDrawnInThePaletteAndFollowsAChange() = runComposeUiTest {
var stored = ""
val store = PaletteStore(
shippedOf = { JavaFxStylesheet.read("/KST4ContestDefaultDay.css") },
fileRolesOf = { emptyMap() },
storedOverridesOf = { stored },
storeOverrides = { _, encoded -> stored = encoded },
)
setContent {
CompositionLocalProvider(LocalPaletteStore provides store) {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Kst4ContestMenuRow(
state = onlineState(),
actions = RecordingActions(),
settingsWindowOpen = false,
monitorWindowOpen = false,
rowState = MainMenuRowState(),
modifier = Modifier.testTag(rowTag),
)
}
}
}
waitForIdle()
assertEquals(store.shipped(false).base, rightEdgeColour(), "the row is not in the shipped base")
runOnIdle { store.set(darkMode = false, role = PaletteRole.SURFACE, colour = "#FF0000") }
waitForIdle()
assertEquals(Color(0xFFFF0000), rightEdgeColour(), "a palette change did not reach the row")
}
private fun ComposeUiTest.rightEdgeColour(): Color {
val pixels = onNodeWithTag(rowTag).captureToImage().toPixelMap()
// Well inside the row's right end, clear of any edge antialiasing.
return pixels[pixels.width - 12, pixels.height / 2]
}
/** A language picked after the first draw reaches the titles and the items. */
@Test
fun aLanguageChosenWhileTheRowIsShownReachesIt() = runComposeUiTest {
var storedLanguage = "en"
val languages = LanguageStore(
storedLanguage = { storedLanguage },
storeLanguage = { storedLanguage = it },
systemDefault = Locale.UK,
)
setContent {
CompositionLocalProvider(LocalStrings provides languages.strings) {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Kst4ContestMenuRow(
state = onlineState(),
actions = RecordingActions(),
settingsWindowOpen = false,
monitorWindowOpen = false,
rowState = MainMenuRowState(),
)
}
}
}
onNodeWithText("File").assertExists()
try {
runOnIdle { languages.use("de") }
waitForIdle()
onNodeWithText("Datei").performClick()
waitForIdle()
onNodeWithText("Trennen").assertExists()
onNodeWithText("File").assertDoesNotExist()
} finally {
// use() also sets the JVM-wide CurrentStrings; leave the next test the base language.
runOnIdle { languages.use("en") }
}
}
}
@@ -1,7 +1,6 @@
package kst4contest.view.compose
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatCategory
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@@ -124,79 +123,36 @@ class MainWindowFrameTest {
* The title carries the whole connection line while the chat is usable.
*
* The JavaFX window did that -- "Connected to: 2: 144/432 MHz as DN5PW (Testing) in
* JO50JP (73 users online, 73 shown), 29 messages total." The first Compose title took
* the connection manager's diagnostic instead and read "ON4KST session is authenticated
* and synchronized" for the whole contest.
* JO50JP (73 users online, 73 shown), 29 messages total." -- and the Compose window
* opened with a fixed "KST4Contest (Compose)" instead, which told a tiling window
* manager nothing and told the operator less.
*/
@Test
fun `the title carries the live connection line once the chat is usable`() {
val line = MainWindowFrame.onlineChatState(
mainCategory = ChatCategory(ChatCategory.VUHF),
secondCategory = null,
callSign = "DN5PW",
name = "Testing",
locator = "JO50JP",
usersOnline = 73,
shown = 70,
messagesTotal = 29,
)
val detail = "Connected to: 2: 144/432 MHz as DN5PW (Testing) in JO50JP " +
"(73 users online, 73 shown), 29 messages total."
assertEquals(
"Connected to: 2: 144/432 MHz as DN5PW (Testing) in JO50JP " +
"(73 users online, 70 shown), 29 messages total.",
line,
)
assertEquals(line, MainWindowFrame.chatState(On4KstConnectionState.ONLINE) { line })
}
@Test
fun `both categories are named when the second chat is logged in`() {
val line = MainWindowFrame.onlineChatState(
mainCategory = ChatCategory(ChatCategory.VUHF),
secondCategory = ChatCategory(ChatCategory.MICROWAVE),
callSign = "DN5PW",
name = "",
locator = "JO50JP",
usersOnline = 1,
shown = 1,
messagesTotal = 1,
)
assertEquals(
"Connected to: 2: 144/432 MHz and 3: Microwave as DN5PW () in JO50JP " +
"(1 users online, 1 shown), 1 messages total.",
line,
)
}
@Test
fun `without a main category the title does not name an empty one`() {
assertEquals(
MainWindowFrame.offlineChatState(On4KstConnectionState.ONLINE),
MainWindowFrame.onlineChatState(null, null, "DN5PW", "x", "JO50JP", 1, 1, 1),
)
}
@Test
fun `large counts are not grouped by locale`() {
val line = MainWindowFrame.onlineChatState(ChatCategory(ChatCategory.VUHF), null, "DN5PW", "x", "JO50JP", 1234, 1200, 23456)
assertTrue(line.contains("(1234 users online, 1200 shown), 23456 messages total."), line)
assertEquals(detail, MainWindowFrame.chatState(On4KstConnectionState.ONLINE, detail))
}
@Test
fun `an unusable chat says what it is doing instead`() {
assertEquals(
MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING),
MainWindowFrame.chatState(On4KstConnectionState.CONNECTING) { error("not online") },
MainWindowFrame.chatState(On4KstConnectionState.CONNECTING, "ignored while connecting"),
)
}
@Test
fun `no state at all reads as disconnected without building the online line`() {
fun `a blank detail falls through rather than leaving the title empty`() {
// The feed substitutes the state name for a blank detail, but it may not have run yet.
assertEquals(
MainWindowFrame.offlineChatState(On4KstConnectionState.ONLINE),
MainWindowFrame.chatState(On4KstConnectionState.ONLINE, " "),
)
assertEquals(
MainWindowFrame.offlineChatState(null),
MainWindowFrame.chatState(null) { error("not online") },
MainWindowFrame.chatState(null, null),
)
}
}
@@ -6,8 +6,6 @@ import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The shortcut and snippet rosters are ChatPreferences.SimpleRoster, which notifies
@@ -15,11 +13,11 @@ import java.nio.file.Path
* above the send line follow the settings window — the reason ShortcutsTabState has been
* calling refreshShortcutButtons into thin air since Etappe 3b.
*/
class MainWindowStateRosterTest(@TempDir private val tempDir: Path) {
class MainWindowStateRosterTest {
@Test
fun `the shortcuts start out as what the profile holds`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "pse sked"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -33,7 +31,7 @@ class MainWindowStateRosterTest(@TempDir private val tempDir: Path) {
*/
@Test
fun `a roster change reaches the mirror when it is refreshed`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -46,7 +44,7 @@ class MainWindowStateRosterTest(@TempDir private val tempDir: Path) {
@Test
fun `an emptied roster empties the mirror`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -62,7 +60,7 @@ class MainWindowStateRosterTest(@TempDir private val tempDir: Path) {
*/
@Test
fun `drawing the buttons subscribes to the mirror`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -81,9 +79,9 @@ class MainWindowStateRosterTest(@TempDir private val tempDir: Path) {
* A mismatch here is silent and total: SplitterState falls back to an even split, so
* every operator would lose the layout they arranged without any error saying so.
*/
class MainWindowSplitterDefaultsTest(@TempDir private val tempDir: Path) {
class MainWindowSplitterDefaultsTest {
private val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
private val prefs = ChatPreferences()
@Test
fun `the message column has as many dividers as the profile stores positions`() {
@@ -1,158 +0,0 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* The cluster and QSO monitor's body, drawn with live tables (#107).
*
* This was the first window whose tables fill while the operator watches, and the defects
* that the Compose port shipped here were all about the coupling between a live list and
* what gets drawn, not about the table itself (`cc8c9020`, Draw the whole interface in
* Compose; in `main` as `2a5c89b9`):
*
* - The monitor sat empty beside a filling JavaFX tab and only redrew when an unrelated
* click forced it: `DataTableState.visibleRows` returned its cache before reading the row
* state, so a recomposition that hit the cache left the table subscribed to nothing.
* - Rows without identity were keyed in a way that could hand out the same key twice, which
* a `LazyColumn` refuses; two messages that read the same are still two messages.
*
* The tables here are built exactly as `Kst4ContestApplication` builds them: the monitor
* columns, keyed by reference.
*
* Headless: [MonitorContent] is drawn on its own, no window is opened.
*/
@OptIn(ExperimentalTestApi::class)
class MonitorWindowScreenTest {
private fun clusterTable() =
DataTableState(MonitorColumns.dxCluster(), RowKeys.byReference<ClusterMessage>(), "dx-cluster-monitor")
private fun qsoTable() =
DataTableState(MonitorColumns.qsoOfTheOther(), RowKeys.byReference<ChatMessage>(), "qso-other-monitor")
private fun member(call: String) = ChatMember().apply { callSign = call }
private fun spot(from: String, to: String, text: String) = ClusterMessage().apply {
sender = member(from)
receiver = member(to)
messageInhibited = text
}
private fun qso(from: String, to: String, text: String) = ChatMessage().apply {
sender = member(from)
receiver = member(to)
messageText = text
}
private fun ComposeUiTest.show(
cluster: DataTableState<ClusterMessage>,
qsos: DataTableState<ChatMessage>,
) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
MonitorContent(cluster, qsos)
}
}
waitForIdle()
}
@Test
fun bothTablesAreDrawnUnderTheirHeadings() = runComposeUiTest {
show(clusterTable(), qsoTable())
onNodeWithText("DX cluster").assertExists()
onNodeWithText("QSO of the other").assertExists()
onNodeWithText("Call tx").assertExists()
onNodeWithText("Call TX").assertExists()
}
@Test
fun messagesArrivingWhileTheWindowIsOpenReachBothTables() = runComposeUiTest {
val cluster = clusterTable()
val qsos = qsoTable()
show(cluster, qsos)
runOnIdle {
cluster.replaceRows(listOf(spot("DL1AAA", "OK1BBB", "144.300 tnx qso")))
qsos.replaceRows(listOf(qso("DL2CCC", "OK2DDD", "pse sked 432")))
}
waitForIdle()
onNodeWithText("144.300 tnx qso").assertExists()
onNodeWithText("pse sked 432").assertExists()
}
/**
* The empty-monitor fault in its exact shape: the table recomposes once for a reason that
* is not its rows -- here a column moved by the operator -- and the rows that arrive after
* that must still come through.
*/
@Test
fun rowsStillArriveAfterARedrawThatWasNotAboutTheRows() = runComposeUiTest {
val cluster = clusterTable()
show(cluster, qsoTable())
runOnIdle { cluster.replaceRows(listOf(spot("DL1AAA", "OK1BBB", "first spot"))) }
waitForIdle()
onNodeWithText("first spot").assertExists()
// A redraw with the order cache warm.
runOnIdle { cluster.moveColumn("message", 0) }
waitForIdle()
runOnIdle {
cluster.replaceRows(
listOf(spot("DL3EEE", "OK3FFF", "second spot")) + cluster.rows,
)
}
waitForIdle()
onNodeWithText("second spot").assertExists()
onNodeWithText("first spot").assertExists()
}
/** Two spots that read alike are two rows; neither is dropped and the list does not throw. */
@Test
fun twoIdenticalLookingMessagesAreTwoRows() = runComposeUiTest {
val cluster = clusterTable()
show(cluster, qsoTable())
runOnIdle {
cluster.replaceRows(
listOf(
spot("DL1AAA", "OK1BBB", "repeated spot"),
spot("DL1AAA", "OK1BBB", "repeated spot"),
),
)
}
waitForIdle()
assertEquals(2, onAllNodesWithText("repeated spot").fetchSemanticsNodes().size)
}
/** A half-filled message off the wire draws as empty cells, not as a crash. */
@Test
fun aMessageWithoutSenderOrReceiverStillDraws() = runComposeUiTest {
val cluster = clusterTable()
val qsos = qsoTable()
show(cluster, qsos)
runOnIdle {
cluster.replaceRows(listOf(ClusterMessage().apply { messageInhibited = "orphan spot" }))
qsos.replaceRows(listOf(ChatMessage().apply { messageText = "orphan qso" }))
}
waitForIdle()
onNodeWithText("orphan spot").assertExists()
onNodeWithText("orphan qso").assertExists()
}
}
@@ -1,65 +0,0 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.awt.Point
/**
* Which reported positions of an opened window count as the operator's move (issue #59).
*/
class MoveTrackerTest {
private val opened = Point(560, 220)
private fun offset(dx: Int, dy: Int = 0) = Point(opened.x + dx, opened.y + dy)
@Test
fun `a nudge around the placed position is not remembered`() {
val tracker = MoveTracker(opened)
assertNull(tracker.observe(offset(0, 30)))
assertNull(tracker.observe(offset(-MOVE_TOLERANCE_PX)))
}
@Test
fun `a move away from the placed position is remembered`() {
val tracker = MoveTracker(opened)
assertEquals(offset(100), tracker.observe(offset(100)))
}
/** The review case: open at O, drag to O+100, drag back to O+10. */
@Test
fun `a move back to near the opening spot replaces the far one`() {
val tracker = MoveTracker(opened)
assertEquals(offset(100), tracker.observe(offset(100)))
assertEquals(offset(10), tracker.observe(offset(10)))
assertEquals(opened, tracker.observe(opened))
}
@Test
fun `an unplaced window takes the platform's placement jump as its anchor`() {
val tracker = MoveTracker(null)
/* Provisional position, then the window manager's real placement on another screen. */
assertNull(tracker.observe(Point(0, 0)))
assertNull(tracker.observe(Point(2400, 300)))
/* A decoration nudge settles it. */
assertNull(tracker.observe(Point(2400, 330)))
/* Now the operator drags. */
assertEquals(Point(2600, 330), tracker.observe(Point(2600, 330)))
}
@Test
fun `an unplaced window settled by the start of a drag remembers the rest of it`() {
val tracker = MoveTracker(null)
assertNull(tracker.observe(Point(500, 300)))
assertNull(tracker.observe(Point(510, 300)))
assertNull(tracker.observe(Point(540, 300)))
assertEquals(Point(700, 300), tracker.observe(Point(700, 300)))
assertEquals(Point(520, 300), tracker.observe(Point(520, 300)))
}
}
@@ -5,8 +5,6 @@ import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* ChatPreferences hands out MutableValue, which notifies its own listeners and nothing
@@ -17,11 +15,11 @@ import java.nio.file.Path
* The JavaFX window solved it with attachQtfFollower() and attachOwnQrgFollower(); this is
* the same idea, ending in Compose state instead of a TextField.
*/
class ObservedValueTest(@TempDir private val tempDir: Path) {
class ObservedValueTest {
@Test
fun `the mirror starts at the stored value`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -32,7 +30,7 @@ class ObservedValueTest(@TempDir private val tempDir: Path) {
/** The rotator moving must reach the screen without anything asking it to. */
@Test
fun `a change to the underlying value reaches the mirror by itself`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
prefs.getActualQTF().set(90.0)
val observed = ObservedValue(prefs.getActualQTF())
@@ -43,7 +41,7 @@ class ObservedValueTest(@TempDir private val tempDir: Path) {
@Test
fun `a null value is carried through rather than turned into a default`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
val observed = ObservedValue(prefs.getMYQRGSecondCat())
prefs.getMYQRGSecondCat().set(null)
@@ -57,7 +55,7 @@ class ObservedValueTest(@TempDir private val tempDir: Path) {
*/
@Test
fun `drawing from the mirror subscribes to it`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -75,7 +73,7 @@ class ObservedValueTest(@TempDir private val tempDir: Path) {
*/
@Test
fun `releasing the mirror stops it following`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
prefs.getMYQRGFirstCat().set("144.370")
val observed = ObservedValue(prefs.getMYQRGFirstCat())
@@ -87,7 +85,7 @@ class ObservedValueTest(@TempDir private val tempDir: Path) {
@Test
fun `releasing twice is harmless`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
val observed = ObservedValue(prefs.getMYQRGFirstCat())
observed.release()
observed.release()
@@ -1,7 +1,6 @@
package kst4contest.view.compose
import kst4contest.model.OperatorProfile
import kst4contest.testing.Await.awaitUntil
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeFalse
@@ -9,10 +8,9 @@ import org.junit.jupiter.api.Test
import java.awt.EventQueue
import java.awt.GraphicsEnvironment
import java.awt.Window
import java.time.Duration
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import javax.swing.Timer
/**
* The picker must be openable from the user-interface thread.
@@ -31,45 +29,31 @@ class OperatorProfilePickerWindowTest {
OperatorProfile().apply { profileId = "second"; displayName = "Second" },
)
/** The picker's dialog, once it is on screen, or null while it is not. */
private fun showingPickerDialog(): Window? =
Window.getWindows().firstOrNull { it.isShowing && it.javaClass.name.contains("ComposeDialog") }
/**
* Closes the picker the way the operator's X would, as soon as it is showing.
*
* Runs on its own thread because both callers block the test thread: one inside
* `invokeAndWait`, the other inside the modal `showAndSelect` itself. The dispose is
* handed to the event thread, which a modal dialog keeps pumping. Waiting for the dialog
* to appear replaces a fixed 1.5 s timer that was long enough on a fast machine only.
*/
private fun disposePickerOnceShowing(): Future<*> =
closer.submit {
awaitUntil(Duration.ofSeconds(10), "the picker's dialog never appeared") {
showingPickerDialog() != null
}
EventQueue.invokeLater { showingPickerDialog()?.dispose() }
}
private val closer = Executors.newSingleThreadExecutor()
/** Closes whatever window the picker opened, the way the operator's X would. */
private fun disposeOpenWindowsAfter(delayMs: Int): Timer =
Timer(delayMs) {
Window.getWindows()
.filter { it.isShowing && it.javaClass.name.contains("ComposeDialog") }
.forEach { it.dispose() }
}.apply { isRepeats = true }
@Test
fun opensAndReturnsWhenCalledOnTheEventThread() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val closer = disposeOpenWindowsAfter(1_500)
val worker = Executors.newSingleThreadExecutor()
try {
val closing = disposePickerOnceShowing()
closer.start()
// invokeAndWait so the call really happens ON the event thread, as the menu does.
val call = worker.submit { EventQueue.invokeAndWait { OperatorProfilePickerWindow.showAndSelect(profiles(), "root") } }
// The deadlock had no timeout of its own: without a fix this never completes.
call.get(20, TimeUnit.SECONDS)
closing.get(1, TimeUnit.SECONDS)
} finally {
closer.stop()
worker.shutdownNow()
closer.shutdownNow()
}
}
@@ -77,16 +61,17 @@ class OperatorProfilePickerWindowTest {
fun opensAndReturnsWhenCalledOffTheEventThread() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val closer = disposeOpenWindowsAfter(1_500)
try {
val closing = disposePickerOnceShowing()
closer.start()
// The startup path: main() resolves the profile before any window exists.
val chosen = OperatorProfilePickerWindow.showAndSelect(profiles(), "root")
// Dismissed rather than confirmed, so the caller is told to quit.
assertTrue(chosen.isEmpty, "a dismissed picker must report no choice")
closing.get(1, TimeUnit.SECONDS)
} finally {
closer.shutdownNow()
closer.stop()
}
}
@@ -1,45 +0,0 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isPopup
/*
* Waiting for what a click opens on top of the window (#107).
*
* In Compose Multiplatform 1.8.2 a Material3 AlertDialog goes through the skiko Dialog, and a
* DropdownMenu through the skiko Popup; both compose their content into a ComposeSceneLayer of
* the same scene (Dialog_skikoKt calls rememberComposeSceneLayer, checked in the bytecode of
* ui-desktop 1.8.2). The layer's content is set from the composition that opened it, so it can
* take one more frame than the click itself. These helpers wait for the node instead of
* assuming that frame has already happened, and they look only inside the overlay, so a text
* that also stands in the window below can never be picked by accident.
*/
private const val OVERLAY_TIMEOUT_MILLIS = 5_000L
/** Waits until a node matching [matcher] is in the tree, then returns it. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.awaitNode(matcher: SemanticsMatcher): SemanticsNodeInteraction {
waitUntil(timeoutMillis = OVERLAY_TIMEOUT_MILLIS) { onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() }
return onNode(matcher)
}
/** Waits until no node matches [matcher] any more. */
@OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.awaitGone(matcher: SemanticsMatcher) {
waitUntil(timeoutMillis = OVERLAY_TIMEOUT_MILLIS) { onAllNodes(matcher).fetchSemanticsNodes().isEmpty() }
}
/** A text inside an open dialog. */
internal fun inDialog(text: String): SemanticsMatcher =
hasText(text, substring = false) and hasAnyAncestor(isDialog())
/** A text inside an open popup, which is what a dropdown menu is. */
internal fun inPopup(text: String): SemanticsMatcher =
hasText(text, substring = false) and hasAnyAncestor(isPopup())
@@ -7,8 +7,6 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* The factory binds the store to one profile's preferences and one profile's stylesheet.
@@ -17,7 +15,7 @@ import java.nio.file.Path
* another's colours. The stylesheet path is injected here rather than derived inside, so the
* test does not have to write into the operator's home directory to check which file is read.
*/
class PaletteStoreFactoryTest(@TempDir private val tempDir: Path) {
class PaletteStoreFactoryTest {
private val daylight = OperatorProfilePaletteFiles(
dayFile = File("/nonexistent/day.css"),
@@ -26,7 +24,7 @@ class PaletteStoreFactoryTest(@TempDir private val tempDir: Path) {
@Test
fun aChangeLandsInTheDesignsOwnPreference() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
val store = PaletteStoreFactory.create(prefs, daylight)
store.set(darkMode = false, role = PaletteRole.ACCENT, colour = "#FF0000")
@@ -37,7 +35,7 @@ class PaletteStoreFactoryTest(@TempDir private val tempDir: Path) {
@Test
fun anEveningChangeLandsInTheEveningPreference() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
val store = PaletteStoreFactory.create(prefs, daylight)
store.set(darkMode = true, role = PaletteRole.ACCENT, colour = "#FF0000")
@@ -49,7 +47,7 @@ class PaletteStoreFactoryTest(@TempDir private val tempDir: Path) {
@Test
fun whatIsAlreadyStoredIsInForceFromTheStart() {
// The store is built at startup; a colour saved last session has to be there at once.
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
prefs.guiOptions_paletteOverridesDay = "accent=#FF0000"
val store = PaletteStoreFactory.create(prefs, daylight)
@@ -59,7 +57,7 @@ class PaletteStoreFactoryTest(@TempDir private val tempDir: Path) {
@Test
fun aMissingStylesheetLeavesEveryRoleWithTheShippedSheet() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
val store = PaletteStoreFactory.create(prefs, daylight)
@@ -75,7 +73,7 @@ class PaletteStoreFactoryTest(@TempDir private val tempDir: Path) {
file.writeText(".root { -fx-accent: #123456; }")
val store = PaletteStoreFactory.create(
ChatPreferences(tempDir.resolve("preferences.xml")),
ChatPreferences(),
OperatorProfilePaletteFiles(dayFile = file, eveningFile = file),
)
@@ -1,49 +0,0 @@
package kst4contest.view.compose
import java.awt.Point
import java.awt.Rectangle
import java.nio.file.Path
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
/**
* The opt-in gate of the stored window positions: off means nothing is restored and
* nothing is written, not even a request to save.
*/
class PreferencesWindowPositionMemoryTest {
@TempDir
lateinit var directory: Path
private val stored = StoredWindowPosition(Point(2100, 40), Rectangle(1920, 0, 2560, 1440))
@Test
fun `with the opt-in off nothing is written or restored`() {
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
prefs.setWindowPosition("Map", 1, 2, 0, 0, 10, 10)
var saves = 0
val memory = PreferencesWindowPositionMemory(prefs) { saves++ }
memory.save("settings-window", stored)
assertEquals(0, saves)
assertNull(prefs.getWindowPosition("settings-window"))
assertNull(memory.load("Map"))
}
@Test
fun `with the opt-in on a position is stored, saved and restored`() {
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
prefs.setGuiOptions_rememberWindowPositions(true)
var saves = 0
val memory = PreferencesWindowPositionMemory(prefs) { saves++ }
memory.save("Map", stored)
assertEquals(1, saves)
assertEquals(stored, memory.load("Map"))
}
}
@@ -39,42 +39,6 @@ class SelectedStationStateTest {
private fun member(call: String) = ChatMember().apply { callSign = call }
@Test
fun turnAntennaSendsTheSelectedStationsHeading() {
val headings = mutableListOf<Double>()
val state = SelectedStationState(
propagateNotQrv = { },
createSked = { _, _, _, _ -> true },
turnAntennaTo = { headings.add(it) },
)
val station = member("DL1ABC")
station.setQTFdirection(145.7)
state.select(station)
assertTrue(state.canTurnAntenna)
state.turnAntenna()
assertEquals(listOf(145.7), headings)
}
@Test
fun turnAntennaIsUnavailableWithoutARotatorOrAHeading() {
val withoutRotator = SelectedStationState(propagateNotQrv = { }, createSked = { _, _, _, _ -> true })
withoutRotator.select(member("DL1ABC").apply { setQTFdirection(145.7) })
assertFalse(withoutRotator.canTurnAntenna)
val headings = mutableListOf<Double>()
val withoutHeading = SelectedStationState(
propagateNotQrv = { },
createSked = { _, _, _, _ -> true },
turnAntennaTo = { headings.add(it) },
)
withoutHeading.select(member("DL1ABC"))
assertFalse(withoutHeading.canTurnAntenna)
withoutHeading.turnAntenna()
assertTrue(headings.isEmpty())
}
@Test
fun `without a selection there is nothing to act on`() {
val state = Session().state()
@@ -259,57 +223,4 @@ class SelectedStationStateTest {
assertTrue(session.failMarked.isEmpty())
}
@Test
fun `antenna 2 is offered only with SO2R and turns rotator 2`() {
val headings2 = mutableListOf<Double>()
var offered = false
val state = SelectedStationState(
propagateNotQrv = { },
createSked = { _, _, _, _ -> true },
turnAntennaTo = { },
turnSecondAntennaTo = { headings2.add(it) },
secondAntennaOffered = { offered },
)
state.select(member("DL1ABC").apply { setQTFdirection(145.7) })
assertFalse(state.showsSecondAntenna)
offered = true
assertTrue(state.showsSecondAntenna)
assertTrue(state.canTurnSecondAntenna)
state.turnSecondAntenna()
assertEquals(listOf(145.7), headings2)
}
@Test
fun `antenna 2 is greyed out without a heading`() {
val state = SelectedStationState(
propagateNotQrv = { },
createSked = { _, _, _, _ -> true },
turnSecondAntennaTo = { },
secondAntennaOffered = { true },
)
state.select(member("DL1ABC"))
assertTrue(state.showsSecondAntenna)
assertFalse(state.canTurnSecondAntenna)
}
@Test
fun `antenna 2 offer follows the surroundings flag without a new selection`() {
val surroundings = MainWindowSurroundings()
val state = SelectedStationState(
propagateNotQrv = { },
createSked = { _, _, _, _ -> true },
turnSecondAntennaTo = { },
secondAntennaOffered = { surroundings.secondAntennaOffered },
)
state.select(member("DL1ABC"))
assertFalse(state.showsSecondAntenna)
surroundings.secondAntennaOffered = true
assertTrue(state.showsSecondAntenna)
}
}
@@ -1,224 +0,0 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The settings draft: edits wait in it, and only apply() writes them and runs their effects.
*/
class SettingsDraftTest {
/** A stored setting with a log of what happened to it. */
private class Stored(var value: Int, private val log: MutableList<String>, private val name: String) {
fun write(v: Int) {
value = v
log += "write $name=$v"
}
}
@Test
fun `a staged value is shown but not written`() {
val log = mutableListOf<String>()
val port = Stored(9871, log, "port")
val draft = SettingsDraft()
draft.stage("port", 9872, live = { port.value }, write = port::write)
assertEquals(9872, draft.valueOf("port") { port.value })
assertEquals(9871, port.value, "a staged value reached the stored setting")
assertTrue(draft.hasPendingChanges)
assertTrue(log.isEmpty())
}
@Test
fun `an unstaged key reads the stored value`() {
val draft = SettingsDraft()
assertEquals(42, draft.valueOf("anything") { 42 })
assertFalse(draft.hasPendingChanges)
}
@Test
fun `apply writes and runs the effect, then nothing is pending`() {
val log = mutableListOf<String>()
val port = Stored(9871, log, "port")
val draft = SettingsDraft()
draft.stage("port", 9872, live = { port.value }, write = port::write, effect = { log += "restart" })
draft.apply()
assertEquals(9872, port.value)
assertEquals(listOf("write port=9872", "restart"), log)
assertFalse(draft.hasPendingChanges)
}
@Test
fun `discard writes nothing and runs nothing`() {
val log = mutableListOf<String>()
val port = Stored(9871, log, "port")
val draft = SettingsDraft()
draft.stage("port", 9872, live = { port.value }, write = port::write, effect = { log += "restart" })
draft.discard()
assertEquals(9871, port.value)
assertTrue(log.isEmpty())
assertFalse(draft.hasPendingChanges)
assertEquals(9871, draft.valueOf("port") { port.value })
}
@Test
fun `staging the stored value again leaves nothing pending`() {
val log = mutableListOf<String>()
val flag = Stored(0, log, "flag")
val draft = SettingsDraft()
draft.stage("flag", 1, live = { flag.value }, write = flag::write, effect = { log += "effect" })
draft.stage("flag", 0, live = { flag.value }, write = flag::write, effect = { log += "effect" })
assertFalse(draft.hasPendingChanges, "a box ticked and unticked again is no change")
draft.apply()
assertTrue(log.isEmpty(), "apply ran something for a setting that did not change")
}
@Test
fun `the last staged value of a key wins`() {
val log = mutableListOf<String>()
val port = Stored(1, log, "port")
val draft = SettingsDraft()
draft.stage("port", 2, live = { port.value }, write = port::write)
draft.stage("port", 3, live = { port.value }, write = port::write)
draft.apply()
assertEquals(listOf("write port=3"), log, "one write per setting, with the final value")
}
/*
* Every write lands before the first effect, so an effect sees the finished state: a
* listener restarted between "enabled" and "port" would bind the old port.
*/
@Test
fun `all writes come before any effect, in the order first changed`() {
val log = mutableListOf<String>()
val a = Stored(0, log, "a")
val b = Stored(0, log, "b")
val draft = SettingsDraft()
draft.stage("b", 1, live = { b.value }, write = b::write, effect = { log += "effect b" })
draft.stage("a", 1, live = { a.value }, write = a::write, effect = { log += "effect a" })
draft.stage("b", 2, live = { b.value }, write = b::write, effect = { log += "effect b" })
draft.apply()
assertEquals(listOf("write b=2", "write a=1", "effect b", "effect a"), log)
}
@Test
fun `settings sharing an effect key run that effect once`() {
val log = mutableListOf<String>()
val enabled = Stored(0, log, "enabled")
val port = Stored(9871, log, "port")
val draft = SettingsDraft()
draft.stage("enabled", 1, live = { enabled.value }, write = enabled::write,
effectKey = "listener", effect = { log += "restart" })
draft.stage("port", 9872, live = { port.value }, write = port::write,
effectKey = "listener", effect = { log += "restart" })
draft.apply()
assertEquals(listOf("write enabled=1", "write port=9872", "restart"), log)
}
@Test
fun `a failing effect is reported and the other effects still run`() {
val draft = SettingsDraft()
val log = mutableListOf<String>()
var a = 0
var b = 0
draft.stage("a", 1, live = { a }, write = { a = it }, effect = { error("socket refused") })
draft.stage("b", 1, live = { b }, write = { b = it }, effect = { log += "timer" })
val outcome = draft.apply()
assertEquals(1, a, "the write before the failing effect happened")
assertEquals(listOf("timer"), log, "one failing effect stopped the others")
assertEquals(listOf("a: socket refused"), outcome.failedEffects)
assertTrue(outcome.failedWrites.isEmpty())
assertFalse(draft.hasPendingChanges, "a stored setting would be applied a second time")
}
@Test
fun `a failing write stays pending, skips its effect, and the rest is applied`() {
val draft = SettingsDraft()
val log = mutableListOf<String>()
var b = 0
draft.stage("a", 1, live = { 0 }, write = { error("disk full") }, effect = { log += "effect a" })
draft.stage("b", 1, live = { b }, write = { b = it }, effect = { log += "effect b" })
val outcome = draft.apply()
assertEquals(1, b)
assertEquals(listOf("effect b"), log, "an effect ran for a value that was never stored")
assertEquals(listOf("a: disk full"), outcome.failedWrites)
assertFalse(outcome.isClean)
assertTrue(draft.hasPendingChanges, "the failed setting was dropped")
assertEquals(1, draft.valueOf("a") { 0 })
}
private class TwoSettings(draft: SettingsDraft, val log: MutableList<String>) {
var storedA = 1
var storedB = 1
var a: Int by draft.setting({ storedA }) { storedA = it; log += "a=$it" }
var b: Int by draft.setting({ storedB }, effect = { log += "effect b" }) { storedB = it; log += "b=$it" }
}
@Test
fun `a drafted property reads through the draft and stages its writes`() {
val draft = SettingsDraft()
val tab = TwoSettings(draft, mutableListOf())
tab.a = 7
assertEquals(7, tab.a)
assertEquals(1, tab.storedA)
assertEquals(1, tab.b, "staging one property touched another")
draft.apply()
assertEquals(7, tab.storedA)
assertEquals(listOf("a=7"), tab.log)
}
@Test
fun `a drafted property carries its effect`() {
val draft = SettingsDraft()
val tab = TwoSettings(draft, mutableListOf())
tab.b = 3
draft.apply()
assertEquals(listOf("b=3", "effect b"), tab.log)
}
@Test
fun `typed entries are committed on request and report their refusals`() {
val draft = SettingsDraft()
var stored = 0
val entry = draft.registerTypedEntry {
draft.stage("k", 5, live = { stored }, write = { stored = it })
null
}
draft.registerTypedEntry { "Port: \"98x\" is not a valid entry." }
val refusals = draft.commitTypedEntries()
assertEquals(listOf("Port: \"98x\" is not a valid entry."), refusals)
assertTrue(draft.hasPendingChanges, "the accepted entry was not staged")
assertEquals(0, stored, "committing an entry applied it")
draft.discard()
entry.close()
draft.commitTypedEntries()
assertFalse(draft.hasPendingChanges, "a removed field was still committed")
}
}
@@ -1,12 +1,9 @@
package kst4contest.view.compose
import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
import kst4contest.model.ChatMember
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
@@ -123,32 +120,6 @@ class StationColumnsTest {
assertEquals("", columns().first { it.id == "not-qrv" }.value(ChatMember()))
}
/**
* Issue #87: the AP column had no comparator and fell back to its text, so "13 (75%)"
* sorted before "9 (75%)". Same shape as DataTableStateTest's "a column with a
* comparator sorts by it, not by its text", with the real columns.
*/
@Test
fun `airscout sorts numerically`() {
val columns = columns()
assertNotNull(columns.first { it.id == "airscout" }.comparator, "the AP column sorts by its own key")
val table = DataTableState(columns = columns, rowKey = RowKeys.byValue<ChatMember> { it.callSign.orEmpty() })
table.replaceRows(listOf(stationWithAeroplane("DL13ABC", minutes = 13), stationWithAeroplane("DL9ABC", minutes = 9)))
table.toggleSort("airscout")
assertEquals(listOf("DL9ABC", "DL13ABC"), table.visibleRows.map { it.callSign },
"9 before 13 numerically; by text it would be the other way round")
}
private fun stationWithAeroplane(call: String, minutes: Int): ChatMember = ChatMember().apply {
setCallSign(call)
airPlaneReflectInfo = AirPlaneReflectionInfo().apply {
risingAirplanes = mutableListOf(AirPlane().apply { arrivingDurationMinutes = minutes; potential = 75 })
}
}
@Test
fun `tropo and score come from outside the station`() {
val columns = StationColumns.all(
@@ -162,27 +133,4 @@ class StationColumnsTest {
"reachability is computed by the controller, not carried by the row")
assertEquals("250", columns.first { it.id == "score" }.value(member))
}
/**
* The key Kst4ContestApplication binds the station list to (#107). The category is part
* of the chat identity, so the same callsign in two categories is two keys; keyed by the
* callsign alone the two rows collided (21251377). The exact text is pinned because it
* was moved out of an inline Java lambda and must read the same as that lambda did.
*/
@Test
fun `the row key separates the categories of one callsign`() {
fun member(call: String?, category: Int?) = ChatMember().apply {
callSign = call
chatCategory = category?.let { kst4contest.model.ChatCategory(it) }
}
assertEquals("DL1ABC|2", StationColumns.rowKey(member("DL1ABC", 2)))
assertEquals("DL1ABC|3", StationColumns.rowKey(member("DL1ABC", 3)))
assertTrue(
StationColumns.rowKey(member("DL1ABC", 2)) != StationColumns.rowKey(member("DL1ABC", 3)),
"two categories of one callsign share a key",
)
assertEquals("DL1ABC|", StationColumns.rowKey(member("DL1ABC", null)))
assertEquals("null|2", StationColumns.rowKey(member(null, 2)))
}
}
@@ -1,157 +0,0 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
import kst4contest.model.Band
import kst4contest.view.compose.tabs.checkBeside
import kst4contest.view.compose.tabs.fieldBeside
import kst4contest.view.compose.tabs.showTab
import kst4contest.view.compose.tabs.toggle
import kst4contest.view.compose.tabs.type
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.EnumSet
/**
* The QRB part of the station filter bar, drawn (#21).
*
* [StationFilterStateTest] pins which distances pass; this pins that the two QRB controls
* on screen write into their own filter and not into the other one. The two labels differ
* only in `<=` and `>`, so a swapped binding would look right and filter the wrong side.
*
* Headless: only a composition, no window.
*/
@OptIn(ExperimentalTestApi::class)
class StationFilterBarScreenTest {
private val maxLabel = "Show only QRB [km] <="
private val minLabel = "Show only QRB [km] >"
private class Bar {
val filters = StationFilterState()
var changes = 0
}
private fun ComposeUiTest.showBar(): Bar {
val bar = Bar()
showTab {
StationFilterBar(
filters = bar.filters,
activeBands = EnumSet.of(Band.B_144),
onChanged = { bar.changes++ },
)
}
return bar
}
@Test
fun theMinimumQrbControlsWriteTheMinimumFilterOnly() = runComposeUiTest {
val bar = showBar()
type(minLabel, "300")
toggle(minLabel)
assertTrue(bar.filters.minQrbEnabled)
assertEquals(300.0, bar.filters.minQrbKm)
assertEquals(setOf(StationFilter.MIN_QRB), bar.filters.activeFilters)
assertEquals(0.0, bar.filters.maxQrbKm, "the minimum field wrote into the maximum")
assertTrue(bar.changes > 0, "switching the filter on did not reapply the filters")
checkBeside(minLabel).assertIsOn()
checkBeside(maxLabel).assertIsOff()
}
@Test
fun bothQrbControlsTogetherMakeARange() = runComposeUiTest {
val bar = showBar()
type(maxLabel, "500")
toggle(maxLabel)
type(minLabel, "200")
toggle(minLabel)
assertEquals(setOf(StationFilter.MAX_QRB, StationFilter.MIN_QRB), bar.filters.activeFilters)
fieldBeside(maxLabel).assertTextEquals("500")
fieldBeside(minLabel).assertTextEquals("200")
assertFalse(bar.filters.acceptsQrb(150.0))
assertTrue(bar.filters.acceptsQrb(350.0))
assertFalse(bar.filters.acceptsQrb(650.0))
}
/** A pasted "Infinity" as the minimum hid every station, a "NaN" let every one through. */
@Test
fun theQrbFieldsKeepTheirValueWhenTheTextIsNoDistance() = runComposeUiTest {
val bar = showBar()
type(maxLabel, "500")
type(minLabel, "200")
listOf("Infinity", "NaN", "-5").forEach { text ->
type(maxLabel, text)
type(minLabel, text)
}
assertEquals(500.0, bar.filters.maxQrbKm)
assertEquals(200.0, bar.filters.minQrbKm)
fieldBeside(maxLabel).assertTextEquals("500")
fieldBeside(minLabel).assertTextEquals("200")
}
@Test
fun resettingTheFiltersUnticksTheMinimumQrb() = runComposeUiTest {
val bar = showBar()
toggle(minLabel)
checkBeside(minLabel).assertIsOn()
onNodeWithText("Reset filters").performClick()
waitForIdle()
checkBeside(minLabel).assertIsOff()
assertFalse(bar.filters.minQrbEnabled)
}
/**
* The first row used to be a plain row: with both QRB filters in it, a narrow station
* pane cut the bearing buttons off at the right edge. It wraps now, and every control
* has to stay on screen.
*/
@Test
fun aNarrowBarWrapsInsteadOfCuttingControlsOff() = runComposeUiTest {
val filters = StationFilterState()
val width = 300.dp
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Box(Modifier.width(width)) {
StationFilterBar(filters = filters, activeBands = EnumSet.of(Band.B_144), onChanged = {})
}
}
}
waitForIdle()
val limit = with(density) { width.toPx() }
val controls = Bearing.entries.map { onNodeWithText(it.name) } +
listOf(
onNodeWithText(maxLabel),
onNodeWithText(minLabel),
onNodeWithText("Show only QTF:"),
fieldBeside(maxLabel),
fieldBeside(minLabel),
)
controls.forEach { node ->
node.assertIsDisplayed()
val right = node.fetchSemanticsNode().boundsInRoot.right
assertTrue(right <= limit + 0.5f, "a control ends at $right px, beyond the $limit px wide bar")
}
}
}
@@ -131,108 +131,6 @@ class StationFilterStateTest {
assertEquals(setOf(StationFilter.MAX_QRB), filters.activeFilters)
}
@Test
fun `the minimum QRB filter needs its own switch`() {
val filters = StationFilterState()
filters.minQrbKm = 300.0
assertTrue(filters.activeFilters.isEmpty(), "a distance alone filters nothing")
filters.minQrbEnabled = true
assertEquals(setOf(StationFilter.MIN_QRB), filters.activeFilters)
}
@Test
fun `resetting switches both QRB filters off`() {
val filters = StationFilterState()
filters.maxQrbEnabled = true
filters.minQrbEnabled = true
filters.reset()
assertTrue(filters.activeFilters.isEmpty())
assertFalse(filters.minQrbEnabled)
assertFalse(filters.maxQrbEnabled)
}
@Test
fun `without a QRB filter every distance passes, an unknown one too`() {
val filters = StationFilterState()
filters.maxQrbKm = 100.0
filters.minQrbKm = 500.0
assertTrue(filters.acceptsQrb(50.0))
assertTrue(filters.acceptsQrb(1000.0))
assertTrue(filters.acceptsQrb(null))
}
/** The behaviour the controller predicate had before #21 moved it here. */
@Test
fun `the maximum QRB keeps stations up to and including the distance`() {
val filters = StationFilterState()
filters.maxQrbEnabled = true
filters.maxQrbKm = 300.0
assertTrue(filters.acceptsQrb(299.9))
assertTrue(filters.acceptsQrb(300.0))
assertFalse(filters.acceptsQrb(300.1))
assertFalse(filters.acceptsQrb(null), "an unknown distance is not a short one")
}
@Test
fun `the minimum QRB keeps only stations further away than the distance`() {
val filters = StationFilterState()
filters.minQrbEnabled = true
filters.minQrbKm = 300.0
assertFalse(filters.acceptsQrb(299.9))
assertFalse(filters.acceptsQrb(300.0), "the issue asks for QRB > X, so X itself is out")
assertTrue(filters.acceptsQrb(300.1))
assertFalse(filters.acceptsQrb(null), "an unknown distance is not a long one")
}
@Test
fun `both QRB filters together keep a distance range`() {
val filters = StationFilterState()
filters.minQrbEnabled = true
filters.minQrbKm = 200.0
filters.maxQrbEnabled = true
filters.maxQrbKm = 500.0
assertFalse(filters.acceptsQrb(200.0))
assertTrue(filters.acceptsQrb(200.1))
assertTrue(filters.acceptsQrb(500.0))
assertFalse(filters.acceptsQrb(500.1))
assertFalse(filters.acceptsQrb(null))
}
@Test
fun `a range with the minimum above the maximum keeps nothing`() {
val filters = StationFilterState()
filters.minQrbEnabled = true
filters.minQrbKm = 500.0
filters.maxQrbEnabled = true
filters.maxQrbKm = 200.0
listOf(100.0, 300.0, 600.0).forEach {
assertFalse(filters.acceptsQrb(it), "$it km passed an empty range")
}
}
@Test
fun `a QRB field reads plain distances`() {
assertEquals(300.0, parseQrbKm("300"))
assertEquals(12.5, parseQrbKm("12.5"))
assertEquals(0.0, parseQrbKm("0"))
}
@Test
fun `a QRB field refuses what is no distance`() {
listOf("NaN", "Infinity", "-Infinity", "-5", "abc", "").forEach {
assertNull(parseQrbKm(it), "\"$it\" was taken as a distance")
}
}
@Test
fun `a search text filters while it is there and stops when it is cleared`() {
val filters = StationFilterState()
@@ -1,224 +0,0 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
import kst4contest.model.Band
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Test
import java.util.EnumSet
/**
* The station list of the main window, drawn: the operator's main working surface (#107).
*
* The same [DataTableView] over [StationColumns] and [StationColumns.rowKey] that
* `Kst4ContestApplication` builds; only the computed cells (tropo, score, band status) are
* stubbed, because they come from the controller. Three regressions are why this list is
* tested on screen and not only as state:
*
* - `21251377` (Codex review findings on PR 89): the row key was the callsign alone, so the
* same callsign in two chat categories collided -- two rows under one key, which a
* `LazyColumn` refuses outright.
* - `21251377` again: sorting worked on all rows instead of the filtered ones, so choosing a
* sort column silently switched the filters off.
* - `ab282162` (#87): the AP column had no comparator and sorted by its text, so
* "13 (75%)" came before "3 (50%)".
*
* Plus the rule the table was built around in `cc8c9020` (Draw the whole interface in
* Compose): rows replaced after the first draw reach the screen, and the selection is kept by
* key across a refresh that hands in new instances.
*
* Headless: only a composition, no window.
*/
@OptIn(ExperimentalTestApi::class)
class StationListScreenTest {
private fun stationList(): DataTableState<ChatMember> = DataTableState(
StationColumns.all(
tropoOf = { "" },
priorityScoreOf = { 0.0 },
formatBand = { _, _, _ -> "" },
activeBands = EnumSet.noneOf(Band::class.java),
),
RowKeys.byValue(StationColumns::rowKey),
"compose-stations",
)
private fun station(
call: String,
category: Int = 2,
qrb: Double? = null,
planes: List<Pair<Int, Int>>? = null,
): ChatMember = ChatMember().apply {
callSign = call
name = "op $call"
chatCategory = ChatCategory(category)
this.qrb = qrb
if (planes != null) {
airPlaneReflectInfo = AirPlaneReflectionInfo().apply {
risingAirplanes = planes.map { (minutes, potential) ->
AirPlane().apply {
arrivingDurationMinutes = minutes
this.potential = potential
}
}.toMutableList()
}
}
}
private fun ComposeUiTest.show(
state: DataTableState<ChatMember>,
onRowClick: (ChatMember) -> Unit = {},
) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
DataTableView(state = state, onRowClick = onRowClick)
}
}
waitForIdle()
}
/** The callsigns as they stand on screen, top to bottom. */
private fun ComposeUiTest.drawnOrder(vararg calls: String): List<String> =
calls.sortedBy { call ->
onNodeWithText(call).fetchSemanticsNode().positionInRoot.y
}
@Test
fun theSameCallsignInTwoCategoriesIsTwoRowsAndOnlyTheClickedOneIsSelected() = runComposeUiTest {
val state = stationList()
val inMain = station("DL1ABC", category = 2)
val inSecond = station("DL1ABC", category = 3)
val clicked = mutableListOf<ChatMember>()
show(state) { clicked += it }
runOnIdle { state.replaceRows(listOf(inMain, inSecond)) }
waitForIdle()
assertEquals(
2,
onAllNodesWithText("DL1ABC").fetchSemanticsNodes().size,
"the second category's row did not reach the screen",
)
// Unsorted, the rows keep their arrival order: the second category is the lower row.
val rows = onAllNodesWithText("DL1ABC")
val lower = rows.fetchSemanticsNodes().withIndex().maxBy { it.value.positionInRoot.y }.index
rows[lower].performClick()
waitForIdle()
assertSame(inSecond, clicked.single(), "the click reached the other category's row")
assertSame(inSecond, state.selectedRow)
assertEquals(false, state.isSelected(inMain), "both categories read as selected")
}
@Test
fun aSortChosenWhileFilteredKeepsTheFilter() = runComposeUiTest {
val state = stationList()
show(state)
runOnIdle {
state.replaceRows(
listOf(
station("DL3CCC", qrb = 300.0),
station("DL1AAA", qrb = 100.0),
station("DL2BBB", qrb = 200.0),
),
)
state.rowFilter = { it.callSign != "DL2BBB" }
}
waitForIdle()
onNodeWithText("DL2BBB").assertDoesNotExist()
onNodeWithText("QRB").performClick()
waitForIdle()
onNodeWithText("DL2BBB").assertDoesNotExist()
assertEquals(listOf("DL1AAA", "DL3CCC"), drawnOrder("DL3CCC", "DL1AAA"))
}
@Test
fun theApColumnSortsByMinutesAndNotByItsText() = runComposeUiTest {
val state = stationList()
show(state)
runOnIdle {
state.replaceRows(
listOf(
station("DL1THIRTEEN", planes = listOf(13 to 75)),
station("DL1NONE", planes = emptyList()),
station("DL1THREE", planes = listOf(3 to 50)),
),
)
}
waitForIdle()
onNodeWithText("AP [minutes / pot%]").performClick()
waitForIdle()
assertEquals(
listOf("DL1THREE", "DL1THIRTEEN", "DL1NONE"),
drawnOrder("DL1THIRTEEN", "DL1NONE", "DL1THREE"),
"the text order puts \"13 (75%)\" above \"3 (50%)\"",
)
onNodeWithText("AP [minutes / pot%] ▲").assertExists()
}
@Test
fun rowsArrivingAfterTheFirstDrawReachTheScreenAndKeepTheSelection() = runComposeUiTest {
val state = stationList()
show(state)
runOnIdle { state.replaceRows(listOf(station("DL1AAA"), station("DL2BBB"))) }
waitForIdle()
onNodeWithText("DL2BBB").performClick()
waitForIdle()
// A refresh from the roster hands in new instances, with one more station on top.
val refreshed = station("DL2BBB")
runOnIdle { state.replaceRows(listOf(station("DL9NEW"), station("DL1AAA"), refreshed)) }
waitForIdle()
onNodeWithText("DL9NEW").assertExists()
assertSame(refreshed, state.selectedRow, "the selection did not follow the station by key")
// A station that left takes the selection with it rather than handing it on.
runOnIdle { state.replaceRows(listOf(station("DL9NEW"), station("DL1AAA"))) }
waitForIdle()
onNodeWithText("DL2BBB").assertDoesNotExist()
assertEquals(null, state.selectedRow)
}
/**
* A station's fields change in place and the application calls forceRedraw: the list
* filters again, so a station the filter now excludes leaves the screen (#21).
*/
@Test
fun aRedrawAfterAFieldChangedFiltersTheListAgain() = runComposeUiTest {
val state = stationList()
val moving = station("DL1MOVE", qrb = 500.0)
show(state)
runOnIdle {
state.replaceRows(listOf(moving, station("DL2STAY", qrb = 800.0)))
state.rowFilter = { (it.qrb ?: 0.0) > 300.0 }
}
waitForIdle()
onNodeWithText("DL1MOVE").assertExists()
runOnIdle {
moving.qrb = 100.0
state.forceRedraw()
}
waitForIdle()
onNodeWithText("DL1MOVE").assertDoesNotExist()
onNodeWithText("DL2STAY").assertExists()
}
}
@@ -1,252 +0,0 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.awt.Point
import java.awt.Rectangle
/**
* Where a secondary window opens (issue #59).
*
* The screens here are made up, which is the point: no test machine has a second
* monitor left of the primary one, or one that was just unplugged.
*/
class WindowPlacementTest {
/** 1920x1080 primary with a 40 px task bar at the bottom. */
private val primary = ScreenArea(
bounds = Rectangle(0, 0, 1920, 1080),
usable = Rectangle(0, 0, 1920, 1040),
)
/** 2560x1440 to the right of the primary, without panels. */
private val right = ScreenArea(
bounds = Rectangle(1920, 0, 2560, 1440),
usable = Rectangle(1920, 0, 2560, 1440),
)
/** 1280x1024 left of the primary, so with negative coordinates, and a 30 px top bar. */
private val left = ScreenArea(
bounds = Rectangle(-1280, 0, 1280, 1024),
usable = Rectangle(-1280, 30, 1280, 994),
)
@Test
fun `a window opens centred on the screen the main window is on`() {
val main = Rectangle(2200, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary, right), main, null, 800, 600)
assertEquals(Rectangle(1920 + 880, 420, 800, 600), placed)
}
@Test
fun `the main window on the primary screen keeps the window there`() {
val main = Rectangle(100, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary, right), main, null, 800, 600)
assertEquals(Rectangle(560, 220, 800, 600), placed)
}
@Test
fun `a screen left of the primary one works with negative coordinates`() {
val main = Rectangle(-1200, 50, 1000, 700)
val placed = WindowPlacement.place(listOf(primary, left), main, null, 800, 600)
assertEquals(Rectangle(-1280 + 240, 30 + 197, 800, 600), placed)
}
@Test
fun `a main window straddling two screens counts for the one holding its centre`() {
val main = Rectangle(1500, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary, right), main, null, 800, 600)
assertEquals(right.bounds, WindowPlacement.screenOf(listOf(primary, right), main).bounds)
assertEquals(1920 + 880, placed!!.x)
}
@Test
fun `a window larger than the screen is cut down to its usable area`() {
val main = Rectangle(100, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary), main, null, 2500, 1300)
assertEquals(Rectangle(0, 0, 1920, 1040), placed)
}
@Test
fun `only the oversized dimension is cut`() {
val main = Rectangle(-1200, 50, 1000, 700)
val placed = WindowPlacement.place(listOf(primary, left), main, null, 800, 2000)
assertEquals(Rectangle(-1280 + 240, 30, 800, 994), placed)
}
@Test
fun `a window moved during the session reopens where it was moved to`() {
val main = Rectangle(100, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary, right), main, Point(3000, 200), 800, 600)
assertEquals(Rectangle(3000, 200, 800, 600), placed)
}
@Test
fun `a remembered position on an unplugged screen falls back to the main window's screen`() {
val main = Rectangle(100, 100, 1200, 800)
/* Moved to the right-hand screen, which is no longer connected. */
val placed = WindowPlacement.place(listOf(primary), main, Point(3000, 200), 800, 600)
assertEquals(Rectangle(560, 220, 800, 600), placed)
}
@Test
fun `a remembered position with the title bar above the screen falls back`() {
val main = Rectangle(100, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary), main, Point(400, -200), 800, 600)
assertEquals(Rectangle(560, 220, 800, 600), placed)
}
@Test
fun `a remembered position with the title bar under the task bar falls back`() {
val main = Rectangle(100, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary), main, Point(400, 1035), 800, 600)
assertEquals(Rectangle(560, 220, 800, 600), placed)
}
@Test
fun `a title bar that is mostly past the edge but still grabbable is pulled back in`() {
val main = Rectangle(100, 100, 1200, 800)
/* 120 px of the title bar remain on the primary screen: the spot counts, the overhang goes. */
val placed = WindowPlacement.place(listOf(primary), main, Point(1800, 300), 800, 600)
assertEquals(Rectangle(1920 - 800, 300, 800, 600), placed)
}
@Test
fun `a remembered window hanging into the task bar is lifted above it`() {
val main = Rectangle(100, 100, 1200, 800)
val placed = WindowPlacement.place(listOf(primary), main, Point(400, 700), 800, 600)
assertEquals(Rectangle(400, 1040 - 600, 800, 600), placed)
}
@Test
fun `a remembered window that fits stays exactly where it was`() {
val placed = WindowPlacement.place(listOf(primary, left), null, Point(-1100, 200), 800, 600)
assertEquals(Rectangle(-1100, 200, 800, 600), placed)
}
@Test
fun `a title bar with too little left to grab falls back`() {
val main = Rectangle(100, 100, 1200, 800)
/* Only 20 px of the title bar remain on the primary screen. */
val placed = WindowPlacement.place(listOf(primary), main, Point(1900, 300), 800, 600)
assertEquals(Rectangle(560, 220, 800, 600), placed)
}
@Test
fun `a remembered window is cut down to the screen it is on`() {
val placed = WindowPlacement.place(listOf(primary, left), null, Point(-1200, 100), 2000, 600)
assertEquals(Rectangle(-1280, 100, 1280, 600), placed)
}
@Test
fun `without a main window the platform decides`() {
assertNull(WindowPlacement.place(listOf(primary, right), null, null, 800, 600))
}
@Test
fun `without a display the platform decides`() {
assertNull(WindowPlacement.place(emptyList(), Rectangle(0, 0, 100, 100), Point(10, 10), 800, 600))
}
@Test
fun `a main window off every screen falls back to the first screen`() {
val main = Rectangle(9000, 9000, 800, 600)
assertEquals(primary, WindowPlacement.screenOf(listOf(primary, right), main))
}
@Test
fun `empty insets leave the whole screen usable`() {
val broken = ScreenArea(Rectangle(0, 0, 1920, 1080), Rectangle(0, 0, 0, 0))
val placed = WindowPlacement.place(listOf(broken), Rectangle(0, 0, 100, 100), null, 800, 600)
assertEquals(Rectangle(560, 240, 800, 600), placed)
}
@Test
fun `a window manager's nudge is not taken for the operator's move`() {
assertFalse(movedAway(Point(560, 220), Point(560, 252)))
assertFalse(movedAway(Point(560, 220), Point(560 - MOVE_TOLERANCE_PX, 220)))
assertTrue(movedAway(Point(560, 220), Point(560, 220 + MOVE_TOLERANCE_PX + 1)))
assertTrue(movedAway(Point(560, 220), Point(3000, 220)))
}
@Test
fun `a stored position is restored on the very screen it was stored on`() {
val stored = StoredWindowPosition(Point(2500, 300), right.bounds)
assertEquals(Rectangle(2500, 300, 800, 600), WindowPlacement.restore(listOf(primary, right), stored, 800, 600))
}
@Test
fun `a stored position is kept within the usable area of its screen`() {
val stored = StoredWindowPosition(Point(1500, 900), primary.bounds)
assertEquals(Rectangle(1120, 440, 800, 600), WindowPlacement.restore(listOf(primary), stored, 800, 600))
}
@Test
fun `a stored position is not used when its screen is gone or changed`() {
val unplugged = StoredWindowPosition(Point(2500, 300), right.bounds)
val otherResolution = StoredWindowPosition(Point(100, 100), Rectangle(0, 0, 2560, 1440))
assertNull(WindowPlacement.restore(listOf(primary, left), unplugged, 800, 600))
assertNull(WindowPlacement.restore(listOf(primary), otherResolution, 800, 600))
}
/** A 640x480 field monitor right of the primary screen. */
private val tiny = ScreenArea(
bounds = Rectangle(1920, 0, 640, 480),
usable = Rectangle(1920, 0, 640, 480),
)
@Test
fun `a window moved onto a smaller screen is cut down to it`() {
val moved = Rectangle(1950, 20, 1000, 800)
assertEquals(Rectangle(1920, 0, 640, 480), WindowPlacement.fitted(listOf(primary, tiny), moved))
}
@Test
fun `a window that fits its screen is left alone`() {
assertNull(WindowPlacement.fitted(listOf(primary, tiny), Rectangle(100, 100, 800, 600)))
}
@Test
fun `a minimum size is capped to the smallest connected screen`() {
assertEquals(java.awt.Dimension(640, 480), WindowPlacement.minimumWithin(listOf(primary, tiny), 900, 600))
assertEquals(java.awt.Dimension(900, 600), WindowPlacement.minimumWithin(listOf(primary), 900, 600))
}
}
@@ -1,473 +0,0 @@
package kst4contest.view.compose.map
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.text.input.TextFieldValue
import kst4contest.locatorUtils.Location
import kst4contest.model.Band
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ChatPreferences
import kst4contest.model.ClusterMessage
import kst4contest.view.compose.BlinkingNotice
import kst4contest.view.compose.ChatInputActions
import kst4contest.view.compose.ChatInputState
import kst4contest.view.compose.DataTableState
import kst4contest.view.compose.Kst4ContestTheme
import kst4contest.view.compose.MainMenuState
import kst4contest.view.compose.MainWindowState
import kst4contest.view.compose.MainWindowSurroundings
import kst4contest.view.compose.RowKeys
import kst4contest.view.compose.SelectedStationState
import kst4contest.view.compose.SplitterState
import kst4contest.view.compose.StationColumns
import kst4contest.view.compose.StationFilterState
import kst4contest.view.compose.ThreadStatusButtons
import kst4contest.view.compose.TimelineState
import kst4contest.view.compose.TopPriorityState
import kst4contest.view.map.StationMapClusterer
import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.PathAnalysisResult
import kst4contest.view.map.TileFetcher
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.io.IOException
import java.util.EnumSet
/**
* The station map window's body, drawn and clicked (#107).
*
* The map has the longest regression history of the Compose windows, and the two that
* reached an operator are both about what a click on the map does to the rest of the
* application:
*
* - `21251377` (Codex review findings on PR 89): a marker click and "Reset view" set the
* map's own copy of the selection instead of going through the central station selector,
* so the station list, the score service and the send line did not follow the map.
* - `cc8c9020` (Draw the whole interface in Compose, in `main` as `2a5c89b9`): an earlier
* Compose version recomputed the zoom on every selection and threw an operator working at
* zoom 15 out to about 7. AGENTS.md: reset clears the selection without changing the zoom.
*
* Tiles never arrive: an empty temporary cache and a source that always fails, the
* arrangement `3dd2cf10` gave the drawing test after the default fetcher read the user's
* disk cache and the real tile server. No running application exists: the path analysis is
* either absent, as it is before a runtime exists, or a recording stand-in passed through the
* window's [MapPathAnalysis] seam, which is what lets the zoom rule on selection run here.
*
* Headless: [StationMapWindow.Content] is drawn on its own, no window is opened.
*/
@OptIn(ExperimentalTestApi::class)
class StationMapWindowScreenTest {
/*
* Never the no-argument ChatPreferences: it resolves the operator's real profile and
* seeds preferences.xml there when it is missing. A file in a temporary directory is
* never read or written by these tests, it only gives the preferences a home.
*/
@TempDir
lateinit var directory: Path
private fun preferences(): ChatPreferences = ChatPreferences(directory.resolve("preferences.xml"))
private lateinit var offlineTileFetcher: TileFetcher
@BeforeEach
fun openTileFetcher() {
offlineTileFetcher = TileFetcher(
directory.resolve("tile-cache"),
{ throw IOException("no tile source in this test") },
)
}
/** The fetcher owns a thread pool; the window closes it on dispose, and so does this. */
@AfterEach
fun closeTileFetcher() {
offlineTileFetcher.close()
}
/** Records what the window asks the path analysis for, instead of a running application. */
private class RecordingPathAnalysis : MapPathAnalysis {
val requested = mutableListOf<ChatMember>()
override fun request(
member: ChatMember,
snapshot: MapCallsignRawSnapshot?,
onResult: (PathAnalysisResult) -> Unit,
) {
requested += member
}
}
private val ownLocator = "JO40HA"
private fun station(call: String, category: Int, locator: String = "JO50JP") = ChatMember().apply {
callSign = call
qra = locator
chatCategory = ChatCategory(category)
}
private class NoChatActions : ChatInputActions {
override fun panelSelection(): ChatMember? = null
override fun tableSelection(): ChatMember? = null
override fun scoreSelection(): ChatMember? = null
override fun resolveVariables(template: String, member: ChatMember?): String? = template
override fun isAddressedToOwnCallsign(text: String): Boolean = false
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null
override fun mainCategory(): ChatCategory? = null
override fun recordOutboundCq(text: String) {}
override fun requestRecompute(reason: String) {}
override fun queue(message: ChatMessage) {}
override fun ownQrgMain(): String = ""
override fun ownQrgSecond(): String = ""
}
private fun windowState(prefs: ChatPreferences): MainWindowState {
val stations = DataTableState(
StationColumns.all(
tropoOf = { "" },
priorityScoreOf = { 0.0 },
formatBand = { _, _, _ -> "" },
),
RowKeys.byValue(StationColumns::rowKey),
)
val menu = MainMenuState()
return MainWindowState(
activeBands = EnumSet.of(Band.B_144, Band.B_432),
prefs = prefs,
stations = stations,
stationFilter = StationFilterState(),
directedMessages = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
publicMessages = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
clusterMessages = DataTableState(emptyList(), RowKeys.byReference<ClusterMessage>()),
qsoOfTheOther = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
selectedStationMessages = DataTableState(emptyList(), RowKeys.byReference<ChatMessage>()),
selectedStation = SelectedStationState(
propagateNotQrv = {},
createSked = { _, _, _, _ -> false },
),
topPriority = TopPriorityState(topStations = { emptyList() }, onSelect = {}),
timeline = TimelineState(),
chatInput = ChatInputState(NoChatActions()),
menu = menu,
surroundings = MainWindowSurroundings(menu),
skedNotice = BlinkingNotice(),
bandUpgradeNotice = BlinkingNotice(),
threadButtons = ThreadStatusButtons(),
outerSplitter = SplitterState(2, doubleArrayOf(0.5)) {},
messageSplitter = SplitterState(MainWindowState.MESSAGE_PANE_COUNT, doubleArrayOf(0.2, 0.4, 0.6, 0.8)) {},
rightSplitter = SplitterState(MainWindowState.RIGHT_PANE_COUNT, doubleArrayOf(0.4, 0.6)) {},
)
}
private fun prefs(pathAnalysisVisible: Boolean = false) = preferences().apply {
stn_loginLocatorMainCat = ownLocator
isGUIstationMapPathAnalysisVisible = pathAnalysisVisible
isGUIstationMapClusteringEnabled = false
}
private fun ComposeUiTest.show(
state: MainWindowState,
members: List<ChatMember>,
mapState: MapState,
pathAnalysis: MapPathAnalysis? = null,
onPathAnalysisAsked: () -> Unit = {},
) {
state.stations.replaceRows(members)
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
StationMapWindow.Content(
mainWindowState = state,
tileFetcher = offlineTileFetcher,
chatMembers = { members },
mapState = mapState,
pathAnalysis = { onPathAnalysisAsked(); pathAnalysis },
)
}
}
waitForIdle()
}
/** A map looking straight at a locator, so its marker sits in the middle of the canvas. */
private fun mapLookingAt(locator: String, zoom: Float): MapState {
val location = Location(locator)
return MapState(
zoom = zoom,
centerLon = location.longitude.toDegrees(),
centerLat = location.latitude.toDegrees(),
)
}
@Test
fun resetViewClearsTheSelectionThroughTheSelectorAndKeepsTheZoom() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2)
state.selectedStation.select(dl1abc)
val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it }
val mapState = mapLookingAt("JO50JP", zoom = 15f)
show(state, listOf(dl1abc), mapState)
onNodeWithText("Reset view").performClick()
waitForIdle()
assertEquals(listOf<ChatMember?>(null), selectorCalls, "reset did not go through the station selector")
assertSame(
dl1abc,
state.selectedStation.selected,
"reset cleared the panel's selection behind the selector's back",
)
assertEquals(15f, mapState.zoom, "reset changed the zoom")
val home = Location(ownLocator)
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9)
assertEquals(home.latitude.toDegrees(), mapState.centerLat, 1e-9)
}
/** Without a selector -- before the host set one -- reset still clears the selection. */
@Test
fun resetViewWithoutASelectorClearsThePanelSelection() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2)
state.selectedStation.select(dl1abc)
show(state, listOf(dl1abc), mapLookingAt("JO50JP", zoom = 9f))
onNodeWithText("Reset view").performClick()
waitForIdle()
assertEquals(null, state.selectedStation.selected)
}
/**
* A marker stands for a raw callsign. With one of its category variants already selected
* the click keeps that one, goes through the selector, and prepares "/cq CALL" over
* whatever was typed -- the JavaFX handleMapCallsignSelection behaviour.
*/
@Test
fun aMarkerClickSelectsThroughTheSelectorAndKeepsTheSelectedCategory() = runComposeUiTest {
val state = windowState(prefs())
val inMain = station("DL1ABC", 2)
val inSecond = station("DL1ABC", 3)
state.selectedStation.select(inSecond)
state.chatInput.value = TextFieldValue("half typed")
val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it }
val mapState = mapLookingAt("JO50JP", zoom = 12f)
show(state, listOf(inMain, inSecond), mapState)
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick()
waitForIdle()
assertEquals(1, selectorCalls.size, "the marker click did not reach the station selector")
assertSame(inSecond, selectorCalls.single(), "the click swapped the selected category")
assertEquals("/cq DL1ABC ", state.chatInput.value.text)
assertEquals(12f, mapState.zoom, "selecting from the map changed the zoom")
}
@Test
fun aMarkerClickWithNothingSelectedTakesTheFirstVariant() = runComposeUiTest {
val state = windowState(prefs())
val inMain = station("DL1ABC", 2)
val inSecond = station("DL1ABC", 3)
val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it }
show(state, listOf(inMain, inSecond), mapLookingAt("JO50JP", zoom = 12f))
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick()
waitForIdle()
assertSame(inMain, selectorCalls.single())
}
/**
* The cc8c9020 rule: selecting a station pans the map to it and leaves the zoom alone.
* The earlier Compose version recomputed the zoom here and threw zoom 15 out to about 7.
*/
@Test
fun selectingAStationPansToItAndKeepsTheZoom() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val analysis = RecordingPathAnalysis()
val mapState = mapLookingAt(ownLocator, zoom = 15f)
show(state, listOf(dl1abc), mapState, analysis)
runOnIdle { state.selectedStation.select(dl1abc) }
waitForIdle()
val target = Location("JO50JP")
assertEquals(target.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map did not pan")
assertEquals(target.latitude.toDegrees(), mapState.centerLat, 1e-9, "the map did not pan")
assertEquals(15f, mapState.zoom, "selecting a station changed the zoom")
assertEquals(listOf(dl1abc), analysis.requested, "the path analysis was not asked")
}
/**
* With grouping on, a selection zooms in only as far as it takes to break the station out
* of its bubble, and never out: StationMapClusterer.zoomToRevealStations.
*/
@Test
fun withGroupingOnASelectionOnlyZoomsInFarEnoughToRevealTheStation() = runComposeUiTest {
val prefs = prefs().apply { isGUIstationMapClusteringEnabled = true }
val state = windowState(prefs)
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val far = mapLookingAt(ownLocator, zoom = 5f)
show(state, listOf(dl1abc), far, RecordingPathAnalysis())
runOnIdle { state.selectedStation.select(dl1abc) }
waitForIdle()
assertEquals(StationMapClusterer.DISABLE_AT_ZOOM.toFloat(), far.zoom)
}
/** Without a running application there is nothing to ask, and the map stays put. */
@Test
fun withoutAPathAnalysisASelectionLeavesTheMapWhereItIs() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val mapState = mapLookingAt(ownLocator, zoom = 15f)
val home = Location(ownLocator)
var asked = 0
show(state, listOf(dl1abc), mapState, pathAnalysis = null, onPathAnalysisAsked = { asked++ })
runOnIdle { state.selectedStation.select(dl1abc) }
waitForIdle()
// Proves the selection effect ran, so the unchanged map is not a vacuous pass.
assertEquals(1, asked, "the selection effect did not ask for a path analysis")
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9)
assertEquals(15f, mapState.zoom)
}
/** The two header switches write the profile, so the map opens the same way next time. */
@Test
fun theHeaderSwitchesWriteThePreferences() = runComposeUiTest {
val prefs = prefs(pathAnalysisVisible = true)
val state = windowState(prefs)
show(state, emptyList(), MapState())
onNodeWithText("Hide path analysis").performClick()
waitForIdle()
assertEquals(false, prefs.isGUIstationMapPathAnalysisVisible)
onNodeWithText("Path analysis is hidden.").assertExists()
onNodeWithText("Show path analysis").assertExists()
onNodeWithText("Show path analysis").performClick()
waitForIdle()
assertEquals(true, prefs.isGUIstationMapPathAnalysisVisible)
// The grouping checkbox sits beside its label; the label itself is not clickable.
onNodeWithText("Group nearby stations").assertExists()
onNode(isToggleable()).assertIsOff().performClick()
waitForIdle()
onNode(isToggleable()).assertIsOn()
assertEquals(true, prefs.isGUIstationMapClusteringEnabled)
}
/**
* The map shows the stations the list shows (#21). The Compose map drew every station
* whatever the filter bar said, although its status line reported a filtered view; the
* JavaFX map took the filtered list. A filter switched on after the first draw has to
* take the marker away too, and a click where it stood selects nothing.
*/
@Test
fun aStationTheFiltersHideHasNoMarker() = runComposeUiTest {
val state = windowState(prefs())
val near = station("DL1NEAR", 2, locator = "JO50JP").apply { qrb = 150.0 }
val far = station("DL2FAR", 2, locator = "JO62QM").apply { qrb = 600.0 }
val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it }
show(state, listOf(near, far), mapLookingAt("JO50JP", zoom = 12f))
onNodeWithText("Showing 2 visible stations").assertExists()
runOnIdle {
state.stationFilter.minQrbKm = 300.0
state.stationFilter.minQrbEnabled = true
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
}
waitForIdle()
onNodeWithText("Showing 1 visible stations | filtered view active").assertExists()
// The map looks straight at the hidden station: a click there finds no marker.
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick()
waitForIdle()
assertEquals(emptyList<ChatMember?>(), selectorCalls, "the hidden station still had a marker")
}
/** A station whose QRB changed in place leaves the map after the redraw, as it leaves the list. */
@Test
fun aRedrawAfterAFieldChangedFiltersTheMapAgain() = runComposeUiTest {
val state = windowState(prefs())
val moving = station("DL1MOVE", 2, locator = "JO50JP").apply { qrb = 500.0 }
val stay = station("DL2STAY", 2, locator = "JO62QM").apply { qrb = 800.0 }
state.stationFilter.minQrbKm = 300.0
state.stationFilter.minQrbEnabled = true
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
show(state, listOf(moving, stay), mapLookingAt("JO50JP", zoom = 12f))
onNodeWithText("Showing 2 visible stations | filtered view active").assertExists()
runOnIdle {
moving.qrb = 100.0
state.stations.forceRedraw()
}
waitForIdle()
onNodeWithText("Showing 1 visible stations | filtered view active").assertExists()
}
/**
* A selected station the filters hide has no marker, and so nothing to analyse: no
* request goes out and the analysis does not sit at "Loading" with an empty target (#21).
* The visible station beside it is the control: selecting it does ask and does load.
*/
@Test
fun aSelectedStationTheFiltersHideGetsNoPathAnalysis() = runComposeUiTest {
val state = windowState(prefs(pathAnalysisVisible = true))
val hidden = station("DL1HIDE", 2, locator = "JO50JP").apply { qrb = 100.0 }
val shown = station("DL2SHOW", 2, locator = "JO62QM").apply { qrb = 800.0 }
state.stationFilter.minQrbKm = 300.0
state.stationFilter.minQrbEnabled = true
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
val analysis = RecordingPathAnalysis()
val mapState = mapLookingAt(ownLocator, zoom = 9f)
val home = Location(ownLocator)
show(state, listOf(hidden, shown), mapState, analysis)
runOnIdle { state.selectedStation.select(hidden) }
waitForIdle()
assertEquals(emptyList<ChatMember>(), analysis.requested, "a hidden station was analysed")
onAllNodesWithText("Loading", substring = true).assertCountEquals(0)
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map panned to a hidden station")
runOnIdle { state.selectedStation.select(shown) }
waitForIdle()
assertEquals(listOf(shown), analysis.requested)
assertEquals(true, onAllNodesWithText("Loading", substring = true).fetchSemanticsNodes().isNotEmpty())
}
}
@@ -1,24 +1,16 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class AirscoutTabStateTest(@TempDir private val tempDir: Path) {
class AirscoutTabStateTest {
/*
* The rules below are pinned with a draft that applies at once; what the draft itself
* changes is pinned by the two tests that pass a real one.
*/
private inner class Session(val draft: SettingsDraft = ImmediateDraft()) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state by lazy { AirscoutTabState(prefs, draft) }
private class Session {
val prefs = ChatPreferences()
val state by lazy { AirscoutTabState(prefs) }
}
@Test
@@ -40,39 +32,7 @@ class AirscoutTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `an edit waits for apply`() {
val s = Session(SettingsDraft())
s.prefs.setAirScout_asCommunicationPort(9872)
s.state.airScout_asCommunicationPort = 9873
s.state.airScout_asServerNameString = " AS3 "
assertEquals(9872, s.prefs.getAirScout_asCommunicationPort(), "an edit took effect before Apply")
assertEquals(9873, s.state.airScout_asCommunicationPort, "the tab shows the edit")
assertEquals("AS3", s.state.airScout_asServerNameString, "the draft holds the trimmed value")
s.draft.apply()
assertEquals(9873, s.prefs.getAirScout_asCommunicationPort())
assertEquals("AS3", s.prefs.getAirScout_asServerNameString())
}
@Test
fun `a discarded edit changes nothing, and an invalid one never enters the draft`() {
val s = Session(SettingsDraft())
s.prefs.setAirScout_asBandString("1440000")
s.state.airScout_asBandString = "-1"
assertFalse(s.draft.hasPendingChanges, "a refused value reached the draft")
s.state.airScout_asBandString = "4320000"
s.draft.discard()
assertEquals("1440000", s.prefs.getAirScout_asBandString())
}
@Test
fun `writing reaches the preferences on apply`() {
fun `writing reaches the preferences at once, without a confirmation step`() {
val s = Session()
s.state.airScout_asUDPListenerEnabled = true
@@ -2,34 +2,22 @@ package kst4contest.view.compose.tabs
import kst4contest.controller.ChatController
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class BeaconTabStateTest(@TempDir private val tempDir: Path) {
class BeaconTabStateTest {
/*
* The rules are pinned with a draft that applies at once; what the draft changes is
* pinned by the tests that pass a real one.
*/
private inner class Session(
private val templateRefusal: String? = null,
val draft: SettingsDraft = ImmediateDraft(),
) {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
private class Session(private val templateRefusal: String? = null) {
val prefs = ChatPreferences()
var timerRestarts = 0
var validated = mutableListOf<String>()
fun state() = BeaconTabState(
prefs,
draft,
validateBeaconTemplate = { template ->
validated.add(template)
templateRefusal?.let { throw IllegalArgumentException(it) }
@@ -41,46 +29,7 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `beacon edits wait for apply, and the timer restarts only then`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
s.prefs.setBcn_beaconsEnabledMainCat(false)
val state = s.state()
state.bcn_beaconsEnabledMainCat = true
assertNull(state.commitBeaconTextMainCat("CQ MYCALL"))
assertNull(state.commitBeaconInterval("7"))
assertFalse(s.prefs.isBcn_beaconsEnabledMainCat(), "an edit took effect before Apply")
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
assertEquals(0, s.timerRestarts, "the timer restarted before Apply")
assertEquals(7, state.beaconIntervalMinutes, "the tab shows the edit")
assertEquals("CQ MYCALL", state.beaconTextMainCat)
s.draft.apply()
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat())
assertEquals("CQ MYCALL", s.prefs.getBcn_beaconTextMainCat())
assertEquals(7, s.prefs.getBcn_beaconIntervalInMinutesSecondCat())
assertEquals(1, s.timerRestarts)
}
@Test
fun `discarded beacon edits leave the timer and the templates alone`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconTextMainCat("CQ MYCALL")
val state = s.state()
state.commitBeaconTextMainCat("CQ MYCALL MYQRG")
state.commitBeaconInterval("9")
s.draft.discard()
assertEquals("CQ MYCALL", s.prefs.getBcn_beaconTextMainCat())
assertEquals(0, s.timerRestarts)
}
@Test
fun `the enable flags reach the preferences`() {
fun `the enable flags write through at once`() {
val s = Session()
val state = s.state()
@@ -1,227 +0,0 @@
package kst4contest.view.compose.tabs
import kst4contest.model.Band
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.i18n.LanguageStore
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.DynamicTest
import org.junit.jupiter.api.TestFactory
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import java.util.Locale
/**
* Every drafted setting of every tab, with a real [SettingsDraft]: unchanged in the
* preferences after the edit, changed after apply, unchanged after discard.
*
* The tab tests pin each tab's rules with a draft that applies at once, so they cannot tell
* a setting that goes through the draft from one that writes ChatPreferences directly. This
* table can, setter by setter. A setting added to a tab belongs in it; one that is meant to
* stay immediate (login credentials, language, colours, actions) belongs in the spec instead.
*/
class DraftedSettingsTableTest {
@TempDir
lateinit var directory: Path
/** All drafted tabs over one preferences file and one draft. */
private class Fixture(val prefs: ChatPreferences, val monitored: SimpleRoster<String> = SimpleRoster()) {
val draft = SettingsDraft()
val station = StationTabState(prefs, draft, { }, { })
val logSynch = LogSynchTabState(prefs, draft, { }, { })
val trx = TrxSynchTabState(prefs, draft) { }
val airscout = AirscoutTabState(prefs, draft)
val notification = NotificationTabState(prefs, draft, monitored, { }, { null })
val shortcuts = ShortcutsTabState(prefs, draft, { }, { })
val beacon = BeaconTabState(prefs, draft, { }, { }, "Microwave", "144/432 MHz")
val messages = MessageHandlingTabState(prefs, draft) { }
val gui = GuiOptionsTabState(prefs, draft, LanguageStore({ "en" }, { }, Locale.UK))
}
/** One drafted setting: how the tab changes it, and how the stored value is read. */
private class Case(val name: String, val change: (Fixture) -> Unit, val stored: (Fixture) -> Any?)
private fun flag(name: String, read: (Fixture) -> Boolean, write: (Fixture, Boolean) -> Unit) =
Case(name, { f -> write(f, !read(f)) }, read)
private fun cases(): List<Case> = listOf(
// Station, all but the login credentials
Case("station beam width", { it.station.stn_antennaBeamWidthDeg += 1.5 }, { it.prefs.getStn_antennaBeamWidthDeg() }),
flag("station 10G", { it.station.stn_bandActive10G }) { f, v -> f.station.stn_bandActive10G = v },
flag("station 1240", { it.station.stn_bandActive1240 }) { f, v -> f.station.stn_bandActive1240 = v },
flag("station 144", { it.station.stn_bandActive144 }) { f, v -> f.station.stn_bandActive144 = v },
flag("station 2300", { it.station.stn_bandActive2300 }) { f, v -> f.station.stn_bandActive2300 = v },
flag("station 3400", { it.station.stn_bandActive3400 }) { f, v -> f.station.stn_bandActive3400 = v },
flag("station 432", { it.station.stn_bandActive432 }) { f, v -> f.station.stn_bandActive432 = v },
flag("station 50", { it.station.stn_bandActive50 }) { f, v -> f.station.stn_bandActive50 = v },
flag("station 5600", { it.station.stn_bandActive5600 }) { f, v -> f.station.stn_bandActive5600 = v },
flag("station 70", { it.station.stn_bandActive70 }) { f, v -> f.station.stn_bandActive70 = v },
Case("station max QRB", { it.station.stn_maxQRBDefault += 1.5 }, { it.prefs.getStn_maxQRBDefault() }),
Case("station server host", { it.station.stn_on4kstServersDns += "x" }, { it.prefs.getStn_on4kstServersDns() }),
Case("station server port", { it.station.stn_on4kstServersPort += 1 }, { it.prefs.getStn_on4kstServersPort() }),
Case("station target gain", { it.station.stn_pathAnalysisDefaultTargetAntennaGainDbi += 1.5 },
{ it.prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi() }),
Case("station target power", { it.station.stn_pathAnalysisDefaultTargetTxPowerWatts += 1.5 },
{ it.prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts() }),
Case("station DEM directory", { it.station.stn_pathAnalysisDemRootDirectory += "x" },
{ it.prefs.getStn_pathAnalysisDemRootDirectory() }),
Case("station own gain", { it.station.stn_pathAnalysisOwnAntennaGainDbi += 1.5 },
{ it.prefs.getStn_pathAnalysisOwnAntennaGainDbi() }),
Case("station own height", { it.station.stn_pathAnalysisOwnAntennaHeightMeters += 1.5 },
{ it.prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }),
Case("station own power", { it.station.stn_pathAnalysisOwnTxPowerWatts += 1.5 },
{ it.prefs.getStn_pathAnalysisOwnTxPowerWatts() }),
// Through the field's commit, which validates and then stages.
Case("station own ground height ASL", { it.station.commitOwnHeightAsl("312") },
{ it.prefs.getStn_pathAnalysisOwnHeightAslMeters() }),
flag("station target on highest point", { it.station.stn_pathAnalysisDxHighestPointInSquare }) { f, v ->
f.station.stn_pathAnalysisDxHighestPointInSquare = v
},
flag("station rotator", { it.station.stn_pstRotatorEnabled }) { f, v -> f.station.stn_pstRotatorEnabled = v },
Case("station rotator host", { it.station.stn_pstRotatorHost += "x" }, { it.prefs.getStn_pstRotatorHost() }),
Case("station rotator port", { it.station.stn_pstRotatorPort += 1 }, { it.prefs.getStn_pstRotatorPort() }),
Case("station QTF", { it.station.stn_qtfDefault += 1.5 }, { it.prefs.getStn_qtfDefault() }),
flag("station second chat", { it.station.loginToSecondChatEnabled }) { f, v -> f.station.loginToSecondChatEnabled = v },
// Log synch, all but the file chooser
flag("log file polling", { it.logSynch.logsynch_fileBasedWkdCallInterpreterEnabled }) { f, v ->
f.logSynch.logsynch_fileBasedWkdCallInterpreterEnabled = v
},
flag("log UCX listener", { it.logSynch.logsynch_ucxUDPWkdCallListenerEnabled }) { f, v ->
f.logSynch.logsynch_ucxUDPWkdCallListenerEnabled = v
},
Case("log UCX port", { it.logSynch.logsynch_ucxUDPWkdCallListenerPort += 1 },
{ it.prefs.getLogsynch_ucxUDPWkdCallListenerPort() }),
flag("log Win-Test listener", { it.logSynch.logsynch_wintestNetworkListenerEnabled }) { f, v ->
f.logSynch.logsynch_wintestNetworkListenerEnabled = v
},
Case("log Win-Test port", { it.logSynch.logsynch_wintestNetworkPort += 1 }, { it.prefs.getLogsynch_wintestNetworkPort() }),
Case("log Win-Test station name", { it.logSynch.logsynch_wintestNetworkStationNameOfKST += "x" },
{ it.prefs.getLogsynch_wintestNetworkStationNameOfKST() }),
Case("log Win-Test broadcast", { it.logSynch.logsynch_wintestNetworkBroadcastAddress = "10.9.8.255" },
{ it.prefs.getLogsynch_wintestNetworkBroadcastAddress() }),
// TRX synch
flag("trx UCX QRG", { it.trx.trxSynch_ucxLogUDPListenerEnabled }) { f, v -> f.trx.trxSynch_ucxLogUDPListenerEnabled = v },
flag("trx Win-Test QRG", { it.trx.logsynch_wintestQrgSyncEnabled }) { f, v -> f.trx.logsynch_wintestQrgSyncEnabled = v },
flag("trx pass QRG", { it.trx.logsynch_wintestUsePassQrg }) { f, v -> f.trx.logsynch_wintestUsePassQrg = v },
Case("trx station filter", { it.trx.logsynch_wintestNetworkStationNameOfWintestClient1 += "x" },
{ it.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1() }),
// Airscout
flag("airscout listener", { it.airscout.airScout_asUDPListenerEnabled }) { f, v -> f.airscout.airScout_asUDPListenerEnabled = v },
Case("airscout server", { it.airscout.airScout_asServerNameString += "X" }, { it.prefs.getAirScout_asServerNameString() }),
Case("airscout client", { it.airscout.airScout_asClientNameString += "X" }, { it.prefs.getAirScout_asClientNameString() }),
Case("airscout port", { it.airscout.airScout_asCommunicationPort += 1 }, { it.prefs.getAirScout_asCommunicationPort() }),
flag("airscout auto band", { it.airscout.airScout_autoBandSelectionEnabled }) { f, v ->
f.airscout.airScout_autoBandSelectionEnabled = v
},
Case("airscout band", { f -> f.airscout.airScout_asBandString = ((f.airscout.airScout_asBandString.toLong()) + 1).toString() },
{ it.prefs.getAirScout_asBandString() }),
// Notification, all but the test spot
flag("sound simple", { it.notification.notify_playSimpleSounds }) { f, v -> f.notification.notify_playSimpleSounds = v },
flag("sound CW", { it.notification.notify_playCWCallsignsOnRxedPMs }) { f, v -> f.notification.notify_playCWCallsignsOnRxedPMs = v },
flag("sound voice", { it.notification.notify_playVoiceCallsignsOnRxedPMs }) { f, v ->
f.notification.notify_playVoiceCallsignsOnRxedPMs = v
},
flag("band upgrade hint", { it.notification.notify_bandUpgradeHintOnLogEnabled }) { f, v ->
f.notification.notify_bandUpgradeHintOnLogEnabled = v
},
flag("band upgrade boost", { it.notification.notify_bandUpgradePriorityBoostEnabled }) { f, v ->
f.notification.notify_bandUpgradePriorityBoostEnabled = v
},
Case("cluster server", { f -> f.notification.enableDxClusterServer(!f.notification.notify_dxClusterServerEnabled) },
{ it.prefs.isNotify_dxClusterServerEnabled() }),
Case("cluster port", { f -> f.notification.commitDxClusterServerPort((f.notification.notify_dxclusterServerPort + 1).toString()) },
{ it.prefs.getNotify_dxclusterServerPort() }),
Case("cluster spotter", { f ->
f.notification.commitSpotterCallSign(if (f.notification.notify_DXCSrv_SpottersCallSign == "DL1ABC") "DO5AMF" else "DL1ABC")
}, { it.prefs.getNotify_DXCSrv_SpottersCallSign().get() }),
Case("cluster fallback band", { f ->
f.notification.notify_optionalFrequencyPrefix =
if (f.notification.notify_optionalFrequencyPrefix == Band.B_432) Band.B_144 else Band.B_432
}, { it.prefs.getNotify_optionalFrequencyPrefix().get() }),
Case("monitoring list", { it.notification.addMonitoredCallSign("DL1ABC") }, { it.monitored.snapshot() }),
// Shortcuts
Case("shortcut buttons", { f ->
f.shortcuts.shortcuts.addAtTop("cq")
f.shortcuts.commitShortcuts()
}, { it.prefs.getLst_txtShortCutBtnList().snapshot() }),
Case("text snippets", { f ->
f.shortcuts.snippets.addAtTop("73")
f.shortcuts.commitSnippets()
}, { it.prefs.getLst_txtSnipList().snapshot() }),
// Beacon
flag("beacon main", { it.beacon.bcn_beaconsEnabledMainCat }) { f, v -> f.beacon.bcn_beaconsEnabledMainCat = v },
flag("beacon second", { it.beacon.bcn_beaconsEnabledSecondCat }) { f, v -> f.beacon.bcn_beaconsEnabledSecondCat = v },
Case("beacon text main", { it.beacon.commitBeaconTextMainCat(it.beacon.beaconTextMainCat + " x") },
{ it.prefs.getBcn_beaconTextMainCat() }),
Case("beacon text second", { it.beacon.commitBeaconTextSecondCat(it.beacon.beaconTextSecondCat + " x") },
{ it.prefs.getBcn_beaconTextSecondCat() }),
Case("beacon interval", { it.beacon.commitBeaconInterval((it.beacon.beaconIntervalMinutes + 1).toString()) },
{ it.prefs.getBcn_beaconIntervalInMinutesMainCat() }),
// Message handling
flag("auto answer", { it.messages.autoAnswerEnabled }) { f, v -> f.messages.autoAnswerEnabled = v },
Case("auto answer text", { it.messages.autoAnswerText += " x" }, { it.prefs.getMessageHandling_autoAnswerTextMainCat() }),
flag("QRG answer", { it.messages.autoAnswerToQRGRequestEnabled }) { f, v -> f.messages.autoAnswerToQRGRequestEnabled = v },
flag("debug to file", { it.messages.debugModeToFileEnabled }) { f, v -> f.messages.debugModeToFileEnabled = v },
// GUI, all but the language
flag("gui dark by default", { it.gui.gUI_darkModeActiveByDefault }) { f, v -> f.gui.gUI_darkModeActiveByDefault = v },
flag("gui filter nothing", { it.gui.guiOptions_defaultFilterNothing }) { f, v -> f.gui.guiOptions_defaultFilterNothing = v },
flag("gui filter pm to me", { it.gui.guiOptions_defaultFilterPmToMe }) { f, v -> f.gui.guiOptions_defaultFilterPmToMe = v },
flag("gui filter pm to other", { it.gui.guiOptions_defaultFilterPmToOther }) { f, v ->
f.gui.guiOptions_defaultFilterPmToOther = v
},
flag("gui filter public", { it.gui.guiOptions_defaultFilterPublicMsgs }) { f, v -> f.gui.guiOptions_defaultFilterPublicMsgs = v },
flag("gui fresh call hint", { it.gui.guiOptions_showFreshCallHintInBandColumns }) { f, v ->
f.gui.guiOptions_showFreshCallHintInBandColumns = v
},
flag("gui gross field hint", { it.gui.guiOptions_showGrossFieldWorkedHintInBandColumns }) { f, v ->
f.gui.guiOptions_showGrossFieldWorkedHintInBandColumns = v
},
)
@TestFactory
fun everyDraftedSettingWaitsForApply(): List<DynamicTest> = cases().map { case ->
DynamicTest.dynamicTest(case.name) {
val f = Fixture(ChatPreferences(fresh().resolve("preferences.xml")))
val before = case.stored(f)
case.change(f)
assertEquals(before, storedNow(case, f), "${case.name} took effect before Apply")
f.draft.apply()
assertNotEquals(before, storedNow(case, f), "${case.name} did not take effect with Apply")
}
}
@TestFactory
fun everyDraftedSettingIsLeftAloneByDiscard(): List<DynamicTest> = cases().map { case ->
DynamicTest.dynamicTest(case.name) {
val f = Fixture(ChatPreferences(fresh().resolve("preferences.xml")))
val before = case.stored(f)
case.change(f)
f.draft.discard()
assertEquals(before, storedNow(case, f), "${case.name} changed although discarded")
}
}
/*
* The stored value, read past the draft: a flag case reads through the tab, which shows
* the draft, so it reads from tab states over the same preferences with a draft of their own.
*/
private fun storedNow(case: Case, f: Fixture): Any? = case.stored(Fixture(f.prefs, f.monitored))
private fun fresh(): Path = Files.createTempDirectory(directory, "case")
}
@@ -1,8 +1,6 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ImmediateDraft
import kst4contest.view.compose.SettingsDraft
import kst4contest.view.i18n.LanguageStore
import kst4contest.view.i18n.LanguageStoreFactory
import kst4contest.view.i18n.SYSTEM_LANGUAGE
@@ -11,10 +9,8 @@ import java.util.Locale
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
class GuiOptionsTabStateTest {
/*
* Over lambdas rather than over the preferences, so switching the language in a test does
@@ -37,46 +33,31 @@ class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
* that gets saved, and it has to do so through the store, which is what repaints the
* open windows. A store wired to anything else would pass a weaker test.
*/
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val draft = SettingsDraft()
val state = GuiOptionsTabState(prefs, draft, LanguageStoreFactory.create(prefs, Locale.UK))
val prefs = ChatPreferences()
val state = GuiOptionsTabState(prefs, LanguageStoreFactory.create(prefs, Locale.UK))
state.language = "de"
assertEquals("de", state.language)
assertEquals("de", prefs.guiOptions_language, "the language switches at once, without Apply")
assertFalse(draft.hasPendingChanges)
assertEquals("de", prefs.guiOptions_language)
}
@Test
fun `reading comes straight from the preferences`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val prefs = ChatPreferences()
prefs.setGUI_darkModeActiveByDefault(true)
assertTrue(GuiOptionsTabState(prefs, SettingsDraft(), languageStore()).gUI_darkModeActiveByDefault)
assertTrue(GuiOptionsTabState(prefs, languageStore()).gUI_darkModeActiveByDefault)
}
@Test
fun `an edit waits for apply, and discarding drops it`() {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val draft = SettingsDraft()
val state = GuiOptionsTabState(prefs, draft, languageStore())
state.gUI_darkModeActiveByDefault = !prefs.isGUI_darkModeActiveByDefault()
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_showFreshCallHintInBandColumns = true
assertFalse(prefs.isGuiOptions_defaultFilterPublicMsgs(), "an edit took effect before Apply")
assertTrue(state.guiOptions_defaultFilterPublicMsgs, "the tab shows the edit")
val darkBefore = prefs.isGUI_darkModeActiveByDefault()
draft.discard()
assertEquals(darkBefore, prefs.isGUI_darkModeActiveByDefault())
fun `writing reaches the preferences at once`() {
val prefs = ChatPreferences()
val state = GuiOptionsTabState(prefs, languageStore())
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_showFreshCallHintInBandColumns = true
draft.apply()
assertTrue(prefs.isGuiOptions_defaultFilterPublicMsgs())
assertTrue(prefs.isGuiOptions_showFreshCallHintInBandColumns())
@@ -87,8 +68,8 @@ class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
// The JavaFX tab offered them as four separate checkboxes, not a choice of
// one. Turning them into a radio group would take a combination away that
// an operator may have configured.
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
val state = GuiOptionsTabState(prefs, ImmediateDraft(), languageStore())
val prefs = ChatPreferences()
val state = GuiOptionsTabState(prefs, languageStore())
state.guiOptions_defaultFilterPublicMsgs = true
state.guiOptions_defaultFilterPmToMe = true
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff. Show more