mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-25 15:45:58 +01:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c43934c7ba | ||
|
|
d38b385166 | ||
|
|
a28abe1591 | ||
|
|
a0039cd52e | ||
|
|
8dec00870b | ||
|
|
7ee02fb029 | ||
|
|
e0c1c6b6d4 | ||
|
|
797b01c748 | ||
|
|
5b7c774f5f | ||
|
|
feb4b4e2c9 | ||
|
|
bb0e9eb327 | ||
|
|
92c2576a81 | ||
|
|
ee4f1fe02e | ||
|
|
f17ae0905f | ||
|
|
c64e9a9014 | ||
|
|
88d10259d4 | ||
|
|
c9b838e946 | ||
|
|
a9d8c013ef | ||
|
|
81b16ad4c5 | ||
|
|
2898c530d9 | ||
|
|
e1e6fe7297 | ||
|
|
4b10d96091 | ||
|
|
2db71ee864 | ||
|
|
4679c708bc |
No files matched your search
@@ -38,7 +38,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -138,7 +138,7 @@ jobs:
|
|||||||
Add-Content -Path $env:GITHUB_ENV -Value "ASSET_BASENAME=praktiKST-$version-$shortSha"
|
Add-Content -Path $env:GITHUB_ENV -Value "ASSET_BASENAME=praktiKST-$version-$shortSha"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -188,7 +188,7 @@ jobs:
|
|||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -262,7 +262,7 @@ jobs:
|
|||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -308,7 +308,7 @@ jobs:
|
|||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -356,7 +356,7 @@ jobs:
|
|||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -468,7 +468,7 @@ jobs:
|
|||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -481,7 +481,7 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
|
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
|
||||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||||
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
|
flatpak --user install -y flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||||
|
|
||||||
- name: Build app-image
|
- name: Build app-image
|
||||||
run: |
|
run: |
|
||||||
@@ -498,7 +498,7 @@ jobs:
|
|||||||
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
||||||
app-id: de.x08.KST4Contest
|
app-id: de.x08.KST4Contest
|
||||||
runtime: org.freedesktop.Platform
|
runtime: org.freedesktop.Platform
|
||||||
runtime-version: "24.08"
|
runtime-version: "25.08"
|
||||||
sdk: org.freedesktop.Sdk
|
sdk: org.freedesktop.Sdk
|
||||||
command: KST4Contest
|
command: KST4Contest
|
||||||
finish-args:
|
finish-args:
|
||||||
@@ -549,7 +549,7 @@ jobs:
|
|||||||
- name: Create flatpakref (nightly)
|
- name: Create flatpakref (nightly)
|
||||||
run: |
|
run: |
|
||||||
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
||||||
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
|
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
|
||||||
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
||||||
cat > "dist/de.x08.KST4Contest.nightly.flatpakref" << EOF
|
cat > "dist/de.x08.KST4Contest.nightly.flatpakref" << EOF
|
||||||
[Flatpak Ref]
|
[Flatpak Ref]
|
||||||
@@ -616,7 +616,7 @@ jobs:
|
|||||||
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
|
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
|
||||||
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
|
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
|
||||||
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
||||||
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
|
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
|
||||||
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
||||||
cat > kst4contest.flatpakrepo << EOF
|
cat > kst4contest.flatpakrepo << EOF
|
||||||
[Flatpak Repo]
|
[Flatpak Repo]
|
||||||
@@ -659,7 +659,7 @@ jobs:
|
|||||||
echo "ARCH=$ARCH" >> "$GITHUB_ENV"
|
echo "ARCH=$ARCH" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -161,7 +161,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
echo "Tag $REF_NAME matches composePackageVersion=$PROPERTY_VERSION"
|
echo "Tag $REF_NAME matches composePackageVersion=$PROPERTY_VERSION"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -148,7 +148,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -217,7 +217,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -284,7 +284,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -355,7 +355,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -426,7 +426,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -529,7 +529,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -542,7 +542,7 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
|
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
|
||||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||||
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
|
flatpak --user install -y flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||||
|
|
||||||
- name: Build app-image
|
- name: Build app-image
|
||||||
run: |
|
run: |
|
||||||
@@ -559,7 +559,7 @@ jobs:
|
|||||||
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
||||||
app-id: de.x08.KST4Contest
|
app-id: de.x08.KST4Contest
|
||||||
runtime: org.freedesktop.Platform
|
runtime: org.freedesktop.Platform
|
||||||
runtime-version: "24.08"
|
runtime-version: "25.08"
|
||||||
sdk: org.freedesktop.Sdk
|
sdk: org.freedesktop.Sdk
|
||||||
command: KST4Contest
|
command: KST4Contest
|
||||||
finish-args:
|
finish-args:
|
||||||
@@ -620,7 +620,7 @@ jobs:
|
|||||||
- name: Create flatpakref
|
- name: Create flatpakref
|
||||||
run: |
|
run: |
|
||||||
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
||||||
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
|
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
|
||||||
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
||||||
cat > "dist/${FLATPAKREF_NAME}" << EOF
|
cat > "dist/${FLATPAKREF_NAME}" << EOF
|
||||||
[Flatpak Ref]
|
[Flatpak Ref]
|
||||||
@@ -659,7 +659,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -827,7 +827,7 @@ jobs:
|
|||||||
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
|
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
|
||||||
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
|
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
|
||||||
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
||||||
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
|
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
|
||||||
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
||||||
cat > kst4contest.flatpakrepo << EOF
|
cat > kst4contest.flatpakrepo << EOF
|
||||||
[Flatpak Repo]
|
[Flatpak Repo]
|
||||||
@@ -867,7 +867,9 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# GitHub's generated release notes list merged pull requests only. Commits
|
# GitHub's generated release notes list merged pull requests only. Commits
|
||||||
# pushed directly to main are added here as their own section:
|
# 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)
|
||||||
# ## What's Changed
|
# ## What's Changed
|
||||||
# ### Commits on main (first-parent commits, no merges, no bots)
|
# ### Commits on main (first-parent commits, no merges, no bots)
|
||||||
# ### Merged PRs (GitHub's generated PR list)
|
# ### Merged PRs (GitHub's generated PR list)
|
||||||
@@ -892,6 +894,19 @@ jobs:
|
|||||||
RANGE="$TAG"
|
RANGE="$TAG"
|
||||||
[ -n "$PREVIOUS_TAG" ] && RANGE="${PREVIOUS_TAG}..${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
|
# --first-parent keeps the commits made directly on main; commits inside
|
||||||
# pull requests are covered by the PR list. Squash-merged PRs end in
|
# pull requests are covered by the PR list. Squash-merged PRs end in
|
||||||
# "(#123)" and are listed by GitHub as well.
|
# "(#123)" and are listed by GitHub as well.
|
||||||
@@ -913,6 +928,7 @@ jobs:
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
commits = open("direct-commits.md", encoding="utf-8").read().strip()
|
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()
|
generated = open("generated-notes.md", encoding="utf-8").read()
|
||||||
|
|
||||||
# GitHub's body: "## What's Changed" with the PR list, optionally
|
# GitHub's body: "## What's Changed" with the PR list, optionally
|
||||||
@@ -932,6 +948,8 @@ jobs:
|
|||||||
rest.append(line)
|
rest.append(line)
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
|
if tag_message:
|
||||||
|
parts.append(tag_message)
|
||||||
if commits or prs:
|
if commits or prs:
|
||||||
parts.append("## What's Changed")
|
parts.append("## What's Changed")
|
||||||
if commits:
|
if commits:
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
|
|
||||||
import os, urllib.request
|
import os, urllib.request
|
||||||
|
|
||||||
REPO = 'praktimarc/kst4contest'
|
REPO = 'HamRadioOnline/kst4contest'
|
||||||
token = os.environ['GH_TOKEN']
|
token = os.environ['GH_TOKEN']
|
||||||
|
|
||||||
def gh_get(path):
|
def gh_get(path):
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ The application is developed by Marc Fröhlich (DO5AMF) and since 2026 Philipp W
|
|||||||
- [Project website](https://kst4contest.hamradioonline.de/)
|
- [Project website](https://kst4contest.hamradioonline.de/)
|
||||||
- [Download Stable, Beta and Nightly builds](https://kst4contest.hamradioonline.de/download/)
|
- [Download Stable, Beta and Nightly builds](https://kst4contest.hamradioonline.de/download/)
|
||||||
- [Online manual](https://kst4contest.hamradioonline.de/manual/)
|
- [Online manual](https://kst4contest.hamradioonline.de/manual/)
|
||||||
- [GitHub wiki](https://github.com/praktimarc/kst4contest/wiki)
|
- [GitHub wiki](https://github.com/HamRadioOnline/kst4contest/wiki)
|
||||||
- [Issues and bug reports](https://github.com/praktimarc/kst4contest/issues)
|
- [Issues and bug reports](https://github.com/HamRadioOnline/kst4contest/issues)
|
||||||
- [Development roadmap](https://kst4contest.hamradioonline.de/roadmap/)
|
- [Development roadmap](https://kst4contest.hamradioonline.de/roadmap/)
|
||||||
|
|
||||||
## What KST4Contest does
|
## What KST4Contest does
|
||||||
@@ -47,8 +47,8 @@ Use the central download page to select the appropriate build:
|
|||||||
|
|
||||||
The documentation is available in German and English:
|
The documentation is available in German and English:
|
||||||
|
|
||||||
- [German manual](https://github.com/praktimarc/kst4contest/wiki/de-Home)
|
- [German manual](https://github.com/HamRadioOnline/kst4contest/wiki/de-Home)
|
||||||
- [English manual](https://github.com/praktimarc/kst4contest/wiki/en-Home)
|
- [English manual](https://github.com/HamRadioOnline/kst4contest/wiki/en-Home)
|
||||||
- [Online manual](https://kst4contest.hamradioonline.de/manual/)
|
- [Online manual](https://kst4contest.hamradioonline.de/manual/)
|
||||||
|
|
||||||
The Markdown sources used for the wiki and the generated PDF manuals are stored in [`github_docs`](github_docs/).
|
The Markdown sources used for the wiki and the generated PDF manuals are stored in [`github_docs`](github_docs/).
|
||||||
@@ -83,13 +83,13 @@ Windows:
|
|||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
[](https://github.com/praktimarc/kst4contest/actions/workflows/github-wiki.yml)
|
[](https://github.com/HamRadioOnline/kst4contest/actions/workflows/github-wiki.yml)
|
||||||
|
|
||||||
[](https://github.com/praktimarc/kst4contest/actions/workflows/docs-pdf.yml)
|
[](https://github.com/HamRadioOnline/kst4contest/actions/workflows/docs-pdf.yml)
|
||||||
|
|
||||||
### Builds
|
### Builds
|
||||||
|
|
||||||
[](https://github.com/praktimarc/kst4contest/actions/workflows/nightly-artifacts.yml)
|
[](https://github.com/HamRadioOnline/kst4contest/actions/workflows/nightly-artifacts.yml)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -66,11 +66,14 @@ val generateStrings by tasks.registering(JavaExec::class) {
|
|||||||
mainClass.set("kst4contest.i18n.GeneratorMain")
|
mainClass.set("kst4contest.i18n.GeneratorMain")
|
||||||
|
|
||||||
val input = layout.projectDirectory.dir("src/main/i18n")
|
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)
|
inputs.dir(input)
|
||||||
outputs.dir(generatedI18nDirectory)
|
outputs.dir(output)
|
||||||
|
|
||||||
argumentProviders.add(CommandLineArgumentProvider {
|
argumentProviders.add(CommandLineArgumentProvider {
|
||||||
listOf(input.asFile.absolutePath, generatedI18nDirectory.get().asFile.absolutePath)
|
listOf(input.asFile.absolutePath, output.get().asFile.absolutePath)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# German. Held complete by a test, because this is a shipped language.
|
# German. Held complete by a test, because this is a shipped language.
|
||||||
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken sofort. Nach einer Änderung des UDP-Ports neu verbinden.
|
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken nach dem Übernehmen. Nach einer Änderung des UDP-Ports neu verbinden.
|
||||||
airscout.automaticFrequency=AirScout-Frequenz je Station automatisch wählen
|
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.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]:
|
airscout.clientIdentifier=KST4Contest-Clientkennung [KST]:
|
||||||
@@ -26,6 +26,7 @@ gui.language=Sprache
|
|||||||
gui.language.system=Systemsprache
|
gui.language.system=Systemsprache
|
||||||
gui.startupDesign=Design beim Start
|
gui.startupDesign=Design beim Start
|
||||||
gui.startupFilters=Beim Start aktive Nachrichtenfilter
|
gui.startupFilters=Beim Start aktive Nachrichtenfilter
|
||||||
|
gui.windows=Fenster
|
||||||
logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen
|
logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen
|
||||||
logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe)
|
logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe)
|
||||||
logSynch.fileLabel=Zu überwachende Logdatei:
|
logSynch.fileLabel=Zu überwachende Logdatei:
|
||||||
@@ -88,6 +89,13 @@ notification.soundsSection=Töne
|
|||||||
notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen
|
notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen
|
||||||
notification.spotterCallsign=Spotter-Rufzeichen
|
notification.spotterCallsign=Spotter-Rufzeichen
|
||||||
settings.applyAndClose=Übernehmen/Schließen
|
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
|
settings.save=Einstellungen speichern
|
||||||
shortcuts.addButton=Kurztaste hinzufügen
|
shortcuts.addButton=Kurztaste hinzufügen
|
||||||
shortcuts.addSnippet=Neuen Textbaustein hinzufügen
|
shortcuts.addSnippet=Neuen Textbaustein hinzufügen
|
||||||
@@ -99,6 +107,7 @@ station.callsign=Rufzeichen
|
|||||||
station.chatSection=Chat-Kategorie
|
station.chatSection=Chat-Kategorie
|
||||||
station.defaultsSection=Vorgaben
|
station.defaultsSection=Vorgaben
|
||||||
station.demDirectory=DEM-Stammverzeichnis
|
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.locator=Locator
|
||||||
station.mainCategory=Hauptkategorie
|
station.mainCategory=Hauptkategorie
|
||||||
station.maxQrb=Maximaler QRB
|
station.maxQrb=Maximaler QRB
|
||||||
@@ -106,6 +115,7 @@ station.nameMain=Name, Hauptkategorie
|
|||||||
station.nameSecond=Name, zweite Kategorie
|
station.nameSecond=Name, zweite Kategorie
|
||||||
station.ownAntennaGain=Eigener Antennengewinn, dBi
|
station.ownAntennaGain=Eigener Antennengewinn, dBi
|
||||||
station.ownAntennaHeight=Eigene Antennenhöhe, Meter
|
station.ownAntennaHeight=Eigene Antennenhöhe, Meter
|
||||||
|
station.ownHeightAsl=Eigene Standorthöhe über NN, Meter
|
||||||
station.ownSection=Eigene Station
|
station.ownSection=Eigene Station
|
||||||
station.ownTxPower=Eigene Sendeleistung, Watt
|
station.ownTxPower=Eigene Sendeleistung, Watt
|
||||||
station.password=Passwort
|
station.password=Passwort
|
||||||
@@ -115,21 +125,31 @@ station.rotatorEnabled=Rotorabgleich aktiv
|
|||||||
station.rotatorHost=Rotor-Host
|
station.rotatorHost=Rotor-Host
|
||||||
station.rotatorPort=Rotor-Port
|
station.rotatorPort=Rotor-Port
|
||||||
station.rotatorSection=PSTRotator
|
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.secondCategory=Zweite Kategorie
|
||||||
station.secondChatEnabled=2. Chat: in einer zweiten Kategorie anmelden
|
station.secondChatEnabled=2. Chat: in einer zweiten Kategorie anmelden
|
||||||
station.serverHost=Servername
|
station.serverHost=Servername
|
||||||
station.serverPort=Serverport
|
station.serverPort=Serverport
|
||||||
station.serverSection=ON4KST-Server
|
station.serverSection=ON4KST-Server
|
||||||
station.targetAntennaGain=Antennengewinn der Gegenstation, dBi
|
station.targetAntennaGain=Antennengewinn der Gegenstation, dBi
|
||||||
|
station.targetHighestPoint=Gegenstation auf dem höchsten Punkt ihres Locatorfelds
|
||||||
station.targetTxPower=Sendeleistung der Gegenstation, Watt
|
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.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.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.ucxEnabled=MYQRG aus RadioInfo-Nachrichten auf dem gemeinsamen Log-Sync-Port aktualisieren
|
||||||
trxSynch.ucxSection=UCXLog-TRX-Infos empfangen
|
trxSynch.ucxSection=UCXLog-TRX-Infos empfangen
|
||||||
trxSynch.winTestEnabled=Win-Test-STATUS-QRG-Abgleich (aktualisiert die eigene QRG aus der Win-Test-Transceiverfrequenz)
|
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.winTestPassFrequency=Durchgangsfrequenz aus Win-Test-STATUS verwenden (statt der eigenen QRG)
|
||||||
trxSynch.winTestSection=Win-Test-TRX-Abgleich
|
trxSynch.winTestSection=Win-Test-TRX-Abgleich
|
||||||
trxSynch.winTestStationFilter=Win-Test-Stationsnamenfilter (z. B. STN1, leer = alle annehmen)
|
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.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.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.
|
worked.lastResetAffected=Das letzte Zurücksetzen betraf {0} Zeilen.
|
||||||
@@ -228,6 +248,7 @@ column.lastQrgTx=Letzte QRG TX
|
|||||||
filter.find=Suchen...
|
filter.find=Suchen...
|
||||||
filter.reset=Filter zurücksetzen
|
filter.reset=Filter zurücksetzen
|
||||||
filter.showOnlyQrb=Nur QRB [km] <=
|
filter.showOnlyQrb=Nur QRB [km] <=
|
||||||
|
filter.showOnlyQrbAbove=Nur QRB [km] >
|
||||||
filter.showOnlyQtf=Nur QTF:
|
filter.showOnlyQtf=Nur QTF:
|
||||||
filter.hideWorked=Gearbeitete ausblenden:
|
filter.hideWorked=Gearbeitete ausblenden:
|
||||||
filter.inactiveStations=Inaktive Stationen
|
filter.inactiveStations=Inaktive Stationen
|
||||||
@@ -253,6 +274,8 @@ selected.lookUpAndShow=Nachschlagen und zeigen
|
|||||||
selected.lookupQrz=Auf qrz.com nachschlagen
|
selected.lookupQrz=Auf qrz.com nachschlagen
|
||||||
selected.lookupQrzcq=Auf qrzcq.com nachschlagen
|
selected.lookupQrzcq=Auf qrzcq.com nachschlagen
|
||||||
selected.showPathInAs=Strecke in AS zeigen
|
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.)
|
selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.)
|
||||||
candidates.title=Stationen mit höchster Priorität
|
candidates.title=Stationen mit höchster Priorität
|
||||||
candidates.none=Noch keine Kandidaten.
|
candidates.none=Noch keine Kandidaten.
|
||||||
@@ -322,6 +345,8 @@ title.connecting=Verbindung zu ON4KST wird aufgebaut…
|
|||||||
title.authenticating=Verbunden – Anmeldung bei ON4KST läuft…
|
title.authenticating=Verbunden – Anmeldung bei ON4KST läuft…
|
||||||
title.synchronizing=Verbunden – ON4KST-Chatdaten werden abgeglichen…
|
title.synchronizing=Verbunden – ON4KST-Chatdaten werden abgeglichen…
|
||||||
title.disconnected=NICHT VERBUNDEN!
|
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.noConnection=Keine ON4KST-Verbindung
|
||||||
status.linkTooltip=ON4KST-Verbindung: {0}\n{1}
|
status.linkTooltip=ON4KST-Verbindung: {0}\n{1}
|
||||||
status.linkConnected=🟢 LINK: Verbunden
|
status.linkConnected=🟢 LINK: Verbunden
|
||||||
@@ -361,10 +386,12 @@ tooltip.unknownGrid=unbekanntes Feld
|
|||||||
tooltip.grid=Feld {0}
|
tooltip.grid=Feld {0}
|
||||||
messages.public=Öffentliche Nachrichten
|
messages.public=Öffentliche Nachrichten
|
||||||
messages.dxCluster=DXCluster-Nachrichten
|
messages.dxCluster=DXCluster-Nachrichten
|
||||||
messages.qsoOfTheOther=QSO der Gegenstation
|
messages.qsoOfTheOther=Fremd-QSOs
|
||||||
chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie
|
chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie
|
||||||
chat.secondQrgTooltip=Die Frequenz für die zweite Chat-Kategorie von Hand eintragen
|
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.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.moveDown=Auswahl nach unten
|
||||||
list.moveUp=Auswahl nach oben
|
list.moveUp=Auswahl nach oben
|
||||||
list.clearToRemove=Den Text eines Eintrags leeren, um ihn zu entfernen.
|
list.clearToRemove=Den Text eines Eintrags leeren, um ihn zu entfernen.
|
||||||
@@ -378,3 +405,51 @@ contrast.textOnWindowSurface=Text auf der Fensterfläche
|
|||||||
contrast.textInsideField=Text im Feld
|
contrast.textInsideField=Text im Feld
|
||||||
contrast.accentOnWindowSurface=Akzent auf der Fensterfläche
|
contrast.accentOnWindowSurface=Akzent auf der Fensterfläche
|
||||||
contrast.textOnMenuStrip=Text auf der Menüzeile
|
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
|
||||||
|
monitor.dxCluster=DX-Cluster
|
||||||
|
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 --
|
# 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.
|
# none of it is here, and three tests keep it that way.
|
||||||
airscout.applyHint=Server identifier, client identifier and frequency mode are applied immediately. Reconnect after changing the UDP port.
|
airscout.applyHint=Server identifier, client identifier and frequency mode take effect when applied. Reconnect after changing the UDP port.
|
||||||
airscout.automaticFrequency=Select AirScout frequency automatically per station
|
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.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]:
|
airscout.clientIdentifier=KST4Contest client identifier [KST]:
|
||||||
@@ -35,6 +35,7 @@ gui.language=Language
|
|||||||
gui.language.system=System language
|
gui.language.system=System language
|
||||||
gui.startupDesign=Startup design
|
gui.startupDesign=Startup design
|
||||||
gui.startupFilters=Message filters active at startup
|
gui.startupFilters=Message filters active at startup
|
||||||
|
gui.windows=Windows
|
||||||
logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File
|
logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File
|
||||||
logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information)
|
logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information)
|
||||||
logSynch.fileLabel=Log file to be monitored:
|
logSynch.fileLabel=Log file to be monitored:
|
||||||
@@ -97,6 +98,13 @@ notification.soundsSection=Sounds
|
|||||||
notification.speakCallsign=Speak the callsign on a received private message
|
notification.speakCallsign=Speak the callsign on a received private message
|
||||||
notification.spotterCallsign=Spotter callsign
|
notification.spotterCallsign=Spotter callsign
|
||||||
settings.applyAndClose=Apply/Close prefs
|
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
|
settings.save=Save settings
|
||||||
shortcuts.addButton=Add shortcut
|
shortcuts.addButton=Add shortcut
|
||||||
shortcuts.addSnippet=Add new snippet
|
shortcuts.addSnippet=Add new snippet
|
||||||
@@ -108,6 +116,7 @@ station.callsign=Callsign
|
|||||||
station.chatSection=Chat category
|
station.chatSection=Chat category
|
||||||
station.defaultsSection=Defaults
|
station.defaultsSection=Defaults
|
||||||
station.demDirectory=DEM root directory
|
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.locator=Locator
|
||||||
station.mainCategory=Main category
|
station.mainCategory=Main category
|
||||||
station.maxQrb=Maximum QRB
|
station.maxQrb=Maximum QRB
|
||||||
@@ -115,6 +124,7 @@ station.nameMain=Name, main category
|
|||||||
station.nameSecond=Name, second category
|
station.nameSecond=Name, second category
|
||||||
station.ownAntennaGain=Own antenna gain, dBi
|
station.ownAntennaGain=Own antenna gain, dBi
|
||||||
station.ownAntennaHeight=Own antenna height, metres
|
station.ownAntennaHeight=Own antenna height, metres
|
||||||
|
station.ownHeightAsl=Own ground height ASL, metres
|
||||||
station.ownSection=Own station
|
station.ownSection=Own station
|
||||||
station.ownTxPower=Own TX power, watts
|
station.ownTxPower=Own TX power, watts
|
||||||
station.password=Password
|
station.password=Password
|
||||||
@@ -124,21 +134,31 @@ station.rotatorEnabled=Rotator sync enabled
|
|||||||
station.rotatorHost=Rotator host
|
station.rotatorHost=Rotator host
|
||||||
station.rotatorPort=Rotator port
|
station.rotatorPort=Rotator port
|
||||||
station.rotatorSection=PSTRotator
|
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.secondCategory=Second category
|
||||||
station.secondChatEnabled=2nd chat: log in to a second category
|
station.secondChatEnabled=2nd chat: log in to a second category
|
||||||
station.serverHost=Server host name
|
station.serverHost=Server host name
|
||||||
station.serverPort=Server port
|
station.serverPort=Server port
|
||||||
station.serverSection=ON4KST server
|
station.serverSection=ON4KST server
|
||||||
station.targetAntennaGain=Target antenna gain, dBi
|
station.targetAntennaGain=Target antenna gain, dBi
|
||||||
|
station.targetHighestPoint=Target station on the highest point of its locator square
|
||||||
station.targetTxPower=Target TX power, watts
|
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.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.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.ucxEnabled=Update MYQRG from RadioInfo messages received on the shared log-sync port
|
||||||
trxSynch.ucxSection=Receive UCXLog TRX info
|
trxSynch.ucxSection=Receive UCXLog TRX info
|
||||||
trxSynch.winTestEnabled=Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)
|
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.winTestPassFrequency=Use pass frequency from Win-Test STATUS (instead of own QRG)
|
||||||
trxSynch.winTestSection=Win-Test TRX sync
|
trxSynch.winTestSection=Win-Test TRX sync
|
||||||
trxSynch.winTestStationFilter=Win-Test station name filter (e.g. STN1, empty = accept all)
|
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.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.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.
|
worked.lastResetAffected=Last reset affected {0} rows.
|
||||||
@@ -237,6 +257,7 @@ column.lastQrgTx=Last QRG TX
|
|||||||
filter.find=Find...
|
filter.find=Find...
|
||||||
filter.reset=Reset filters
|
filter.reset=Reset filters
|
||||||
filter.showOnlyQrb=Show only QRB [km] <=
|
filter.showOnlyQrb=Show only QRB [km] <=
|
||||||
|
filter.showOnlyQrbAbove=Show only QRB [km] >
|
||||||
filter.showOnlyQtf=Show only QTF:
|
filter.showOnlyQtf=Show only QTF:
|
||||||
filter.hideWorked=Hide worked:
|
filter.hideWorked=Hide worked:
|
||||||
filter.inactiveStations=Inactive stations
|
filter.inactiveStations=Inactive stations
|
||||||
@@ -262,6 +283,8 @@ selected.lookUpAndShow=Look up and show
|
|||||||
selected.lookupQrz=Lookup on qrz.com
|
selected.lookupQrz=Lookup on qrz.com
|
||||||
selected.lookupQrzcq=Lookup on qrzcq.com
|
selected.lookupQrzcq=Lookup on qrzcq.com
|
||||||
selected.showPathInAs=Show path in AS
|
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)
|
selected.lastActivity=Last activity: {0} ({1} min ago)
|
||||||
candidates.title=Top priority candidates
|
candidates.title=Top priority candidates
|
||||||
candidates.none=No candidates yet.
|
candidates.none=No candidates yet.
|
||||||
@@ -331,6 +354,8 @@ title.connecting=Connecting to ON4KST…
|
|||||||
title.authenticating=Connected – authenticating with ON4KST…
|
title.authenticating=Connected – authenticating with ON4KST…
|
||||||
title.synchronizing=Connected – synchronizing ON4KST chat data…
|
title.synchronizing=Connected – synchronizing ON4KST chat data…
|
||||||
title.disconnected=DISCONNECTED!
|
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.noConnection=No ON4KST connection
|
||||||
status.linkTooltip=ON4KST link: {0}\n{1}
|
status.linkTooltip=ON4KST link: {0}\n{1}
|
||||||
status.linkConnected=🟢 LINK: Connected
|
status.linkConnected=🟢 LINK: Connected
|
||||||
@@ -374,6 +399,8 @@ messages.qsoOfTheOther=QSO of the other
|
|||||||
chat.ownQrgTooltip=Your own frequency in the main category
|
chat.ownQrgTooltip=Your own frequency in the main category
|
||||||
chat.secondQrgTooltip=Enter the frequency for the second chat category by hand
|
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.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.moveDown=Move selected down
|
||||||
list.moveUp=Move selected up
|
list.moveUp=Move selected up
|
||||||
list.clearToRemove=Clear the text of an entry to remove it.
|
list.clearToRemove=Clear the text of an entry to remove it.
|
||||||
@@ -387,3 +414,51 @@ contrast.textOnWindowSurface=Text on the window surface
|
|||||||
contrast.textInsideField=Text inside a field
|
contrast.textInsideField=Text inside a field
|
||||||
contrast.accentOnWindowSurface=Accent on the window surface
|
contrast.accentOnWindowSurface=Accent on the window surface
|
||||||
contrast.textOnMenuStrip=Text on the menu strip
|
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
|
||||||
|
monitor.dxCluster=DX cluster
|
||||||
|
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
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
package kst4contest.view;
|
package kst4contest.view;
|
||||||
|
import kst4contest.view.compose.SettingsDraft;
|
||||||
import kst4contest.view.compose.SettingsHost;
|
import kst4contest.view.compose.SettingsHost;
|
||||||
import kst4contest.view.compose.SettingsNotices;
|
import kst4contest.view.compose.SettingsNotices;
|
||||||
import kst4contest.view.compose.SettingsTabsKt;
|
import kst4contest.view.compose.SettingsTabsKt;
|
||||||
@@ -1985,11 +1986,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
*/
|
*/
|
||||||
private void requestOperatorProfileSwitch(OperatorProfile targetProfile) {
|
private void requestOperatorProfileSwitch(OperatorProfile targetProfile) {
|
||||||
|
|
||||||
if (targetProfile == null || !confirmOperatorProfileSwitch(targetProfile)) {
|
if (targetProfile == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplicationRuntimeLauncher.switchProfile(targetProfile);
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2110,6 +2120,16 @@ 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() {
|
private void requestLayoutSave() {
|
||||||
if (layoutAutosave != null) {
|
if (layoutAutosave != null) {
|
||||||
layoutAutosave.requestSave();
|
layoutAutosave.requestSave();
|
||||||
@@ -2153,6 +2173,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
.isMessageHandling_debugModeToFileEnabled());
|
.isMessageHandling_debugModeToFileEnabled());
|
||||||
layoutAutosave = new LayoutAutosave(
|
layoutAutosave = new LayoutAutosave(
|
||||||
chatcontroller.getChatPreferences()::writeLayoutPreferencesToXmlFile, uiDispatcher);
|
chatcontroller.getChatPreferences()::writeLayoutPreferencesToXmlFile, uiDispatcher);
|
||||||
|
installWindowPositionMemory(chatcontroller.getChatPreferences());
|
||||||
// Every profile starts with its own configured design; the menu quick switch only lasts for the session.
|
// Every profile starts with its own configured design; the menu quick switch only lasts for the session.
|
||||||
chatcontroller.getChatPreferences().setGUI_darkModeActive(
|
chatcontroller.getChatPreferences().setGUI_darkModeActive(
|
||||||
chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault());
|
chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault());
|
||||||
@@ -2421,6 +2442,15 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
private void closeWindowEvent() {
|
private void closeWindowEvent() {
|
||||||
System.out.println("Window close request ...");
|
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,
|
* 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
|
* and JavaFX ordered YES ahead of CANCEL on Linux and Windows but behind it on
|
||||||
@@ -2609,10 +2639,7 @@ 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.NEW_BANDS) && !isNewBandOpportunity(member)) return false;
|
||||||
if (state.isOn(kst4contest.view.compose.StationFilter.AIRSCOUT_NEXT_5_MIN) && !hasAsWindowInNextMinutes(member, 5)) return false;
|
if (state.isOn(kst4contest.view.compose.StationFilter.AIRSCOUT_NEXT_5_MIN) && !hasAsWindowInNextMinutes(member, 5)) return false;
|
||||||
|
|
||||||
if (state.isOn(kst4contest.view.compose.StationFilter.MAX_QRB)) {
|
if (!state.acceptsQrb(member.getQrb())) return false;
|
||||||
Double qrb = member.getQrb();
|
|
||||||
if (qrb == null || qrb > state.getMaxQrbKm()) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.isOn(kst4contest.view.compose.StationFilter.QTF)) {
|
if (state.isOn(kst4contest.view.compose.StationFilter.QTF)) {
|
||||||
Double qtf = member.getQTFdirection();
|
Double qtf = member.getQTFdirection();
|
||||||
@@ -3034,8 +3061,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
member -> parseTableDouble(formatPriorityScore(member)),
|
member -> parseTableDouble(formatPriorityScore(member)),
|
||||||
(member, band, worked) -> formatBandCellStatus(member, band, worked),
|
(member, band, worked) -> formatBandCellStatus(member, band, worked),
|
||||||
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())),
|
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())),
|
||||||
RowKeys.INSTANCE.byValue(member -> member.getCallSign() + "|"
|
RowKeys.INSTANCE.byValue(StationColumns.INSTANCE::rowKey),
|
||||||
+ (member.getChatCategory() == null ? "" : member.getChatCategory().getCategoryNumber())),
|
|
||||||
"compose-stations",
|
"compose-stations",
|
||||||
widths);
|
widths);
|
||||||
|
|
||||||
@@ -3073,6 +3099,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
|
|
||||||
MainMenuState menu = new MainMenuState();
|
MainMenuState menu = new MainMenuState();
|
||||||
MainWindowSurroundings surroundings = new MainWindowSurroundings(menu);
|
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(
|
SelectedStationState selectedStation = new SelectedStationState(
|
||||||
member -> {
|
member -> {
|
||||||
@@ -3136,7 +3165,16 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
if (member == null) return null;
|
if (member == null) return null;
|
||||||
java.util.EnumSet<Band> enabledBands = kst4contest.logic.BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
|
java.util.EnumSet<Band> enabledBands = kst4contest.logic.BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
|
||||||
return resolveDefaultSkedBand(member, enabledBands);
|
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()) {
|
if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) {
|
||||||
selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_ME);
|
selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_ME);
|
||||||
@@ -3668,9 +3706,25 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
clusterTable.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
|
clusterTable.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
|
||||||
qsoTable.replaceRows(chatcontroller.toOtherMessages());
|
qsoTable.replaceRows(chatcontroller.toOtherMessages());
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The same preference the JavaFX SplitPane wrote, so a profile gets back the
|
||||||
|
* position its operator dragged before the Compose port (#144).
|
||||||
|
*/
|
||||||
|
SplitterState monitorSplitter = new SplitterState(
|
||||||
|
2,
|
||||||
|
chatcontroller.getChatPreferences().getGUIpnl_directedMSGWin_dividerpositionDefault(),
|
||||||
|
SplitterState.DEFAULT_MIN_PANE_PX,
|
||||||
|
positions -> {
|
||||||
|
chatcontroller.getChatPreferences()
|
||||||
|
.setGUIpnl_directedMSGWin_dividerpositionDefault(positions);
|
||||||
|
requestLayoutSave();
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
});
|
||||||
|
|
||||||
MonitorWindow.show(
|
MonitorWindow.show(
|
||||||
clusterTable,
|
clusterTable,
|
||||||
qsoTable,
|
qsoTable,
|
||||||
|
monitorSplitter,
|
||||||
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
|
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
|
||||||
SETTINGS_WINDOW_FONT_SIZE_SP,
|
SETTINGS_WINDOW_FONT_SIZE_SP,
|
||||||
paletteStore,
|
paletteStore,
|
||||||
@@ -3727,6 +3781,19 @@ 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.
|
* Opens the settings window, or does nothing when it is already open.
|
||||||
*
|
*
|
||||||
@@ -3735,9 +3802,17 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
*/
|
*/
|
||||||
private void openSettingsWindow() {
|
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(
|
SettingsWindow.show(
|
||||||
SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore),
|
SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore,
|
||||||
|
this::settingsConnectionState, draft),
|
||||||
settingsNotices,
|
settingsNotices,
|
||||||
|
draft,
|
||||||
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
|
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
|
||||||
SETTINGS_WINDOW_FONT_SIZE_SP,
|
SETTINGS_WINDOW_FONT_SIZE_SP,
|
||||||
paletteStore,
|
paletteStore,
|
||||||
@@ -3750,7 +3825,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
requestLayoutSave();
|
requestLayoutSave();
|
||||||
return kotlin.Unit.INSTANCE;
|
return kotlin.Unit.INSTANCE;
|
||||||
},
|
},
|
||||||
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices));
|
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices, draft));
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -3822,10 +3897,13 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
chatcontroller.getMessageTXBus().add(sendMe);
|
chatcontroller.getMessageTXBus().add(sendMe);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options/Windows -> open or close the settings window. */
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
void menuActionToggleSettingsWindow() {
|
void menuActionToggleSettingsWindow() {
|
||||||
if (SettingsWindow.isOpen()) {
|
if (SettingsWindow.isOpen()) {
|
||||||
SettingsWindow.close();
|
SettingsWindow.requestClose();
|
||||||
} else {
|
} else {
|
||||||
openSettingsWindow();
|
openSettingsWindow();
|
||||||
}
|
}
|
||||||
@@ -3939,6 +4017,20 @@ 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.
|
* No-op since the JavaFX own-QRG text field was removed with the never-shown window.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ fun ChatInputRow(
|
|||||||
ownQrgMain: ObservedValue<String>,
|
ownQrgMain: ObservedValue<String>,
|
||||||
ownQrgSecond: ObservedValue<String>,
|
ownQrgSecond: ObservedValue<String>,
|
||||||
antennaQtf: ObservedValue<Double>,
|
antennaQtf: ObservedValue<Double>,
|
||||||
|
antennaQtf2: ObservedValue<Double>,
|
||||||
|
so2rEnabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
@@ -181,7 +183,7 @@ fun ChatInputRow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Form.button(text = "TX", onClick = { state.send() })
|
Form.button(text = "TX", onClick = { state.send() })
|
||||||
Form.button(text = "clear", onClick = { state.clear() })
|
Form.button(text = kst4contest.view.i18n.LocalStrings.current.chatClear, onClick = { state.clear() })
|
||||||
|
|
||||||
VerticalRule()
|
VerticalRule()
|
||||||
|
|
||||||
@@ -195,13 +197,26 @@ fun ChatInputRow(
|
|||||||
InlineCommittedField(
|
InlineCommittedField(
|
||||||
stored = ownQrgSecond.value.orEmpty(),
|
stored = ownQrgSecond.value.orEmpty(),
|
||||||
onCommit = { prefs.getMYQRGSecondCat().set(it) },
|
onCommit = { prefs.getMYQRGSecondCat().set(it) },
|
||||||
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip,
|
tooltip = if (so2rEnabled) {
|
||||||
|
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltipSo2r
|
||||||
|
} else {
|
||||||
|
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip
|
||||||
|
},
|
||||||
)
|
)
|
||||||
InlineCommittedField(
|
InlineCommittedField(
|
||||||
stored = antennaQtf.value?.toString().orEmpty(),
|
stored = antennaQtf.value?.toString().orEmpty(),
|
||||||
onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
|
onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
|
||||||
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +233,7 @@ private fun InlineCommittedField(
|
|||||||
stored: String,
|
stored: String,
|
||||||
onCommit: (String) -> Unit,
|
onCommit: (String) -> Unit,
|
||||||
tooltip: String,
|
tooltip: String,
|
||||||
|
editable: Boolean = true,
|
||||||
) {
|
) {
|
||||||
var draft by remember(stored) { mutableStateOf(stored) }
|
var draft by remember(stored) { mutableStateOf(stored) }
|
||||||
|
|
||||||
@@ -225,6 +241,7 @@ private fun InlineCommittedField(
|
|||||||
CompactTextField(
|
CompactTextField(
|
||||||
value = draft,
|
value = draft,
|
||||||
onValueChange = { draft = it },
|
onValueChange = { draft = it },
|
||||||
|
enabled = editable,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(QRG_FIELD_WIDTH)
|
.width(QRG_FIELD_WIDTH)
|
||||||
.onFocusChanged { focus ->
|
.onFocusChanged { focus ->
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ object ComposeAlert {
|
|||||||
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS)
|
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS)
|
||||||
dialog.title = title
|
dialog.title = title
|
||||||
dialog.size = DIALOG_SIZE
|
dialog.size = DIALOG_SIZE
|
||||||
dialog.setLocationRelativeTo(null)
|
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
|
||||||
/* Disposed and not hidden, for the reason spelled out in open(). */
|
/* Disposed and not hidden, for the reason spelled out in open(). */
|
||||||
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
||||||
dialog.isAlwaysOnTop = true
|
dialog.isAlwaysOnTop = true
|
||||||
@@ -274,7 +274,7 @@ object ComposeAlert {
|
|||||||
AlertContent(
|
AlertContent(
|
||||||
header = header,
|
header = header,
|
||||||
body = body,
|
body = body,
|
||||||
buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX),
|
buttons = alertButtons(CurrentStrings.get().alertOk, null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX),
|
||||||
onPressed = { dialog.dispose() },
|
onPressed = { dialog.dispose() },
|
||||||
link = linkText to linkTarget,
|
link = linkText to linkTarget,
|
||||||
)
|
)
|
||||||
@@ -322,7 +322,7 @@ object ComposeAlert {
|
|||||||
)
|
)
|
||||||
dialog.title = title
|
dialog.title = title
|
||||||
dialog.size = DIALOG_SIZE
|
dialog.size = DIALOG_SIZE
|
||||||
dialog.setLocationRelativeTo(null)
|
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
|
||||||
/*
|
/*
|
||||||
* Disposed, not hidden. The JDialog default is HIDE_ON_CLOSE, which would leave the
|
* 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,
|
* window and its Skia layer alive behind the operator's back — one per dismissal,
|
||||||
|
|||||||
@@ -11,16 +11,25 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
|
|||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.res.loadImageBitmap
|
import androidx.compose.ui.res.loadImageBitmap
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.FrameWindowScope
|
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.Window
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import androidx.compose.ui.window.rememberWindowState
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
import androidx.compose.ui.awt.ComposeWindow
|
import androidx.compose.ui.awt.ComposeWindow
|
||||||
import java.awt.EventQueue
|
import java.awt.EventQueue
|
||||||
import java.awt.Frame
|
import java.awt.Frame
|
||||||
|
import java.awt.Point
|
||||||
import java.awt.Toolkit
|
import java.awt.Toolkit
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
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.
|
* A Compose window that lives beside the JavaFX application.
|
||||||
@@ -45,10 +54,20 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||||||
* - **It must follow a design switch.** The operator can change the design from the
|
* - **It must follow a design switch.** The operator can change the design from the
|
||||||
* main window's menu while this window is open.
|
* 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
|
* @param threadName names the thread in a stack dump, which is where one looks when a
|
||||||
* window will not close
|
* 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.
|
||||||
*/
|
*/
|
||||||
class ComposeWindowHost(private val threadName: String) {
|
class ComposeWindowHost(
|
||||||
|
private val threadName: String,
|
||||||
|
private val isMainWindow: Boolean = false,
|
||||||
|
) {
|
||||||
|
|
||||||
private val open = AtomicBoolean(false)
|
private val open = AtomicBoolean(false)
|
||||||
private val darkModeState = mutableStateOf(false)
|
private val darkModeState = mutableStateOf(false)
|
||||||
@@ -61,6 +80,16 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var frame: ComposeWindow? = null
|
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. */
|
/** Whether the window is currently open, so a caller can tell without opening one. */
|
||||||
val isOpen: Boolean
|
val isOpen: Boolean
|
||||||
get() = open.get()
|
get() = open.get()
|
||||||
@@ -127,7 +156,7 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
* [deiconifiedState] for what to write — so the only thing left here that no headless
|
* [deiconifiedState] for what to write — so the only thing left here that no headless
|
||||||
* test can observe is the single call to `toFront`.
|
* test can observe is the single call to `toFront`.
|
||||||
*/
|
*/
|
||||||
private fun raise() {
|
fun raise() {
|
||||||
val frame = this.frame ?: return
|
val frame = this.frame ?: return
|
||||||
|
|
||||||
EventQueue.invokeLater {
|
EventQueue.invokeLater {
|
||||||
@@ -216,9 +245,34 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
return@application
|
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(
|
val windowState = rememberWindowState(
|
||||||
width = widthDp.dp,
|
position = placed
|
||||||
height = heightDp.dp,
|
?.let { WindowPosition(it.x.dp, it.y.dp) }
|
||||||
|
?: WindowPosition.PlatformDefault,
|
||||||
|
width = placed?.width?.dp ?: widthDp.dp,
|
||||||
|
height = placed?.height?.dp ?: heightDp.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -227,11 +281,103 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
* comfort: these tables are wider than the default window, so an
|
* comfort: these tables are wider than the default window, so an
|
||||||
* operator who widens it once must not have to do it again.
|
* 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) {
|
if (onResized != null) {
|
||||||
LaunchedEffect(windowState) {
|
LaunchedEffect(windowState) {
|
||||||
snapshotFlow { windowState.size }.collect { size ->
|
/*
|
||||||
if (size.width.isSpecified && size.height.isSpecified) {
|
* A secondary window skips the opening size: it may be the
|
||||||
onResized(size.width.value, size.height.value)
|
* 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +397,9 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
*/
|
*/
|
||||||
DisposableEffect(window) {
|
DisposableEffect(window) {
|
||||||
frame = window
|
frame = window
|
||||||
|
if (isMainWindow) {
|
||||||
|
WindowPlacementOnScreen.mainWindow = window
|
||||||
|
}
|
||||||
/*
|
/*
|
||||||
* The only place the handle is dropped. Clearing it again in
|
* The only place the handle is dropped. Clearing it again in
|
||||||
* the thread's finally block looks like a belt to this brace,
|
* the thread's finally block looks like a belt to this brace,
|
||||||
@@ -259,7 +408,12 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
* the late write would wipe a live handle and bring back the
|
* the late write would wipe a live handle and bring back the
|
||||||
* dead button this whole method exists to remove.
|
* dead button this whole method exists to remove.
|
||||||
*/
|
*/
|
||||||
onDispose { frame = null }
|
onDispose {
|
||||||
|
frame = null
|
||||||
|
if (WindowPlacementOnScreen.mainWindow === window) {
|
||||||
|
WindowPlacementOnScreen.mainWindow = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
@@ -297,6 +451,126 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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
|
* the default Java cup. Null when the resource is missing rather than failing: a window
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
@@ -240,7 +242,7 @@ class EditableListState(
|
|||||||
* reject it and put the stored value back.
|
* reject it and put the stored value back.
|
||||||
*
|
*
|
||||||
* @param onChanged called after every committed change — add, edit, removal, move.
|
* @param onChanged called after every committed change — add, edit, removal, move.
|
||||||
* The lists write through, so this is where the roster is filled.
|
* The settings tabs stage the list here; applying the draft fills the roster.
|
||||||
* @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
|
* @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
|
||||||
* tables opened an alert here.
|
* tables opened an alert here.
|
||||||
* @param onAdd replaces the default "insert the template at the top" behaviour.
|
* @param onAdd replaces the default "insert the template at the top" behaviour.
|
||||||
@@ -300,6 +302,23 @@ 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
|
val isSelected = state.selectedIndex == index
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -275,7 +275,14 @@ class DataTableState<T>(
|
|||||||
var contentRevision by mutableStateOf(0)
|
var contentRevision by mutableStateOf(0)
|
||||||
private set
|
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() {
|
fun forceRedraw() {
|
||||||
|
invalidateOrder()
|
||||||
contentRevision++
|
contentRevision++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +306,7 @@ class DataTableState<T>(
|
|||||||
*/
|
*/
|
||||||
val currentRows = rows
|
val currentRows = rows
|
||||||
val currentFilter = rowFilter
|
val currentFilter = rowFilter
|
||||||
|
contentRevision // read for the subscription: forceRedraw drops the cache
|
||||||
val currentColumn = sortedColumnId
|
val currentColumn = sortedColumnId
|
||||||
val ascending = sortAscending
|
val ascending = sortAscending
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ object DirectedMessageColumns {
|
|||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
id = "airscout",
|
id = "airscout",
|
||||||
title = "AP [minutes / pot%]",
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnAirplane },
|
||||||
value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) },
|
value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) },
|
||||||
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
|
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
|
||||||
comparator = compareBy(AirplaneCell.order) { it.sender?.airPlaneReflectInfo },
|
comparator = compareBy(AirplaneCell.order) { it.sender?.airPlaneReflectInfo },
|
||||||
@@ -62,7 +62,7 @@ object DirectedMessageColumns {
|
|||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
id = "category",
|
id = "category",
|
||||||
title = "Category",
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
|
||||||
value = { message ->
|
value = { message ->
|
||||||
message.chatCategory
|
message.chatCategory
|
||||||
?.let { it.getChatCategoryName(it.categoryNumber) }
|
?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -111,10 +113,25 @@ object Form {
|
|||||||
* every prefix of a valid entry, so the draft lives here until the operator leaves
|
* every prefix of a valid entry, so the draft lives here until the operator leaves
|
||||||
* the field or presses Enter.
|
* the field or presses Enter.
|
||||||
*
|
*
|
||||||
* @param stored the value currently stored; the draft falls back to it when an
|
* Only an edited draft is committed: drawing the field, or entering and leaving it
|
||||||
* entry is refused, which is what those fields did with setText
|
* 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 commit returns null when the value was accepted, otherwise the reason
|
* @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 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
|
@Composable
|
||||||
fun committed(
|
fun committed(
|
||||||
@@ -122,24 +139,67 @@ object Form {
|
|||||||
stored: String,
|
stored: String,
|
||||||
commit: (String) -> String?,
|
commit: (String) -> String?,
|
||||||
onRefused: (String) -> Unit,
|
onRefused: (String) -> Unit,
|
||||||
|
isUnchanged: (draft: String, stored: String) -> Boolean = { typed, now -> typed == now },
|
||||||
|
current: () -> String,
|
||||||
) {
|
) {
|
||||||
var draft by remember(stored) { mutableStateOf(stored) }
|
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() {
|
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)
|
val refusal = commit(draft)
|
||||||
if (refusal != null) {
|
if (refusal != null) {
|
||||||
onRefused(refusal)
|
onRefused(refusal)
|
||||||
draft = stored
|
draft = current()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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) {
|
labelled(label) {
|
||||||
CompactTextField(
|
CompactTextField(
|
||||||
value = draft,
|
value = draft,
|
||||||
onValueChange = { draft = it },
|
onValueChange = { draft = it },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onFocusChanged { focus -> if (!focus.isFocused) commitDraft() }
|
.onFocusChanged { focus ->
|
||||||
|
if (focus.isFocused) {
|
||||||
|
hadFocus[0] = true
|
||||||
|
} else if (hadFocus[0]) {
|
||||||
|
hadFocus[0] = false
|
||||||
|
commitDraft()
|
||||||
|
}
|
||||||
|
}
|
||||||
.onKeyEvent { event ->
|
.onKeyEvent { event ->
|
||||||
if (event.type == KeyEventType.KeyUp && event.key == Key.Enter) {
|
if (event.type == KeyEventType.KeyUp && event.key == Key.Enter) {
|
||||||
commitDraft()
|
commitDraft()
|
||||||
@@ -183,7 +243,7 @@ object Form {
|
|||||||
.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
draft.value?.let(describe) ?: "not selected",
|
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
@@ -227,7 +287,7 @@ object Form {
|
|||||||
modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
draft.value?.let(describe) ?: "not selected",
|
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -264,34 +324,53 @@ object Form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun int(label: String, value: Int, onChange: (Int) -> Unit) {
|
fun int(label: String, value: Int, onChange: (Int) -> Unit) =
|
||||||
var draft by remember(value) { mutableStateOf(value.toString()) }
|
number(label, value, { it.trim().toIntOrNull() }, onChange)
|
||||||
|
|
||||||
labelled(label) {
|
|
||||||
CompactTextField(
|
|
||||||
value = draft,
|
|
||||||
onValueChange = { typed ->
|
|
||||||
draft = typed
|
|
||||||
typed.trim().toIntOrNull()?.let(onChange)
|
|
||||||
},
|
|
||||||
isError = draft.trim().toIntOrNull() == null,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) {
|
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) =
|
||||||
var draft by remember(value) { mutableStateOf(value.toString()) }
|
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() }
|
||||||
|
}
|
||||||
|
|
||||||
labelled(label) {
|
labelled(label) {
|
||||||
CompactTextField(
|
CompactTextField(
|
||||||
value = draft,
|
value = draft,
|
||||||
onValueChange = { typed ->
|
onValueChange = { entered ->
|
||||||
draft = typed
|
typed[0] = entered
|
||||||
typed.trim().replace(',', '.').toDoubleOrNull()?.let(onChange)
|
draft = entered
|
||||||
|
parse(entered)?.let(onChange)
|
||||||
},
|
},
|
||||||
isError = draft.trim().replace(',', '.').toDoubleOrNull() == null,
|
isError = parse(draft) == null,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,20 @@ import androidx.compose.foundation.hoverable
|
|||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.MenuDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -21,8 +27,16 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntRect
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.FrameWindowScope
|
import androidx.compose.ui.window.FrameWindowScope
|
||||||
import androidx.compose.ui.window.MenuBar
|
import androidx.compose.ui.window.MenuBar
|
||||||
|
import androidx.compose.ui.window.Popup
|
||||||
|
import androidx.compose.ui.window.PopupPositionProvider
|
||||||
|
import androidx.compose.ui.window.PopupProperties
|
||||||
import kst4contest.controller.On4KstConnectionState
|
import kst4contest.controller.On4KstConnectionState
|
||||||
import kst4contest.utils.PlatformUtils
|
import kst4contest.utils.PlatformUtils
|
||||||
import kst4contest.view.i18n.LocalStrings
|
import kst4contest.view.i18n.LocalStrings
|
||||||
@@ -163,7 +177,14 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
|
|||||||
val hovered by interactions.collectIsHoveredAsState()
|
val hovered by interactions.collectIsHoveredAsState()
|
||||||
val open = rowState.openMenu == menu.title
|
val open = rowState.openMenu == menu.title
|
||||||
|
|
||||||
Box {
|
/*
|
||||||
|
* Full bar height, so the popup anchored to this box starts at the bar's bottom edge
|
||||||
|
* rather than at the centred title text a few pixels above it.
|
||||||
|
*/
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.heightIn(min = Density.TAB_MIN_HEIGHT),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = menu.title,
|
text = menu.title,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -195,29 +216,90 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
DropdownMenu(expanded = open, onDismissRequest = rowState::close) {
|
/*
|
||||||
menu.entries.forEach { entry ->
|
* A hand-rolled stand-in for Material3's DropdownMenu, because that one cannot open
|
||||||
when (entry) {
|
* flush under a menu bar — see MenuRowPopupPositionProvider. Same dismissal (outside
|
||||||
is MenuEntry.Item -> DropdownMenuItem(
|
* click and Escape come with a focusable popup), same MenuDefaults look; only the
|
||||||
text = {
|
* expand animation is gone, which a menu bar does not have anyway.
|
||||||
Text(entry.label, style = MaterialTheme.typography.bodyMedium)
|
*/
|
||||||
},
|
if (open) {
|
||||||
enabled = entry.enabled,
|
Popup(
|
||||||
onClick = { rowState.pick(entry.onClick) },
|
popupPositionProvider = MenuRowPopupPositionProvider,
|
||||||
)
|
onDismissRequest = rowState::close,
|
||||||
|
properties = PopupProperties(focusable = true),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = MenuDefaults.shape,
|
||||||
|
color = MenuDefaults.containerColor,
|
||||||
|
tonalElevation = MenuDefaults.TonalElevation,
|
||||||
|
shadowElevation = MenuDefaults.ShadowElevation,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = MENU_POPUP_VERTICAL_PADDING)
|
||||||
|
.width(IntrinsicSize.Max)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
menu.entries.forEach { entry ->
|
||||||
|
when (entry) {
|
||||||
|
is MenuEntry.Item -> DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(entry.label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
},
|
||||||
|
enabled = entry.enabled,
|
||||||
|
onClick = { rowState.pick(entry.onClick) },
|
||||||
|
)
|
||||||
|
|
||||||
MenuEntry.Separator ->
|
MenuEntry.Separator ->
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = Density.HAIRLINE,
|
thickness = Density.HAIRLINE,
|
||||||
color = palette.separatorLine,
|
color = palette.separatorLine,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places a menu-bar menu: flush under its anchor, leading edges aligned.
|
||||||
|
*
|
||||||
|
* Material3's DropdownMenu cannot be told to do this. Its position provider refuses any
|
||||||
|
* position closer than 48 dp (`MenuVerticalMargin`) to the window's top edge, and a menu
|
||||||
|
* bar sits exactly there, so every menu opened pushed down to that margin — a visible gap
|
||||||
|
* below the bar. The margin is applied after the `offset` parameter and the provider is
|
||||||
|
* not replaceable, hence this one and the hand-rolled popup in [MenuTitle].
|
||||||
|
*/
|
||||||
|
internal object MenuRowPopupPositionProvider : PopupPositionProvider {
|
||||||
|
override fun calculatePosition(
|
||||||
|
anchorBounds: IntRect,
|
||||||
|
windowSize: IntSize,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
popupContentSize: IntSize,
|
||||||
|
): IntOffset {
|
||||||
|
val preferredX = when (layoutDirection) {
|
||||||
|
LayoutDirection.Ltr -> anchorBounds.left
|
||||||
|
LayoutDirection.Rtl -> anchorBounds.right - popupContentSize.width
|
||||||
|
}
|
||||||
|
val x = preferredX.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width))
|
||||||
|
val below = anchorBounds.bottom
|
||||||
|
val above = anchorBounds.top - popupContentSize.height
|
||||||
|
val y = when {
|
||||||
|
below + popupContentSize.height <= windowSize.height -> below
|
||||||
|
above >= 0 -> above
|
||||||
|
else -> maxOf(0, windowSize.height - popupContentSize.height)
|
||||||
|
}
|
||||||
|
return IntOffset(x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Enough tint to show the pointer is over a title, not enough to read as a selection. */
|
/** Enough tint to show the pointer is over a title, not enough to read as a selection. */
|
||||||
private const val HOVER_ALPHA = 0.35f
|
private const val HOVER_ALPHA = 0.35f
|
||||||
|
|
||||||
private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f)
|
private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f)
|
||||||
|
|
||||||
|
/** Material3's DropdownMenuVerticalPadding, which is internal; keep the menus looking alike. */
|
||||||
|
private val MENU_POPUP_VERTICAL_PADDING = 8.dp
|
||||||
@@ -205,6 +205,8 @@ private fun MessageColumn(
|
|||||||
ownQrgMain = state.ownQrgMain,
|
ownQrgMain = state.ownQrgMain,
|
||||||
ownQrgSecond = state.ownQrgSecond,
|
ownQrgSecond = state.ownQrgSecond,
|
||||||
antennaQtf = state.antennaQtf,
|
antennaQtf = state.antennaQtf,
|
||||||
|
antennaQtf2 = state.antennaQtf2,
|
||||||
|
so2rEnabled = state.surroundings.so2rEnabled,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package kst4contest.view.compose
|
package kst4contest.view.compose
|
||||||
|
|
||||||
import kst4contest.controller.On4KstConnectionState
|
import kst4contest.controller.On4KstConnectionState
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
|
||||||
/** How big the main window opens. */
|
/** How big the main window opens. */
|
||||||
data class MainWindowSize(val heightDp: Double, val widthDp: Double)
|
data class MainWindowSize(val heightDp: Double, val widthDp: Double)
|
||||||
@@ -56,30 +57,75 @@ object MainWindowFrame {
|
|||||||
fun title(chatState: String, profileName: String?): String =
|
fun title(chatState: String, profileName: String?): String =
|
||||||
if (profileName.isNullOrBlank()) chatState else "$chatState - $profileName"
|
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.
|
* What the title says while the chat is not usable.
|
||||||
*
|
*
|
||||||
* The wording separates a scheduled reconnect from a dead session, which is the
|
* The wording separates a scheduled reconnect from a dead session, which is the
|
||||||
* difference between waiting and doing something about it.
|
* 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 =
|
fun offlineChatState(state: On4KstConnectionState?): String =
|
||||||
when (state ?: On4KstConnectionState.DISCONNECTED) {
|
when (state ?: On4KstConnectionState.DISCONNECTED) {
|
||||||
On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().titleConnectionLost
|
On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().titleConnectionLost
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import androidx.compose.runtime.setValue
|
|||||||
*/
|
*/
|
||||||
object MainWindowHost {
|
object MainWindowHost {
|
||||||
|
|
||||||
private val host = ComposeWindowHost("main-window")
|
private val host = ComposeWindowHost("main-window", isMainWindow = true)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
val isOpen: Boolean
|
val isOpen: Boolean
|
||||||
@@ -111,14 +111,29 @@ object MainWindowHost {
|
|||||||
* Read inside the composition, so the title follows the connection the way
|
* 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
|
* 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
|
* (73 users online, 73 shown), 29 messages total." A captured string would
|
||||||
* say "disconnected" for the life of the session.
|
* 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.
|
||||||
*/
|
*/
|
||||||
val surroundings = currentState?.surroundings
|
val state = currentState
|
||||||
MainWindowFrame.title(
|
MainWindowFrame.title(
|
||||||
chatState = MainWindowFrame.chatState(
|
chatState = if (state == null) {
|
||||||
surroundings?.connectionState,
|
MainWindowFrame.offlineChatState(null)
|
||||||
surroundings?.connectionDetail,
|
} 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,
|
||||||
|
)
|
||||||
|
},
|
||||||
profileName = profileName,
|
profileName = profileName,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -75,13 +75,14 @@ class MainWindowState(
|
|||||||
val snippets = RosterMirror(prefs.getLst_txtSnipList())
|
val snippets = RosterMirror(prefs.getLst_txtSnipList())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The three small fields beside the send controls. Mirrored rather than read straight
|
* The four small fields beside the send controls. Mirrored rather than read straight
|
||||||
* from the preferences, because all three move without the operator touching them: the
|
* from the preferences, because all of them move without the operator touching them: the
|
||||||
* rotator writes the heading, and the frequency follower writes the own QRG.
|
* rotator writes the heading, and the frequency follower writes the own QRG.
|
||||||
*/
|
*/
|
||||||
val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat())
|
val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat())
|
||||||
val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat())
|
val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat())
|
||||||
val antennaQtf = ObservedValue(prefs.getActualQTF())
|
val antennaQtf = ObservedValue(prefs.getActualQTF())
|
||||||
|
val antennaQtf2 = ObservedValue(prefs.getActualQTF2())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops following the preferences. Called when this state is dropped — a profile switch
|
* Stops following the preferences. Called when this state is dropped — a profile switch
|
||||||
@@ -92,6 +93,7 @@ class MainWindowState(
|
|||||||
ownQrgMain.release()
|
ownQrgMain.release()
|
||||||
ownQrgSecond.release()
|
ownQrgSecond.release()
|
||||||
antennaQtf.release()
|
antennaQtf.release()
|
||||||
|
antennaQtf2.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -161,4 +163,13 @@ class MainWindowSurroundings(private val menu: MainMenuState = MainMenuState())
|
|||||||
var settingsWindowOpen: Boolean by mutableStateOf(false)
|
var settingsWindowOpen: Boolean by mutableStateOf(false)
|
||||||
|
|
||||||
var monitorWindowOpen: 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(
|
DataColumn(
|
||||||
id = "category",
|
id = "category",
|
||||||
title = "Category",
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
|
||||||
value = { message ->
|
value = { message ->
|
||||||
message.sender?.chatCategory
|
message.sender?.chatCategory
|
||||||
?.let { it.getChatCategoryName(it.categoryNumber) }
|
?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -17,10 +16,11 @@ import kst4contest.model.ClusterMessage
|
|||||||
/**
|
/**
|
||||||
* The cluster and QSO monitor.
|
* The cluster and QSO monitor.
|
||||||
*
|
*
|
||||||
* Two live tables, one above the other. This is the first window in which the table
|
* Two live tables, one above the other, with a divider the operator can drag. This
|
||||||
* carries data that changes while the operator watches, which is why the row identity
|
* is the first window in which the table carries data that changes while the operator
|
||||||
* rules in DataTableState matter here and not in the settings window: in Etappe 2 the
|
* watches, which is why the row identity rules in DataTableState matter here and not in
|
||||||
* visible defects all came from that coupling, not from the table itself.
|
* the settings window: in Etappe 2 the visible defects all came from that coupling, not
|
||||||
|
* from the table itself.
|
||||||
*/
|
*/
|
||||||
object MonitorWindow {
|
object MonitorWindow {
|
||||||
|
|
||||||
@@ -44,6 +44,8 @@ object MonitorWindow {
|
|||||||
fun show(
|
fun show(
|
||||||
clusterTable: DataTableState<ClusterMessage>,
|
clusterTable: DataTableState<ClusterMessage>,
|
||||||
qsoTable: DataTableState<ChatMessage>,
|
qsoTable: DataTableState<ChatMessage>,
|
||||||
|
/** The divider between the two tables, remembered in the profile (#144). */
|
||||||
|
splitter: SplitterState,
|
||||||
darkMode: Boolean,
|
darkMode: Boolean,
|
||||||
baseFontSizeSp: Float,
|
baseFontSizeSp: Float,
|
||||||
/**
|
/**
|
||||||
@@ -61,7 +63,7 @@ object MonitorWindow {
|
|||||||
onResized: (Float, Float) -> Unit,
|
onResized: (Float, Float) -> Unit,
|
||||||
) {
|
) {
|
||||||
host.show(
|
host.show(
|
||||||
title = { "Cluster & QSO of the other" },
|
title = { kst4contest.view.i18n.CurrentStrings.get().monitorTitle },
|
||||||
darkMode = darkMode,
|
darkMode = darkMode,
|
||||||
baseFontSizeSp = baseFontSizeSp,
|
baseFontSizeSp = baseFontSizeSp,
|
||||||
paletteStore = paletteStore,
|
paletteStore = paletteStore,
|
||||||
@@ -70,38 +72,41 @@ object MonitorWindow {
|
|||||||
heightDp = heightDp,
|
heightDp = heightDp,
|
||||||
onResized = onResized,
|
onResized = onResized,
|
||||||
) { _ ->
|
) { _ ->
|
||||||
MonitorContent(clusterTable, qsoTable)
|
MonitorContent(clusterTable, qsoTable, splitter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Internal rather than private so the window's body can be drawn headless in a test. */
|
||||||
|
@Composable
|
||||||
|
internal fun MonitorContent(
|
||||||
|
clusterTable: DataTableState<ClusterMessage>,
|
||||||
|
qsoTable: DataTableState<ChatMessage>,
|
||||||
|
splitter: SplitterState,
|
||||||
|
) {
|
||||||
|
val strings = kst4contest.view.i18n.LocalStrings.current
|
||||||
|
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
/*
|
||||||
|
* Each heading sits in the pane of its table, so dragging the divider moves the
|
||||||
|
* heading with the table it names rather than leaving it stranded above the other.
|
||||||
|
*/
|
||||||
|
SplitterPane(
|
||||||
|
state = splitter,
|
||||||
|
orientation = SplitterOrientation.VERTICAL,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(4.dp),
|
||||||
|
panes = listOf(
|
||||||
|
{ MonitorPane(strings.monitorDxCluster, clusterTable) },
|
||||||
|
{ MonitorPane(strings.messagesQsoOfTheOther, qsoTable) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonitorContent(
|
private fun <T> MonitorPane(heading: String, table: DataTableState<T>) {
|
||||||
clusterTable: DataTableState<ClusterMessage>,
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
qsoTable: DataTableState<ChatMessage>,
|
Text(heading, style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 4.dp))
|
||||||
) {
|
DataTableView(table, modifier = Modifier.weight(1f).fillMaxWidth())
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
|
||||||
Text("DX cluster", style = MaterialTheme.typography.titleSmall)
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Half each, fixed. The JavaFX window used a SplitPane whose divider the
|
|
||||||
* operator could drag, and that position was stored; reproducing the
|
|
||||||
* gesture belongs with the station list of Etappe 5, where a split pane is
|
|
||||||
* unavoidable. Until then an even split is honest rather than a broken
|
|
||||||
* handle.
|
|
||||||
*/
|
|
||||||
DataTableView(clusterTable, modifier = Modifier.weight(1f).fillMaxWidth())
|
|
||||||
|
|
||||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"QSO of the other",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
DataTableView(qsoTable, modifier = Modifier.weight(1f).fillMaxWidth())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ object OperatorProfilePickerWindow {
|
|||||||
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL)
|
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL)
|
||||||
dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle
|
dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle
|
||||||
dialog.size = DIALOG_SIZE
|
dialog.size = DIALOG_SIZE
|
||||||
dialog.setLocationRelativeTo(null)
|
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
|
||||||
/* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */
|
/* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */
|
||||||
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
||||||
/*
|
/*
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
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 = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.sender?.name.orEmpty() }),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
id = "last-qrg",
|
id = "last-qrg",
|
||||||
title = "Last QRG",
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrg },
|
||||||
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
|
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
|
||||||
weight = 0.8f,
|
weight = 0.8f,
|
||||||
),
|
),
|
||||||
@@ -38,7 +38,7 @@ object PublicMessageColumns {
|
|||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
id = "category",
|
id = "category",
|
||||||
title = "Category",
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
|
||||||
value = { message ->
|
value = { message ->
|
||||||
message.sender?.chatCategory
|
message.sender?.chatCategory
|
||||||
?.let { it.getChatCategoryName(it.categoryNumber) }
|
?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||||
|
|||||||
+2
-2
@@ -16,13 +16,13 @@ object SelectedStationMessageColumns {
|
|||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
id = "call-tx",
|
id = "call-tx",
|
||||||
title = "Sender",
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnSender },
|
||||||
value = { MonitorColumns.callSignOf(it.sender) },
|
value = { MonitorColumns.callSignOf(it.sender) },
|
||||||
weight = 0.8f,
|
weight = 0.8f,
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
id = "call-rx",
|
id = "call-rx",
|
||||||
title = "Receiver",
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnReceiver },
|
||||||
value = { MonitorColumns.callSignOf(it.receiver) },
|
value = { MonitorColumns.callSignOf(it.receiver) },
|
||||||
weight = 0.8f,
|
weight = 0.8f,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ fun SelectedStationPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Form.check("all", activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
|
Form.check(strings.selectedNotQrvAll, activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
|
||||||
state.tagNotQrvAll(it)
|
state.tagNotQrvAll(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,17 @@ fun SelectedStationPanel(
|
|||||||
Form.section(strings.selectedLookUpAndShow) {
|
Form.section(strings.selectedLookUpAndShow) {
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||||
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
|
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
|
||||||
Form.button("🗺 Show on map", onClick = onShowOnMap)
|
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(strings.selectedLookupQrz, enabled = state.canLookUp) {
|
Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) {
|
||||||
openInBrowser.accept(state.qrzComUrl())
|
openInBrowser.accept(state.qrzComUrl())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ enum class SelectedMessageFilter(val label: String) {
|
|||||||
* `propagateNotQrvStateToActiveMembers` does
|
* `propagateNotQrvStateToActiveMembers` does
|
||||||
* @param createSked takes the station, the minutes, the band and the mode; returns
|
* @param createSked takes the station, the minutes, the band and the mode; returns
|
||||||
* whether it was accepted
|
* 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(
|
class SelectedStationState(
|
||||||
private val propagateNotQrv: (ChatMember) -> Unit,
|
private val propagateNotQrv: (ChatMember) -> Unit,
|
||||||
@@ -63,6 +69,9 @@ class SelectedStationState(
|
|||||||
private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { },
|
private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { },
|
||||||
private val detectBands: (ChatMember) -> String = { "" },
|
private val detectBands: (ChatMember) -> String = { "" },
|
||||||
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
|
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)
|
var selected: ChatMember? by mutableStateOf(null)
|
||||||
@@ -103,6 +112,30 @@ class SelectedStationState(
|
|||||||
val canLookUp: Boolean
|
val canLookUp: Boolean
|
||||||
get() = !selected?.callSign.isNullOrBlank()
|
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?) {
|
fun select(member: ChatMember?) {
|
||||||
if (selected?.callSignRaw != member?.callSignRaw) {
|
if (selected?.callSignRaw != member?.callSignRaw) {
|
||||||
skedBand = member?.let { resolveDefaultSkedBand(it) }
|
skedBand = member?.let { resolveDefaultSkedBand(it) }
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
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,6 +8,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import kst4contest.controller.ChatController
|
import kst4contest.controller.ChatController
|
||||||
import kst4contest.controller.ActiveOperatorProfile
|
import kst4contest.controller.ActiveOperatorProfile
|
||||||
import kst4contest.controller.OperatorProfileManagementService
|
import kst4contest.controller.OperatorProfileManagementService
|
||||||
|
import kst4contest.controller.On4KstConnectionState
|
||||||
import kst4contest.controller.OperatorProfilePaths
|
import kst4contest.controller.OperatorProfilePaths
|
||||||
import kst4contest.model.ChatMember
|
import kst4contest.model.ChatMember
|
||||||
import kst4contest.model.OperatorProfile
|
import kst4contest.model.OperatorProfile
|
||||||
@@ -36,6 +37,7 @@ import kst4contest.view.compose.tabs.TrxSynchTab
|
|||||||
import kst4contest.view.compose.tabs.TrxSynchTabState
|
import kst4contest.view.compose.tabs.TrxSynchTabState
|
||||||
import kst4contest.view.compose.tabs.WorkedDatabaseTab
|
import kst4contest.view.compose.tabs.WorkedDatabaseTab
|
||||||
import kst4contest.view.compose.tabs.WorkedDatabaseTabState
|
import kst4contest.view.compose.tabs.WorkedDatabaseTabState
|
||||||
|
import kst4contest.view.compose.tabs.repairStoredSettings
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The work the settings window needs from the main window.
|
* The work the settings window needs from the main window.
|
||||||
@@ -56,6 +58,13 @@ interface SettingsHost {
|
|||||||
*/
|
*/
|
||||||
fun refreshShortcutButtonsFromSettings()
|
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. */
|
/** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */
|
||||||
fun refreshTextSnippetContextMenusFromSettings()
|
fun refreshTextSnippetContextMenusFromSettings()
|
||||||
|
|
||||||
@@ -113,6 +122,12 @@ interface SettingsHost {
|
|||||||
* place, and the foreign-write coverage test can see it. A tab that only wrote a
|
* 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
|
* preference and skipped its session call would look correct in the file and do
|
||||||
* nothing until the next restart.
|
* 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(
|
fun buildSettingsTabs(
|
||||||
controller: ChatController,
|
controller: ChatController,
|
||||||
@@ -120,32 +135,42 @@ fun buildSettingsTabs(
|
|||||||
notices: SettingsNotices,
|
notices: SettingsNotices,
|
||||||
paletteStore: PaletteStore,
|
paletteStore: PaletteStore,
|
||||||
languageStore: kst4contest.view.i18n.LanguageStore,
|
languageStore: kst4contest.view.i18n.LanguageStore,
|
||||||
|
connectionState: () -> On4KstConnectionState,
|
||||||
|
draft: SettingsDraft,
|
||||||
): List<SettingsTab> {
|
): List<SettingsTab> {
|
||||||
|
|
||||||
val prefs = controller.chatPreferences
|
val prefs = controller.chatPreferences
|
||||||
|
|
||||||
|
// Once, before any tab reads: see repairStoredSettings for why not inside the tabs.
|
||||||
|
repairStoredSettings(prefs, host::detectWintestBroadcastAddress)
|
||||||
|
|
||||||
val stationState = StationTabState(
|
val stationState = StationTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
applyMainCategory = { category -> controller.setChatCategoryMain(category) },
|
applyMainCategory = { category -> controller.setChatCategoryMain(category) },
|
||||||
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
|
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
|
||||||
|
connectionState = connectionState,
|
||||||
|
applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() },
|
||||||
)
|
)
|
||||||
|
|
||||||
val notificationState = NotificationTabState(
|
val notificationState = NotificationTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
controller.lstNotify_QSOSniffer_sniffedCallSignList,
|
controller.lstNotify_QSOSniffer_sniffedCallSignList,
|
||||||
applyDxClusterServerEnabled = { enabled ->
|
applyDxClusterServer = {
|
||||||
/*
|
/*
|
||||||
* Starting a server that nothing can reach yet is pointless, so the JavaFX
|
* 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
|
* checkbox only touched the server while the session was logged in; the
|
||||||
* flag alone decides what happens at the next login.
|
* 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.
|
||||||
*/
|
*/
|
||||||
if (controller.isConnectedAndLoggedIn) {
|
if (controller.isConnectedAndLoggedIn) {
|
||||||
if (enabled) controller.startDxClusterServerIfEnabled() else controller.stopDxClusterServer()
|
if (prefs.isNotify_dxClusterServerEnabled()) {
|
||||||
}
|
controller.restartDxClusterServerIfEnabled()
|
||||||
},
|
} else {
|
||||||
applyDxClusterServerPort = {
|
controller.stopDxClusterServer()
|
||||||
if (controller.isConnectedAndLoggedIn && prefs.isNotify_dxClusterServerEnabled()) {
|
}
|
||||||
controller.restartDxClusterServerIfEnabled()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) },
|
broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) },
|
||||||
@@ -153,12 +178,14 @@ fun buildSettingsTabs(
|
|||||||
|
|
||||||
val shortcutsState = ShortcutsTabState(
|
val shortcutsState = ShortcutsTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
refreshShortcutButtons = host::refreshShortcutButtonsFromSettings,
|
refreshShortcutButtons = host::refreshShortcutButtonsFromSettings,
|
||||||
refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings,
|
refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings,
|
||||||
)
|
)
|
||||||
|
|
||||||
val beaconState = BeaconTabState(
|
val beaconState = BeaconTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
|
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
|
||||||
restartBeaconTimer = { controller.restartBeaconTimer() },
|
restartBeaconTimer = { controller.restartBeaconTimer() },
|
||||||
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
|
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
|
||||||
@@ -167,9 +194,9 @@ fun buildSettingsTabs(
|
|||||||
|
|
||||||
val logSynchState = LogSynchTabState(
|
val logSynchState = LogSynchTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
|
restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
|
||||||
stopWintestListener = { controller.stopWintestUdpListener() },
|
stopWintestListener = { controller.stopWintestUdpListener() },
|
||||||
detectWintestBroadcastAddress = host::detectWintestBroadcastAddress,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val workedDatabaseState = WorkedDatabaseTabState(
|
val workedDatabaseState = WorkedDatabaseTabState(
|
||||||
@@ -204,14 +231,14 @@ fun buildSettingsTabs(
|
|||||||
* so a tidier order or a shorter title would be a regression, not an improvement.
|
* so a tidier order or a shorter title would be a regression, not an improvement.
|
||||||
*/
|
*/
|
||||||
return listOf(
|
return listOf(
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState, reportRefusal) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, host::applyOwnQrgFollower)) },
|
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)) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs, draft)) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabNotification }) { NotificationTab(notificationState, reportRefusal) },
|
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().tabShortcuts }) { ShortcutsTab(shortcutsState, reportRefusal) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabBeacon }) { BeaconTab(beaconState, reportRefusal) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabBeacon }) { BeaconTab(beaconState, reportRefusal) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, host::applyDebugModeToFile)) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, draft, host::applyDebugModeToFile)) },
|
||||||
/*
|
/*
|
||||||
* Selecting this tab re-read the database in the JavaFX window; the list is a
|
* 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.
|
* snapshot and would otherwise show what was true when the window opened.
|
||||||
@@ -220,7 +247,7 @@ fun buildSettingsTabs(
|
|||||||
{ kst4contest.view.i18n.CurrentStrings.get().tabWorkedDatabase },
|
{ kst4contest.view.i18n.CurrentStrings.get().tabWorkedDatabase },
|
||||||
onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() },
|
onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() },
|
||||||
) { WorkedDatabaseTab(workedDatabaseState) },
|
) { WorkedDatabaseTab(workedDatabaseState) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, languageStore)) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, draft, languageStore)) },
|
||||||
/*
|
/*
|
||||||
* Appended last on purpose so no established tab position shifts, the reason
|
* Appended last on purpose so no established tab position shifts, the reason
|
||||||
* the JavaFX tab gave for the same placement.
|
* the JavaFX tab gave for the same placement.
|
||||||
@@ -257,18 +284,18 @@ fun buildSettingsTabs(
|
|||||||
*/
|
*/
|
||||||
private fun deliverTestSpot(controller: ChatController, spot: ChatMember): String? {
|
private fun deliverTestSpot(controller: ChatController, spot: ChatMember): String? {
|
||||||
if (!controller.isConnectedAndLoggedIn || !controller.chatPreferences.isNotify_dxClusterServerEnabled()) {
|
if (!controller.isConnectedAndLoggedIn || !controller.chatPreferences.isNotify_dxClusterServerEnabled()) {
|
||||||
return "Connect KST4Contest and enable the local DX Cluster server first."
|
return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNeedsConnection
|
||||||
}
|
}
|
||||||
|
|
||||||
val server = controller.dxClusterServer
|
val server = controller.dxClusterServer
|
||||||
?: return "Connect KST4Contest and enable the local DX Cluster server first."
|
?: return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNeedsConnection
|
||||||
|
|
||||||
if (!server.hasConnectedClients()) {
|
if (!server.hasConnectedClients()) {
|
||||||
return "No DX Cluster client is connected to KST4Contest."
|
return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNoClient
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!server.broadcastSingleDXClusterEntryToLoggers(spot)) {
|
if (!server.broadcastSingleDXClusterEntryToLoggers(spot)) {
|
||||||
return "The test spot could not be delivered."
|
return kst4contest.view.i18n.CurrentStrings.get().settingsSpotNotDelivered
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -284,11 +311,19 @@ private fun categoryName(category: kst4contest.model.ChatCategory?, fallback: St
|
|||||||
* Built here because it needs the controller for its state and the host for its
|
* 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
|
* 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.
|
* 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(
|
fun settingsButtons(
|
||||||
controller: ChatController,
|
controller: ChatController,
|
||||||
host: SettingsHost,
|
host: SettingsHost,
|
||||||
notices: SettingsNotices,
|
notices: SettingsNotices,
|
||||||
|
draft: SettingsDraft,
|
||||||
): @Composable (close: () -> Unit) -> Unit {
|
): @Composable (close: () -> Unit) -> Unit {
|
||||||
val barState = ConnectionBarState(controller.chatPreferences, controller)
|
val barState = ConnectionBarState(controller.chatPreferences, controller)
|
||||||
|
|
||||||
@@ -304,24 +339,14 @@ fun settingsButtons(
|
|||||||
ConnectionBar(
|
ConnectionBar(
|
||||||
state = barState,
|
state = barState,
|
||||||
onConnect = {
|
onConnect = {
|
||||||
val refusal = host.connectFromSettings()
|
if (applyChecked(draft, notices)) {
|
||||||
if (refusal != null) notices.problem(refusal)
|
val refusal = host.connectFromSettings()
|
||||||
|
if (refusal != null) notices.problem(refusal)
|
||||||
|
}
|
||||||
revision++
|
revision++
|
||||||
},
|
},
|
||||||
onSave = {
|
onSave = { applyAndSave(draft, notices, host::savePreferencesFromSettings) },
|
||||||
val target = host.savePreferencesFromSettings()
|
onApplyAndClose = { if (applyChecked(draft, notices)) close() },
|
||||||
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,
|
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
|
||||||
onDisconnectOnly = {
|
onDisconnectOnly = {
|
||||||
host.disconnectOnlyFromSettings()
|
host.disconnectOnlyFromSettings()
|
||||||
@@ -330,3 +355,74 @@ 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,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -29,12 +30,16 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
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.
|
* One tab of the settings window: a title and the content to show.
|
||||||
@@ -104,6 +109,90 @@ 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.
|
* The settings window.
|
||||||
*
|
*
|
||||||
@@ -112,9 +201,11 @@ class SettingsNotices {
|
|||||||
* most once — a second instance would let two windows write the same preferences
|
* most once — a second instance would let two windows write the same preferences
|
||||||
* against each other.
|
* against each other.
|
||||||
*
|
*
|
||||||
* Saving does not collect values. Every control writes into ChatPreferences as it
|
* The controls stage their edits in a [SettingsDraft]; the button bar applies it. Closing
|
||||||
* changes; the button bar only persists, which is what the JavaFX "Save settings"
|
* the window, switching the operator profile or leaving the client while edits are waiting
|
||||||
* button did.
|
* 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.
|
||||||
*/
|
*/
|
||||||
object SettingsWindow {
|
object SettingsWindow {
|
||||||
|
|
||||||
@@ -135,11 +226,67 @@ object SettingsWindow {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
|
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
|
||||||
|
|
||||||
/** Closes the window and waits for it to be gone; see ComposeWindowHost.close. */
|
/** 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].
|
||||||
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun close() = host.close()
|
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
|
* @param buttons the bottom button row; it receives the action that closes the
|
||||||
* window, because only the window itself can end its Compose application
|
* window, because only the window itself can end its Compose application
|
||||||
*/
|
*/
|
||||||
@@ -147,6 +294,7 @@ object SettingsWindow {
|
|||||||
fun show(
|
fun show(
|
||||||
tabs: List<SettingsTab>,
|
tabs: List<SettingsTab>,
|
||||||
notices: SettingsNotices,
|
notices: SettingsNotices,
|
||||||
|
draft: SettingsDraft,
|
||||||
darkMode: Boolean,
|
darkMode: Boolean,
|
||||||
baseFontSizeSp: Float,
|
baseFontSizeSp: Float,
|
||||||
/**
|
/**
|
||||||
@@ -164,8 +312,17 @@ object SettingsWindow {
|
|||||||
onResized: (Float, Float) -> Unit,
|
onResized: (Float, Float) -> Unit,
|
||||||
buttons: @Composable (close: () -> Unit) -> 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(
|
host.show(
|
||||||
title = { "Change Client Settings" },
|
title = { kst4contest.view.i18n.CurrentStrings.get().settingsWindowTitle },
|
||||||
darkMode = darkMode,
|
darkMode = darkMode,
|
||||||
baseFontSizeSp = baseFontSizeSp,
|
baseFontSizeSp = baseFontSizeSp,
|
||||||
paletteStore = paletteStore,
|
paletteStore = paletteStore,
|
||||||
@@ -173,85 +330,158 @@ object SettingsWindow {
|
|||||||
widthDp = widthDp,
|
widthDp = widthDp,
|
||||||
heightDp = heightDp,
|
heightDp = heightDp,
|
||||||
onResized = onResized,
|
onResized = onResized,
|
||||||
|
// The X is clicked in a window that is showing, so its frames run.
|
||||||
|
onCloseRequest = { question.settleClose(draft, notices) },
|
||||||
) { close ->
|
) { close ->
|
||||||
SettingsContent(tabs, notices, buttons, close)
|
SettingsContent(tabs, notices, buttons, close, draft, question)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
@Composable
|
||||||
internal fun SettingsContent(
|
internal fun SettingsContent(
|
||||||
tabs: List<SettingsTab>,
|
tabs: List<SettingsTab>,
|
||||||
notices: SettingsNotices,
|
notices: SettingsNotices,
|
||||||
buttons: @Composable (close: () -> Unit) -> Unit,
|
buttons: @Composable (close: () -> Unit) -> Unit,
|
||||||
close: () -> Unit,
|
close: () -> Unit,
|
||||||
|
draft: SettingsDraft = SettingsDraft(),
|
||||||
|
question: SettingsQuestion = SettingsQuestion(),
|
||||||
) {
|
) {
|
||||||
var selected by remember { mutableStateOf(0) }
|
var selected by remember { mutableStateOf(0) }
|
||||||
|
val closes = question.closeRequests
|
||||||
|
LaunchedEffect(closes) {
|
||||||
|
if (closes > 0) close()
|
||||||
|
}
|
||||||
|
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
CompositionLocalProvider(LocalSettingsDraft provides draft) {
|
||||||
Column(
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier.padding(10.dp),
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
modifier = Modifier.padding(10.dp),
|
||||||
) {
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
SettingsTabStrip(
|
) {
|
||||||
tabs = tabs,
|
SettingsTabStrip(
|
||||||
selectedIndex = selected,
|
tabs = tabs,
|
||||||
onSelect = { index ->
|
selectedIndex = selected,
|
||||||
selected = index
|
onSelect = { index ->
|
||||||
tabs[index].onSelected?.invoke()
|
selected = index
|
||||||
},
|
tabs[index].onSelected?.invoke()
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* One scroll region for the whole window, with a scrollbar beside it. The
|
* 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
|
* 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
|
* 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.
|
* mid-form with nothing to say that more was below.
|
||||||
*/
|
*/
|
||||||
val scroll = rememberScrollState()
|
val scroll = rememberScrollState()
|
||||||
|
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(scroll)
|
.verticalScroll(scroll)
|
||||||
.padding(end = Density.SCROLLBAR_GUTTER),
|
.padding(end = Density.SCROLLBAR_GUTTER),
|
||||||
) {
|
) {
|
||||||
tabs.getOrNull(selected)?.content?.invoke()
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
VerticalScrollbar(
|
buttons(close)
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons(close)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
notices.current?.let { notice ->
|
notices.current?.let { notice ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = notices::dismiss,
|
onDismissRequest = notices::dismiss,
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
when (notice) {
|
when (notice) {
|
||||||
is SettingsNotice.Info -> "Info"
|
is SettingsNotice.Info -> kst4contest.view.i18n.LocalStrings.current.settingsNoticeInfo
|
||||||
is SettingsNotice.Problem -> "Settings"
|
is SettingsNotice.Problem -> kst4contest.view.i18n.LocalStrings.current.settingsNoticeProblem
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = { Text(notice.message) },
|
text = { Text(notice.message) },
|
||||||
confirmButton = { TextButton(onClick = notices::dismiss) { Text("OK") } },
|
confirmButton = { TextButton(onClick = notices::dismiss) { Text(kst4contest.view.i18n.LocalStrings.current.dialogOk) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* The row of tab buttons.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ object StationColumns {
|
|||||||
/** The heading the ten worked columns sit under. */
|
/** The heading the ten worked columns sit under. */
|
||||||
const val WORKED_GROUP = "worked"
|
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(
|
fun all(
|
||||||
tropoOf: (ChatMember) -> String,
|
tropoOf: (ChatMember) -> String,
|
||||||
priorityScoreOf: (ChatMember) -> Double,
|
priorityScoreOf: (ChatMember) -> Double,
|
||||||
@@ -52,7 +65,7 @@ object StationColumns {
|
|||||||
add(DataColumn(id = "qrg", title = "QRG", value = { it.frequency?.get().orEmpty() }, weight = 0.8f))
|
add(DataColumn(id = "qrg", title = "QRG", value = { it.frequency?.get().orEmpty() }, weight = 0.8f))
|
||||||
add(DataColumn(
|
add(DataColumn(
|
||||||
id = "airscout",
|
id = "airscout",
|
||||||
title = "AP [minutes / pot%]",
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnAirplane },
|
||||||
value = { AirplaneCell.format(it.airPlaneReflectInfo) },
|
value = { AirplaneCell.format(it.airPlaneReflectInfo) },
|
||||||
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
|
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
|
||||||
comparator = compareBy(AirplaneCell.order) { it.airPlaneReflectInfo },
|
comparator = compareBy(AirplaneCell.order) { it.airPlaneReflectInfo },
|
||||||
@@ -61,7 +74,7 @@ object StationColumns {
|
|||||||
add(DataColumn(id = "tropo", title = "Tropo", value = tropoOf, weight = 0.7f))
|
add(DataColumn(id = "tropo", title = "Tropo", value = tropoOf, weight = 0.7f))
|
||||||
add(DataColumn(
|
add(DataColumn(
|
||||||
id = "score",
|
id = "score",
|
||||||
title = "Score",
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnScore },
|
||||||
value = { priorityScoreOf(it).toInt().toString() },
|
value = { priorityScoreOf(it).toInt().toString() },
|
||||||
comparator = compareBy { priorityScoreOf(it) },
|
comparator = compareBy { priorityScoreOf(it) },
|
||||||
weight = 0.6f,
|
weight = 0.6f,
|
||||||
@@ -100,7 +113,7 @@ object StationColumns {
|
|||||||
add(DataColumn(id = "not-qrv", title = "NOT QRV @", value = { qrvBands(it, activeBands) }, weight = 1.6f))
|
add(DataColumn(id = "not-qrv", title = "NOT QRV @", value = { qrvBands(it, activeBands) }, weight = 1.6f))
|
||||||
add(DataColumn(
|
add(DataColumn(
|
||||||
id = "category",
|
id = "category",
|
||||||
title = "Category",
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnCategory },
|
||||||
value = { member ->
|
value = { member ->
|
||||||
member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }.orEmpty()
|
member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }.orEmpty()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,11 +48,19 @@ fun StationFilterBar(
|
|||||||
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp),
|
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
) {
|
) {
|
||||||
// ROW 1: Reset | QRB | QTF + bearings
|
/*
|
||||||
Row(
|
* ROW 1: Reset | QRB max + min | QTF | bearings
|
||||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
|
*
|
||||||
|
* 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(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Reset filters
|
// Reset filters
|
||||||
FilterButton(strings.filterReset, on = false, isAccent = true) {
|
FilterButton(strings.filterReset, on = false, isAccent = true) {
|
||||||
@@ -60,65 +68,105 @@ fun StationFilterBar(
|
|||||||
onChanged()
|
onChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
VerticalSeparator()
|
// QRB maximum + minimum (#21): together they make a range, so they stay one group
|
||||||
|
FilterGroup {
|
||||||
// QRB checkbox + field
|
GroupSeparator()
|
||||||
CompactCheckbox(
|
FlowRow(
|
||||||
checked = filters.maxQrbEnabled,
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
onCheckedChange = {
|
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
filters.maxQrbEnabled = it
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
onChanged()
|
) {
|
||||||
},
|
FilterGroup {
|
||||||
)
|
CompactCheckbox(
|
||||||
Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall)
|
checked = filters.maxQrbEnabled,
|
||||||
CompactTextField(
|
onCheckedChange = {
|
||||||
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
|
filters.maxQrbEnabled = it
|
||||||
onValueChange = {
|
onChanged()
|
||||||
val parsed = it.toDoubleOrNull()
|
},
|
||||||
if (parsed != null) {
|
)
|
||||||
filters.maxQrbKm = parsed
|
Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall)
|
||||||
if (filters.maxQrbEnabled) onChanged()
|
CompactTextField(
|
||||||
} else if (it.isEmpty()) {
|
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
|
||||||
filters.maxQrbKm = 0.0
|
onValueChange = {
|
||||||
if (filters.maxQrbEnabled) onChanged()
|
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 {
|
||||||
modifier = Modifier.width(50.dp)
|
CompactCheckbox(
|
||||||
)
|
checked = filters.minQrbEnabled,
|
||||||
|
onCheckedChange = {
|
||||||
VerticalSeparator()
|
filters.minQrbEnabled = it
|
||||||
|
onChanged()
|
||||||
// QTF checkbox + field + bearings
|
},
|
||||||
CompactCheckbox(
|
)
|
||||||
checked = filters.qtfEnabled,
|
Text(strings.filterShowOnlyQrbAbove, style = MaterialTheme.typography.bodySmall)
|
||||||
onCheckedChange = {
|
CompactTextField(
|
||||||
filters.qtfEnabled = it
|
value = if (filters.minQrbKm.rem(1) == 0.0) filters.minQrbKm.toInt().toString() else filters.minQrbKm.toString(),
|
||||||
onChanged()
|
onValueChange = {
|
||||||
},
|
val parsed = parseQrbKm(it)
|
||||||
)
|
if (parsed != null) {
|
||||||
Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall)
|
filters.minQrbKm = parsed
|
||||||
CompactTextField(
|
if (filters.minQrbEnabled) onChanged()
|
||||||
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
|
} else if (it.isEmpty()) {
|
||||||
onValueChange = {
|
filters.minQrbKm = 0.0
|
||||||
val parsed = it.toDoubleOrNull()
|
if (filters.minQrbEnabled) onChanged()
|
||||||
if (parsed != null) {
|
}
|
||||||
filters.qtfDegrees = parsed
|
},
|
||||||
filters.bearing = null
|
modifier = Modifier.width(50.dp)
|
||||||
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 ->
|
// QTF checkbox + field
|
||||||
FilterButton(bearing.name, on = filters.bearing == bearing) {
|
FilterGroup {
|
||||||
filters.toggleBearing(bearing)
|
GroupSeparator()
|
||||||
onChanged()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,6 +283,31 @@ 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. */
|
/** Thin vertical separator matching JavaFX's group dividers. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun VerticalSeparator() {
|
private fun VerticalSeparator() {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ enum class StationFilter {
|
|||||||
NEW_BANDS,
|
NEW_BANDS,
|
||||||
AIRSCOUT_NEXT_5_MIN,
|
AIRSCOUT_NEXT_5_MIN,
|
||||||
MAX_QRB,
|
MAX_QRB,
|
||||||
|
MIN_QRB,
|
||||||
QTF,
|
QTF,
|
||||||
SEARCH_TEXT,
|
SEARCH_TEXT,
|
||||||
HIDE_INACTIVE,
|
HIDE_INACTIVE,
|
||||||
@@ -33,6 +34,17 @@ enum class StationFilter {
|
|||||||
HIDE_WORKED_3,
|
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. */
|
/** 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) {
|
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),
|
B_50("50", StationFilter.HIDE_WORKED_50, kst4contest.model.Band.B_50),
|
||||||
@@ -99,6 +111,16 @@ class StationFilterState {
|
|||||||
/** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */
|
/** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */
|
||||||
var maxQrbKm: Double by mutableStateOf(0.0)
|
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
|
var qtfEnabled: Boolean
|
||||||
get() = isOn(StationFilter.QTF)
|
get() = isOn(StationFilter.QTF)
|
||||||
set(value) = switch(StationFilter.QTF, value)
|
set(value) = switch(StationFilter.QTF, value)
|
||||||
@@ -130,6 +152,23 @@ class StationFilterState {
|
|||||||
|
|
||||||
fun isOn(filter: StationFilter): Boolean = switched[filter] == true
|
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 setHideWorkedOn(band: WorkedBandFilter, hide: Boolean) = switch(band.filter, hide)
|
||||||
|
|
||||||
fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter)
|
fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter)
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
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,5 +1,6 @@
|
|||||||
package kst4contest.view.compose.map
|
package kst4contest.view.compose.map
|
||||||
|
|
||||||
|
import kst4contest.view.compose.WindowPlacementOnScreen
|
||||||
import kst4contest.view.i18n.LocalStrings
|
import kst4contest.view.i18n.LocalStrings
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -10,6 +11,7 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -59,10 +61,14 @@ object StationMapWindow {
|
|||||||
heightDp = heightDp,
|
heightDp = heightDp,
|
||||||
onResized = onResized,
|
onResized = onResized,
|
||||||
) { _ ->
|
) { _ ->
|
||||||
val strings = LocalStrings.current
|
|
||||||
|
|
||||||
val window = this.window
|
val window = this.window
|
||||||
androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) }
|
/*
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
val tileFetcher = remember { TileFetcher() }
|
val tileFetcher = remember { TileFetcher() }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -74,6 +80,37 @@ object StationMapWindow {
|
|||||||
onDispose { tileFetcher.close() }
|
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
|
* 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
|
* own. Polled rather than wired through a callback: this drives one line of
|
||||||
@@ -99,7 +136,19 @@ object StationMapWindow {
|
|||||||
val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg
|
val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg
|
||||||
val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault
|
val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault
|
||||||
|
|
||||||
val markers by remember(mainWindowState.stations.rows, mainWindowState.stations.contentRevision, selectedMember, mainWindowState.activeBands) {
|
/*
|
||||||
|
* 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,
|
||||||
|
) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val builder = MapCallsignRawSnapshotBuilder()
|
val builder = MapCallsignRawSnapshotBuilder()
|
||||||
val activeBands = mainWindowState.activeBands
|
val activeBands = mainWindowState.activeBands
|
||||||
@@ -110,7 +159,7 @@ object StationMapWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
builder.buildSnapshots(
|
builder.buildSnapshots(
|
||||||
mainWindowState.stations.rows,
|
mainWindowState.stations.visibleRows,
|
||||||
selectedMember,
|
selectedMember,
|
||||||
bandSet
|
bandSet
|
||||||
)
|
)
|
||||||
@@ -118,19 +167,33 @@ object StationMapWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) }
|
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) }
|
||||||
val mapState = remember { MapState() }
|
|
||||||
|
|
||||||
LaunchedEffect(selectedMember) {
|
// 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) {
|
||||||
if (selectedMember == null) {
|
if (selectedMember == null) {
|
||||||
pathAnalysisResult = null
|
pathAnalysisResult = null
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
|
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
|
||||||
if (chatController != null) {
|
if (selectedSnapshot == null) {
|
||||||
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
|
// No marker: no request, and no "Loading" for a station the map does not show.
|
||||||
|
pathAnalysisResult = null
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) {
|
val analysis = currentPathAnalysis()
|
||||||
|
if (analysis != null) {
|
||||||
|
if (selectedSnapshot.hasUsablePosition()) {
|
||||||
/*
|
/*
|
||||||
* Pan to the station and leave the zoom alone. The Leaflet map
|
* Pan to the station and leave the zoom alone. The Leaflet map
|
||||||
* did exactly this (focusCallsignRaw) and touched the zoom only
|
* did exactly this (focusCallsignRaw) and touched the zoom only
|
||||||
@@ -151,17 +214,11 @@ object StationMapWindow {
|
|||||||
|
|
||||||
pathAnalysisResult = PathAnalysisResult.loading(
|
pathAnalysisResult = PathAnalysisResult.loading(
|
||||||
prefs.stn_loginLocatorMainCat,
|
prefs.stn_loginLocatorMainCat,
|
||||||
selectedSnapshot?.locator6() ?: "",
|
selectedSnapshot.locator6(),
|
||||||
selectedSnapshot?.callSignRaw() ?: ""
|
selectedSnapshot.callSignRaw()
|
||||||
)
|
)
|
||||||
|
|
||||||
val overrideBand: kst4contest.model.Band? = null
|
analysis.request(selectedMember, selectedSnapshot) { result ->
|
||||||
|
|
||||||
chatController.reachabilityService.requestPathAnalysisForMap(
|
|
||||||
selectedMember,
|
|
||||||
selectedSnapshot,
|
|
||||||
overrideBand
|
|
||||||
) { result ->
|
|
||||||
pathAnalysisResult = result
|
pathAnalysisResult = result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,10 +334,8 @@ object StationMapWindow {
|
|||||||
maxQrbKm = maxQrbKm,
|
maxQrbKm = maxQrbKm,
|
||||||
darkMode = isDarkTheme,
|
darkMode = isDarkTheme,
|
||||||
onMarkerClicked = { snapshot ->
|
onMarkerClicked = { snapshot ->
|
||||||
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
|
val variants = chatMembers()
|
||||||
val variants = chatController?.lst_chatMemberList?.snapshot()
|
.filter { it.callSignRaw == snapshot.callSignRaw() }
|
||||||
?.filter { it.callSignRaw == snapshot.callSignRaw() }
|
|
||||||
.orEmpty()
|
|
||||||
/*
|
/*
|
||||||
* A marker stands for a raw callsign, not a category. Keep the
|
* A marker stands for a raw callsign, not a category. Keep the
|
||||||
* category already selected when it is one of the variants.
|
* category already selected when it is one of the variants.
|
||||||
@@ -298,7 +353,7 @@ object StationMapWindow {
|
|||||||
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
|
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize().testTag(MAP_CANVAS_TAG)
|
||||||
)
|
)
|
||||||
|
|
||||||
MapOverlayControls(
|
MapOverlayControls(
|
||||||
@@ -361,8 +416,10 @@ object StationMapWindow {
|
|||||||
} // main layout column
|
} // main layout column
|
||||||
} // window column
|
} // window column
|
||||||
} // Surface
|
} // Surface
|
||||||
} // host.show content
|
} // Content
|
||||||
}
|
|
||||||
|
/** Marks the map canvas so a test can click a marker on it. */
|
||||||
|
internal const val MAP_CANVAS_TAG = "station-map-canvas"
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun applyDarkMode(darkMode: Boolean) {
|
fun applyDarkMode(darkMode: Boolean) {
|
||||||
@@ -397,3 +454,34 @@ 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.
|
* The Airscout tab: the UDP link to AirScout and how the queried frequency is chosen.
|
||||||
*
|
*
|
||||||
* Labels, defaults and the closing note are the JavaFX ones, including that the server
|
* Labels and defaults are the JavaFX ones. The closing note says that the server
|
||||||
* identifier, the client identifier and the frequency mode take effect at once while a
|
* identifier, the client identifier and the frequency mode take effect when applied while a
|
||||||
* changed UDP port needs a reconnect.
|
* changed UDP port needs a reconnect.
|
||||||
*
|
*
|
||||||
* The identifier, port and band fields keep what the operator typed and mark it as an
|
* The identifier, port and band fields keep what the operator typed and mark it as an
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Airscout tab as a typed facade over ChatPreferences.
|
* The Airscout tab as a typed facade over ChatPreferences.
|
||||||
*
|
*
|
||||||
* Every property writes through immediately and there is deliberately no apply(), the
|
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||||
* way the JavaFX controls behaved.
|
* applies them.
|
||||||
*
|
*
|
||||||
* Three properties are guarded before the write, and the guard is not decoration. The
|
* Three properties are guarded before they are staged, and the guard is not decoration. The
|
||||||
* ChatPreferences setters do not keep the previous value on bad input, they substitute
|
* 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
|
* 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
|
* becomes 9872, a band value that is not a positive number becomes "1440000". The
|
||||||
@@ -19,27 +21,48 @@ import kst4contest.model.ChatPreferences
|
|||||||
* The accepted ranges are the JavaFX ones, unchanged: AirScout ports and the band
|
* 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.
|
* value are protocol values and neither the defaults nor the bounds are touched.
|
||||||
*/
|
*/
|
||||||
class AirscoutTabState(private val prefs: ChatPreferences) {
|
class AirscoutTabState(private val prefs: ChatPreferences, private val draft: SettingsDraft) {
|
||||||
|
|
||||||
var airScout_asUDPListenerEnabled: Boolean
|
var airScout_asUDPListenerEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isAirScout_asUDPListenerEnabled()
|
{ prefs.isAirScout_asUDPListenerEnabled() },
|
||||||
set(value) { prefs.setAirScout_asUDPListenerEnabled(value) }
|
) { 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) }
|
||||||
|
|
||||||
/** Logical identifier of the target AirScout server; invalid input is ignored. */
|
/** Logical identifier of the target AirScout server; invalid input is ignored. */
|
||||||
var airScout_asServerNameString: String
|
var airScout_asServerNameString: String
|
||||||
get() = prefs.getAirScout_asServerNameString()
|
get() = serverName
|
||||||
set(value) {
|
set(value) {
|
||||||
if (isValidIdentifier(value)) {
|
if (isValidIdentifier(value)) {
|
||||||
prefs.setAirScout_asServerNameString(value.trim())
|
serverName = value.trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Identifier of this KST4Contest instance; invalid input is ignored. */
|
/** Identifier of this KST4Contest instance; invalid input is ignored. */
|
||||||
var airScout_asClientNameString: String
|
var airScout_asClientNameString: String
|
||||||
get() = prefs.getAirScout_asClientNameString()
|
get() = clientName
|
||||||
set(value) {
|
set(value) {
|
||||||
if (isValidIdentifier(value)) {
|
if (isValidIdentifier(value)) {
|
||||||
prefs.setAirScout_asClientNameString(value.trim())
|
clientName = value.trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,10 +72,10 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
|
|||||||
* that changes here.
|
* that changes here.
|
||||||
*/
|
*/
|
||||||
var airScout_asCommunicationPort: Int
|
var airScout_asCommunicationPort: Int
|
||||||
get() = prefs.getAirScout_asCommunicationPort()
|
get() = communicationPort
|
||||||
set(value) {
|
set(value) {
|
||||||
if (isValidPort(value)) {
|
if (isValidPort(value)) {
|
||||||
prefs.setAirScout_asCommunicationPort(value)
|
communicationPort = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +83,9 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
|
|||||||
* Pick the AirScout frequency per station instead of forcing one value. Uses the
|
* 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.
|
* station's current QRG first, then station-name and chat-category evidence.
|
||||||
*/
|
*/
|
||||||
var airScout_autoBandSelectionEnabled: Boolean
|
var airScout_autoBandSelectionEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isAirScout_autoBandSelectionEnabled()
|
{ prefs.isAirScout_autoBandSelectionEnabled() },
|
||||||
set(value) { prefs.setAirScout_autoBandSelectionEnabled(value) }
|
) { prefs.setAirScout_autoBandSelectionEnabled(it) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The forced AirScout band value, a protocol number such as 1440000 for 144 MHz.
|
* The forced AirScout band value, a protocol number such as 1440000 for 144 MHz.
|
||||||
@@ -70,9 +93,9 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
|
|||||||
* value produced in the JavaFX field.
|
* value produced in the JavaFX field.
|
||||||
*/
|
*/
|
||||||
var airScout_asBandString: String
|
var airScout_asBandString: String
|
||||||
get() = prefs.getAirScout_asBandString()
|
get() = bandString
|
||||||
set(value) {
|
set(value) {
|
||||||
parseBandValue(value)?.let { prefs.setAirScout_asBandString(it) }
|
parseBandValue(value)?.let { bandString = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
|
|||||||
stored = state.beaconTextMainCat,
|
stored = state.beaconTextMainCat,
|
||||||
commit = state::commitBeaconTextMainCat,
|
commit = state::commitBeaconTextMainCat,
|
||||||
onRefused = onRefused,
|
onRefused = onRefused,
|
||||||
|
current = { state.beaconTextMainCat },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
|
|||||||
stored = state.beaconTextSecondCat,
|
stored = state.beaconTextSecondCat,
|
||||||
commit = state::commitBeaconTextSecondCat,
|
commit = state::commitBeaconTextSecondCat,
|
||||||
onRefused = onRefused,
|
onRefused = onRefused,
|
||||||
|
current = { state.beaconTextSecondCat },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +54,12 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
|
|||||||
stored = state.beaconIntervalMinutes.toString(),
|
stored = state.beaconIntervalMinutes.toString(),
|
||||||
commit = state::commitBeaconInterval,
|
commit = state::commitBeaconInterval,
|
||||||
onRefused = onRefused,
|
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,6 +2,9 @@ package kst4contest.view.compose.tabs
|
|||||||
|
|
||||||
import kst4contest.controller.ChatController
|
import kst4contest.controller.ChatController
|
||||||
import kst4contest.model.ChatPreferences
|
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.
|
* State of the beacon tab: the two CQ templates and the one interval they share.
|
||||||
@@ -9,34 +12,57 @@ import kst4contest.model.ChatPreferences
|
|||||||
* The two commit methods are the ports of `applyBeaconTextSetting` and
|
* The two commit methods are the ports of `applyBeaconTextSetting` and
|
||||||
* `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they
|
* `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
|
* return the refusal text instead, so the tab decides how to show it and a test can
|
||||||
* read it.
|
* 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.
|
||||||
*
|
*
|
||||||
* @param validateBeaconTemplate throws IllegalArgumentException with the reason,
|
* @param validateBeaconTemplate throws IllegalArgumentException with the reason,
|
||||||
* the contract of `ChatController.validateBeaconTemplate`.
|
* the contract of `ChatController.validateBeaconTemplate`.
|
||||||
* @param restartBeaconTimer called only after the interval actually changed; the
|
* @param restartBeaconTimer called when a changed interval is applied; the JavaFX code
|
||||||
* JavaFX code restarted the shared timer there and nowhere else.
|
* restarted the shared timer there and nowhere else.
|
||||||
*/
|
*/
|
||||||
class BeaconTabState(
|
class BeaconTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
private val validateBeaconTemplate: (String) -> Unit,
|
private val validateBeaconTemplate: (String) -> Unit,
|
||||||
private val restartBeaconTimer: () -> Unit,
|
private val restartBeaconTimer: () -> Unit,
|
||||||
val mainCategoryName: String,
|
val mainCategoryName: String,
|
||||||
val secondCategoryName: String,
|
val secondCategoryName: String,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
var bcn_beaconsEnabledMainCat: Boolean
|
var bcn_beaconsEnabledMainCat: Boolean by draft.setting(
|
||||||
get() = prefs.isBcn_beaconsEnabledMainCat()
|
{ prefs.isBcn_beaconsEnabledMainCat() },
|
||||||
set(value) { prefs.setBcn_beaconsEnabledMainCat(value) }
|
) { prefs.setBcn_beaconsEnabledMainCat(it) }
|
||||||
|
|
||||||
var bcn_beaconsEnabledSecondCat: Boolean
|
var bcn_beaconsEnabledSecondCat: Boolean by draft.setting(
|
||||||
get() = prefs.isBcn_beaconsEnabledSecondCat()
|
{ prefs.isBcn_beaconsEnabledSecondCat() },
|
||||||
set(value) { prefs.setBcn_beaconsEnabledSecondCat(value) }
|
) { 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)
|
||||||
|
}
|
||||||
|
|
||||||
val beaconTextMainCat: String
|
val beaconTextMainCat: String
|
||||||
get() = prefs.getBcn_beaconTextMainCat()
|
get() = textMainCat
|
||||||
|
|
||||||
val beaconTextSecondCat: String
|
val beaconTextSecondCat: String
|
||||||
get() = prefs.getBcn_beaconTextSecondCat()
|
get() = textSecondCat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The interval both categories share, never below the minimum.
|
* The interval both categories share, never below the minimum.
|
||||||
@@ -49,16 +75,16 @@ class BeaconTabState(
|
|||||||
val beaconIntervalMinutes: Int
|
val beaconIntervalMinutes: Int
|
||||||
get() = maxOf(
|
get() = maxOf(
|
||||||
ChatController.MIN_BEACON_INTERVAL_MINUTES,
|
ChatController.MIN_BEACON_INTERVAL_MINUTES,
|
||||||
prefs.getBcn_beaconIntervalInMinutesMainCat(),
|
interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** @return null when stored, otherwise the reason it was refused. */
|
/** @return null when staged, otherwise the reason it was refused. */
|
||||||
fun commitBeaconTextMainCat(template: String): String? =
|
fun commitBeaconTextMainCat(template: String): String? =
|
||||||
commitBeaconText(template) { prefs.setBcn_beaconTextMainCat(it) }
|
commitBeaconText(template) { textMainCat = it }
|
||||||
|
|
||||||
/** @return null when stored, otherwise the reason it was refused. */
|
/** @return null when staged, otherwise the reason it was refused. */
|
||||||
fun commitBeaconTextSecondCat(template: String): String? =
|
fun commitBeaconTextSecondCat(template: String): String? =
|
||||||
commitBeaconText(template) { prefs.setBcn_beaconTextSecondCat(it) }
|
commitBeaconText(template) { textSecondCat = it }
|
||||||
|
|
||||||
private fun commitBeaconText(template: String, store: (String) -> Unit): String? {
|
private fun commitBeaconText(template: String, store: (String) -> Unit): String? {
|
||||||
try {
|
try {
|
||||||
@@ -69,7 +95,7 @@ class BeaconTabState(
|
|||||||
* so a refused template that replaced a working one would silently stop
|
* so a refused template that replaced a working one would silently stop
|
||||||
* the beacon instead of reporting anything.
|
* the beacon instead of reporting anything.
|
||||||
*/
|
*/
|
||||||
return "The beacon message is invalid: " + refusal.message
|
return CurrentStrings.get().beaconInvalidMessage(refusal.message.orEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
store(template)
|
store(template)
|
||||||
@@ -77,28 +103,18 @@ class BeaconTabState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the shared interval to both categories and restarts the timer.
|
* Stages the shared interval for both categories; applying it restarts the timer.
|
||||||
*
|
*
|
||||||
* @return null when applied, otherwise the reason it was refused.
|
* @return null when staged, otherwise the reason it was refused.
|
||||||
*/
|
*/
|
||||||
fun commitBeaconInterval(entered: String): String? {
|
fun commitBeaconInterval(entered: String): String? {
|
||||||
val minutes = entered.trim().toIntOrNull()
|
val minutes = entered.trim().toIntOrNull()
|
||||||
|
|
||||||
if (minutes == null || minutes < ChatController.MIN_BEACON_INTERVAL_MINUTES) {
|
if (minutes == null || minutes < ChatController.MIN_BEACON_INTERVAL_MINUTES) {
|
||||||
return "Enter a whole beacon interval of at least " +
|
return CurrentStrings.get().beaconIntervalTooShort(ChatController.MIN_BEACON_INTERVAL_MINUTES.toString())
|
||||||
ChatController.MIN_BEACON_INTERVAL_MINUTES + " minute."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
|
interval = 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
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +119,13 @@ fun ColoursTab(
|
|||||||
stored = row.hex,
|
stored = row.hex,
|
||||||
commit = { typed -> state.set(row.role, typed) },
|
commit = { typed -> state.set(row.role, typed) },
|
||||||
onRefused = reportRefusal,
|
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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-12
@@ -12,13 +12,14 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.AwtWindow
|
import androidx.compose.ui.window.AwtWindow
|
||||||
import java.awt.FileDialog
|
import java.awt.FileDialog
|
||||||
@@ -36,8 +37,7 @@ import java.io.File
|
|||||||
* back on bad input. Writing through on every keystroke without that guard would be
|
* 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
|
* worse than the old behaviour, because the ChatPreferences setters for the AirScout
|
||||||
* identifiers, the AirScout port and the AirScout band value substitute the factory
|
* identifiers, the AirScout port and the AirScout band value substitute the factory
|
||||||
* default on bad input instead of keeping the previous value. It also reports focus
|
* default on bad input instead of keeping the previous value.
|
||||||
* 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.
|
* - [FilePathRow] is the read-only path plus "Choose..." button of the Log synch tab.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -47,7 +47,6 @@ internal fun GuardedTextField(
|
|||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
accepts: (String) -> Boolean = { true },
|
accepts: (String) -> Boolean = { true },
|
||||||
onAccepted: (String) -> Unit,
|
onAccepted: (String) -> Unit,
|
||||||
onCommit: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
/*
|
/*
|
||||||
* Keyed on the stored value: the draft is only reset when the stored value really
|
* Keyed on the stored value: the draft is only reset when the stored value really
|
||||||
@@ -55,7 +54,21 @@ internal fun GuardedTextField(
|
|||||||
* value such as a trailing space that is trimmed away, leaves the typed text alone.
|
* value such as a trailing space that is trimmed away, leaves the typed text alone.
|
||||||
*/
|
*/
|
||||||
var draft by remember(value) { mutableStateOf(value) }
|
var draft by remember(value) { mutableStateOf(value) }
|
||||||
var hadFocus by remember { mutableStateOf(false) }
|
|
||||||
|
/*
|
||||||
|
* 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() }
|
||||||
|
}
|
||||||
|
|
||||||
LabelledRow(label) {
|
LabelledRow(label) {
|
||||||
CompactTextField(
|
CompactTextField(
|
||||||
@@ -68,12 +81,7 @@ internal fun GuardedTextField(
|
|||||||
},
|
},
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
isError = !accepts(draft),
|
isError = !accepts(draft),
|
||||||
modifier = Modifier.fillMaxWidth().onFocusChanged { focusState ->
|
modifier = Modifier.fillMaxWidth(),
|
||||||
if (hadFocus && !focusState.isFocused) {
|
|
||||||
onCommit()
|
|
||||||
}
|
|
||||||
hadFocus = focusState.isFocused
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +111,7 @@ internal fun FilePathRow(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Form.button("Choose...") { choosing = true }
|
Form.button(kst4contest.view.i18n.LocalStrings.current.formChoose) { choosing = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,31 +25,31 @@ fun GuiOptionsTab(state: GuiOptionsTabState) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
Form.section(strings.guiStartupDesign) {
|
Form.section(strings.guiStartupDesign) {
|
||||||
Form.check("Start in the evening design", state.gUI_darkModeActiveByDefault) {
|
Form.check(strings.guiStartInEveningDesign, state.gUI_darkModeActiveByDefault) {
|
||||||
state.gUI_darkModeActiveByDefault = it
|
state.gUI_darkModeActiveByDefault = it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Form.flowingSection(strings.guiStartupFilters) {
|
Form.flowingSection(strings.guiStartupFilters) {
|
||||||
Form.check("Public messages", state.guiOptions_defaultFilterPublicMsgs) {
|
Form.check(strings.guiFilterPublic, state.guiOptions_defaultFilterPublicMsgs) {
|
||||||
state.guiOptions_defaultFilterPublicMsgs = it
|
state.guiOptions_defaultFilterPublicMsgs = it
|
||||||
}
|
}
|
||||||
Form.check("Private messages to me", state.guiOptions_defaultFilterPmToMe) {
|
Form.check(strings.guiFilterPmToMe, state.guiOptions_defaultFilterPmToMe) {
|
||||||
state.guiOptions_defaultFilterPmToMe = it
|
state.guiOptions_defaultFilterPmToMe = it
|
||||||
}
|
}
|
||||||
Form.check("Private messages to others", state.guiOptions_defaultFilterPmToOther) {
|
Form.check(strings.guiFilterPmToOthers, state.guiOptions_defaultFilterPmToOther) {
|
||||||
state.guiOptions_defaultFilterPmToOther = it
|
state.guiOptions_defaultFilterPmToOther = it
|
||||||
}
|
}
|
||||||
Form.check("No filter", state.guiOptions_defaultFilterNothing) {
|
Form.check(strings.guiFilterNothing, state.guiOptions_defaultFilterNothing) {
|
||||||
state.guiOptions_defaultFilterNothing = it
|
state.guiOptions_defaultFilterNothing = it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Form.flowingSection(strings.guiBandColumnHints) {
|
Form.flowingSection(strings.guiBandColumnHints) {
|
||||||
Form.check("Mark a callsign seen for the first time", state.guiOptions_showFreshCallHintInBandColumns) {
|
Form.check(strings.guiHintFreshCall, state.guiOptions_showFreshCallHintInBandColumns) {
|
||||||
state.guiOptions_showFreshCallHintInBandColumns = it
|
state.guiOptions_showFreshCallHintInBandColumns = it
|
||||||
}
|
}
|
||||||
Form.check("Mark a worked large grid field", state.guiOptions_showGrossFieldWorkedHintInBandColumns) {
|
Form.check(strings.guiHintLargeGridWorked, state.guiOptions_showGrossFieldWorkedHintInBandColumns) {
|
||||||
state.guiOptions_showGrossFieldWorkedHintInBandColumns = it
|
state.guiOptions_showGrossFieldWorkedHintInBandColumns = it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,5 +76,12 @@ 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,42 +1,34 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
import kst4contest.view.i18n.LanguageStore
|
import kst4contest.view.i18n.LanguageStore
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The GUI tab as a typed facade over ChatPreferences.
|
* The GUI tab as a typed facade over ChatPreferences.
|
||||||
*
|
*
|
||||||
* Writes through immediately and has no apply(), like every settings tab: the
|
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||||
* JavaFX controls wrote as they changed and "Save settings" only persisted.
|
* applies them. The language is the exception: it switches at once, because the switch is
|
||||||
|
* itself what the operator wants to see.
|
||||||
*/
|
*/
|
||||||
class GuiOptionsTabState(
|
class GuiOptionsTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
private val languageStore: LanguageStore,
|
private val languageStore: LanguageStore,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
var gUI_darkModeActiveByDefault: Boolean
|
var gUI_darkModeActiveByDefault: Boolean by draft.setting({ prefs.isGUI_darkModeActiveByDefault() }) { prefs.setGUI_darkModeActiveByDefault(it) }
|
||||||
get() = prefs.isGUI_darkModeActiveByDefault()
|
|
||||||
set(value) { prefs.setGUI_darkModeActiveByDefault(value) }
|
|
||||||
|
|
||||||
var guiOptions_defaultFilterNothing: Boolean
|
var guiOptions_defaultFilterNothing: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterNothing() }) { prefs.setGuiOptions_defaultFilterNothing(it) }
|
||||||
get() = prefs.isGuiOptions_defaultFilterNothing()
|
|
||||||
set(value) { prefs.setGuiOptions_defaultFilterNothing(value) }
|
|
||||||
|
|
||||||
var guiOptions_defaultFilterPmToMe: Boolean
|
var guiOptions_defaultFilterPmToMe: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToMe() }) { prefs.setGuiOptions_defaultFilterPmToMe(it) }
|
||||||
get() = prefs.isGuiOptions_defaultFilterPmToMe()
|
|
||||||
set(value) { prefs.setGuiOptions_defaultFilterPmToMe(value) }
|
|
||||||
|
|
||||||
var guiOptions_defaultFilterPmToOther: Boolean
|
var guiOptions_defaultFilterPmToOther: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToOther() }) { prefs.setGuiOptions_defaultFilterPmToOther(it) }
|
||||||
get() = prefs.isGuiOptions_defaultFilterPmToOther()
|
|
||||||
set(value) { prefs.setGuiOptions_defaultFilterPmToOther(value) }
|
|
||||||
|
|
||||||
var guiOptions_defaultFilterPublicMsgs: Boolean
|
var guiOptions_defaultFilterPublicMsgs: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPublicMsgs() }) { prefs.setGuiOptions_defaultFilterPublicMsgs(it) }
|
||||||
get() = prefs.isGuiOptions_defaultFilterPublicMsgs()
|
|
||||||
set(value) { prefs.setGuiOptions_defaultFilterPublicMsgs(value) }
|
|
||||||
|
|
||||||
var guiOptions_showFreshCallHintInBandColumns: Boolean
|
var guiOptions_showFreshCallHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showFreshCallHintInBandColumns() }) { prefs.setGuiOptions_showFreshCallHintInBandColumns(it) }
|
||||||
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
|
* The interface language, as the picker offers it: a language code, or the empty string
|
||||||
@@ -50,8 +42,8 @@ class GuiOptionsTabState(
|
|||||||
get() = languageStore.stored
|
get() = languageStore.stored
|
||||||
set(value) { languageStore.use(value) }
|
set(value) { languageStore.use(value) }
|
||||||
|
|
||||||
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean
|
var guiOptions_rememberWindowPositions: Boolean by draft.setting({ prefs.isGuiOptions_rememberWindowPositions() }) { prefs.setGuiOptions_rememberWindowPositions(it) }
|
||||||
get() = prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns()
|
|
||||||
set(value) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(value) }
|
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns() }) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(it) }
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -17,8 +17,8 @@ import kst4contest.view.compose.Form
|
|||||||
* The Log synch tab: how worked callsigns reach KST4Contest — by polling a log file, by
|
* 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.
|
* UDP from N1MM+/QARTEST/UCXLog/DXLog, and by the Win-Test network.
|
||||||
*
|
*
|
||||||
* No confirmation callback: LogSynchTabState writes through to ChatPreferences, the way
|
* The controls stage their edits in the window's draft (see LogSynchTabState); the file
|
||||||
* the JavaFX controls did. "Save settings" persists, it does not collect.
|
* chooser writes the chosen path at once.
|
||||||
*
|
*
|
||||||
* Labels, units and default hints are the JavaFX ones. The Win-Test port field is
|
* 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.
|
* disabled while its listener is off, as it was there.
|
||||||
@@ -75,9 +75,8 @@ fun LogSynchTab(state: LogSynchTabState) {
|
|||||||
value = state.logsynch_wintestNetworkPort.toString(),
|
value = state.logsynch_wintestNetworkPort.toString(),
|
||||||
enabled = state.logsynch_wintestNetworkListenerEnabled,
|
enabled = state.logsynch_wintestNetworkListenerEnabled,
|
||||||
accepts = { it.trim().toIntOrNull() != null },
|
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++ },
|
onAccepted = { state.logsynch_wintestNetworkPort = it.trim().toInt(); revision++ },
|
||||||
// Rebinding the socket belongs to the finished entry, not to every digit.
|
|
||||||
onCommit = { state.commitWintestNetworkPort() },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
GuardedTextField(
|
GuardedTextField(
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Log synch tab as a typed facade over ChatPreferences.
|
* The Log synch tab as a typed facade over ChatPreferences.
|
||||||
*
|
*
|
||||||
* Every property writes through immediately, and there is deliberately no apply().
|
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||||
* That mirrors the JavaFX window it replaces: its controls each wrote into
|
* applies them. The log file path is the exception: it changes through a file chooser,
|
||||||
* ChatPreferences the moment they changed, and its two buttons only persisted
|
* an action, and is written when chosen.
|
||||||
* ("Save settings" calls writePreferencesToXmlFile) or closed the window.
|
|
||||||
*
|
*
|
||||||
* Two things this tab controlled are not settings at all but effects on the running
|
* One thing this tab controls is not a setting at all but an effect on the running session,
|
||||||
* session, and no coverage test can see them because they are not ChatPreferences
|
* and no coverage test can see it because it is not a ChatPreferences setter: the Win-Test
|
||||||
* setters. They are taken as callbacks rather than from a ChatController so the rules
|
* UDP listener is started, stopped or rebound when its switch or its port is applied -- once
|
||||||
* stay testable without opening a database:
|
* per apply, see [applyWintestListener]. It is taken as callbacks rather than from a
|
||||||
|
* ChatController so the rules stay testable without opening a database.
|
||||||
*
|
*
|
||||||
* - the Win-Test UDP listener is started and stopped with its setting;
|
* The broadcast address the JavaFX window auto-detected while building the pane is detected
|
||||||
* - the Win-Test broadcast address is auto-detected while it is still the catch-all
|
* by repairStoredSettings now, before the window is built, not by this state.
|
||||||
* default, which the JavaFX window did while building the settings pane.
|
|
||||||
*
|
*
|
||||||
* Ports, transports and framing are untouched: the accepted values, the defaults and
|
* 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.
|
* the moment a socket is rebound are the ones the JavaFX window had.
|
||||||
@@ -25,23 +26,17 @@ import kst4contest.model.ChatPreferences
|
|||||||
* @param restartWintestListener rebinds the Win-Test UDP listener; maps to
|
* @param restartWintestListener rebinds the Win-Test UDP listener; maps to
|
||||||
* ChatController.restartWintestUdpListenerIfEnabled()
|
* ChatController.restartWintestUdpListenerIfEnabled()
|
||||||
* @param stopWintestListener maps to ChatController.stopWintestUdpListener()
|
* @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(
|
class LogSynchTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
private val restartWintestListener: () -> Unit,
|
private val restartWintestListener: () -> Unit,
|
||||||
private val stopWintestListener: () -> Unit,
|
private val stopWintestListener: () -> Unit,
|
||||||
private val detectWintestBroadcastAddress: () -> String?,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
init {
|
var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean by draft.setting(
|
||||||
autoDetectBroadcastAddressWhileStillDefault()
|
{ prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled() },
|
||||||
}
|
) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(it) }
|
||||||
|
|
||||||
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
|
* The log file polled for worked callsigns. The JavaFX tab showed it read-only and
|
||||||
@@ -53,75 +48,73 @@ class LogSynchTabState(
|
|||||||
get() = prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()
|
get() = prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()
|
||||||
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(value) }
|
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(value) }
|
||||||
|
|
||||||
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean
|
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isLogsynch_ucxUDPWkdCallListenerEnabled()
|
{ prefs.isLogsynch_ucxUDPWkdCallListenerEnabled() },
|
||||||
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(value) }
|
) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(it) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared UDP port for QSO and TRX messages, default 12060. The JavaFX field
|
* 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 —
|
* accepted any number here and rebound nothing — that listener comes up at start —
|
||||||
* so neither a range nor a restart is added.
|
* so neither a range nor a restart is added.
|
||||||
*/
|
*/
|
||||||
var logsynch_ucxUDPWkdCallListenerPort: Int
|
var logsynch_ucxUDPWkdCallListenerPort: Int by draft.setting(
|
||||||
get() = prefs.getLogsynch_ucxUDPWkdCallListenerPort()
|
{ prefs.getLogsynch_ucxUDPWkdCallListenerPort() },
|
||||||
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(value) }
|
) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(it) }
|
||||||
|
|
||||||
var logsynch_wintestNetworkListenerEnabled: Boolean
|
var logsynch_wintestNetworkListenerEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isLogsynch_wintestNetworkListenerEnabled()
|
{ prefs.isLogsynch_wintestNetworkListenerEnabled() },
|
||||||
set(value) {
|
effectKey = WINTEST_LISTENER,
|
||||||
prefs.setLogsynch_wintestNetworkListenerEnabled(value)
|
effect = ::applyWintestListener,
|
||||||
if (value) restartWintestListener() else stopWintestListener()
|
) { prefs.setLogsynch_wintestNetworkListenerEnabled(it) }
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UDP port for the Win-Test listener, default 9871. The setting is written at once
|
* UDP port for the Win-Test listener, default 9871. The socket is rebound when the port
|
||||||
* like every other, but the socket is only rebound by [commitWintestNetworkPort] —
|
* is applied, not per keystroke: rebinding per digit would bind and unbind a UDP port
|
||||||
* the JavaFX field did that on focus loss, and rebinding per keystroke would bind
|
* for every number on the way to the intended one.
|
||||||
* and unbind a UDP port for every digit on the way to the intended one.
|
|
||||||
*/
|
*/
|
||||||
var logsynch_wintestNetworkPort: Int
|
var logsynch_wintestNetworkPort: Int by draft.setting(
|
||||||
get() = prefs.getLogsynch_wintestNetworkPort()
|
{ prefs.getLogsynch_wintestNetworkPort() },
|
||||||
set(value) { prefs.setLogsynch_wintestNetworkPort(value) }
|
effectKey = WINTEST_LISTENER,
|
||||||
|
effect = ::applyWintestListener,
|
||||||
|
) { prefs.setLogsynch_wintestNetworkPort(it) }
|
||||||
|
|
||||||
/** Applies the entered port to the running listener, if there is one. */
|
/*
|
||||||
fun commitWintestNetworkPort() {
|
* The two trimmed values are staged trimmed, so the draft holds what will be stored and a
|
||||||
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) {
|
* trailing space does not count as a change.
|
||||||
restartWintestListener()
|
*/
|
||||||
}
|
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) }
|
||||||
|
|
||||||
/** The name KST4Contest uses as source of Win-Test SKED packets. */
|
/** The name KST4Contest uses as source of Win-Test SKED packets. */
|
||||||
var logsynch_wintestNetworkStationNameOfKST: String
|
var logsynch_wintestNetworkStationNameOfKST: String
|
||||||
get() = prefs.getLogsynch_wintestNetworkStationNameOfKST()
|
get() = stationNameOfKst
|
||||||
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfKST(value.trim()) }
|
set(value) { stationNameOfKst = value.trim() }
|
||||||
|
|
||||||
/** UDP broadcast address used for sending to Win-Test. */
|
/** UDP broadcast address used for sending to Win-Test. */
|
||||||
var logsynch_wintestNetworkBroadcastAddress: String
|
var logsynch_wintestNetworkBroadcastAddress: String
|
||||||
get() = prefs.getLogsynch_wintestNetworkBroadcastAddress()
|
get() = broadcastAddress
|
||||||
set(value) { prefs.setLogsynch_wintestNetworkBroadcastAddress(value.trim()) }
|
set(value) { broadcastAddress = value.trim() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces the catch-all default 255.255.255.255 with the detected subnet
|
* Brings the Win-Test listener in line with the applied switch and port: rebound while
|
||||||
* broadcast address. An address the operator or a previous detection chose is
|
* enabled, stopped otherwise. The switch and the port share it, so applying both runs it
|
||||||
* left alone, and a failed detection keeps the default: guessing an address here
|
* once and binds the new port; a port changed while the listener is off meets a listener
|
||||||
* would send SKED packets somewhere nobody is listening.
|
* that is not running, and stopping that does nothing.
|
||||||
*/
|
*/
|
||||||
private fun autoDetectBroadcastAddressWhileStillDefault() {
|
private fun applyWintestListener() {
|
||||||
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() != DEFAULT_BROADCAST_ADDRESS) {
|
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) restartWintestListener() else stopWintestListener()
|
||||||
return
|
|
||||||
}
|
|
||||||
val detected = try {
|
|
||||||
detectWintestBroadcastAddress()
|
|
||||||
} catch (probeFailure: RuntimeException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (detected != null && detected.isNotBlank()) {
|
|
||||||
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The stored value that still means "not configured for this network". */
|
/** The stored value that still means "not configured for this network". */
|
||||||
const val DEFAULT_BROADCAST_ADDRESS: String = "255.255.255.255"
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+25
-21
@@ -1,6 +1,8 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State of the message-handling tab.
|
* State of the message-handling tab.
|
||||||
@@ -10,42 +12,44 @@ import kst4contest.model.ChatPreferences
|
|||||||
* (Kst4ContestApplication:12004 and :12039). Exposing one property per pair keeps
|
* (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
|
* 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.
|
* 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(
|
class MessageHandlingTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
private val applyDebugModeToFile: (Boolean) -> Unit = {},
|
private val applyDebugModeToFile: (Boolean) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Drives both category flags, as the single JavaFX checkbox did. */
|
/** Drives both category flags, as the single JavaFX checkbox did. */
|
||||||
var autoAnswerEnabled: Boolean
|
var autoAnswerEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isMessageHandling_autoAnswerEnabled()
|
{ prefs.isMessageHandling_autoAnswerEnabled() },
|
||||||
set(value) {
|
) { enabled ->
|
||||||
prefs.setMessageHandling_autoAnswerEnabled(value)
|
prefs.setMessageHandling_autoAnswerEnabled(enabled)
|
||||||
prefs.setMessageHandling_autoAnswerEnabledSecondCat(value)
|
prefs.setMessageHandling_autoAnswerEnabledSecondCat(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drives both category texts, as the single JavaFX text field did. */
|
/** Drives both category texts, as the single JavaFX text field did. */
|
||||||
var autoAnswerText: String
|
var autoAnswerText: String by draft.setting(
|
||||||
get() = prefs.getMessageHandling_autoAnswerTextMainCat()
|
{ prefs.getMessageHandling_autoAnswerTextMainCat() },
|
||||||
set(value) {
|
) { text ->
|
||||||
prefs.setMessageHandling_autoAnswerTextMainCat(value)
|
prefs.setMessageHandling_autoAnswerTextMainCat(text)
|
||||||
prefs.setMessageHandling_autoAnswerTextSecondCat(value)
|
prefs.setMessageHandling_autoAnswerTextSecondCat(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Independent of the automatic answer; its own checkbox in the JavaFX window. */
|
/** Independent of the automatic answer; its own checkbox in the JavaFX window. */
|
||||||
var autoAnswerToQRGRequestEnabled: Boolean
|
var autoAnswerToQRGRequestEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isMessageHandling_autoAnswerToQRGRequestEnabled()
|
{ prefs.isMessageHandling_autoAnswerToQRGRequestEnabled() },
|
||||||
set(value) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(value) }
|
) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(it) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records the whole session to file. Beyond the preference it opens or closes the
|
* 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;
|
* error-log handler and the history recorder, which only the application can sequence;
|
||||||
* [applyDebugModeToFile] carries that out in the order the JavaFX checkbox kept.
|
* [applyDebugModeToFile] carries that out in the order the JavaFX checkbox kept.
|
||||||
*/
|
*/
|
||||||
var debugModeToFileEnabled: Boolean
|
var debugModeToFileEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isMessageHandling_debugModeToFileEnabled()
|
{ prefs.isMessageHandling_debugModeToFileEnabled() },
|
||||||
set(value) {
|
effect = { applyDebugModeToFile(prefs.isMessageHandling_debugModeToFileEnabled()) },
|
||||||
prefs.setMessageHandling_debugModeToFileEnabled(value)
|
) { prefs.setMessageHandling_debugModeToFileEnabled(it) }
|
||||||
applyDebugModeToFile(value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import kst4contest.model.Band
|
import kst4contest.model.Band
|
||||||
import kst4contest.view.compose.DataTable
|
import kst4contest.view.compose.DataTable
|
||||||
import kst4contest.view.compose.Form
|
import kst4contest.view.compose.Form
|
||||||
@@ -76,12 +77,18 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
|
|||||||
stored = state.notify_dxclusterServerPort.toString(),
|
stored = state.notify_dxclusterServerPort.toString(),
|
||||||
commit = state::commitDxClusterServerPort,
|
commit = state::commitDxClusterServerPort,
|
||||||
onRefused = onRefused,
|
onRefused = onRefused,
|
||||||
|
// Compared as the number commitDxClusterServerPort stores.
|
||||||
|
isUnchanged = { typed, now -> typed.trim().toIntOrNull()?.toString() == now },
|
||||||
|
current = { state.notify_dxclusterServerPort.toString() },
|
||||||
)
|
)
|
||||||
Form.committed(
|
Form.committed(
|
||||||
label = strings.notificationSpotterCallsign,
|
label = strings.notificationSpotterCallsign,
|
||||||
stored = state.notify_DXCSrv_SpottersCallSign,
|
stored = state.notify_DXCSrv_SpottersCallSign,
|
||||||
commit = state::commitSpotterCallSign,
|
commit = state::commitSpotterCallSign,
|
||||||
onRefused = onRefused,
|
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
|
* Shows the band prefix, as the JavaFX ComboBox did: the prefix is what the
|
||||||
@@ -134,6 +141,7 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
|
|||||||
onValueChange = { entered = it },
|
onValueChange = { entered = it },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
label = { Text(strings.notificationCallsign) },
|
label = { Text(strings.notificationCallsign) },
|
||||||
|
modifier = Modifier.testTag(MONITORED_CALLSIGN_FIELD_TAG),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -149,3 +157,6 @@ 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,137 +9,144 @@ import kst4contest.view.GuiUtils
|
|||||||
import kst4contest.view.compose.CommitOutcome
|
import kst4contest.view.compose.CommitOutcome
|
||||||
import kst4contest.view.compose.EditableListState
|
import kst4contest.view.compose.EditableListState
|
||||||
import kst4contest.view.compose.EntryRule
|
import kst4contest.view.compose.EntryRule
|
||||||
|
import kst4contest.view.i18n.CurrentStrings
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State of the notification tab: sounds, band-upgrade hints, the local DX Cluster
|
* State of the notification tab: sounds, band-upgrade hints, the local DX Cluster
|
||||||
* server and the QSO monitoring list.
|
* server and the QSO monitoring list.
|
||||||
*
|
*
|
||||||
* Three of these reach past the preferences into the running session, which the
|
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||||
* settings coverage test cannot see, so they are constructor callbacks rather than
|
* applies them; the monitoring list too. The test spot is an action and is sent at once.
|
||||||
* plain properties: starting and stopping the server, restarting it after a port
|
*
|
||||||
* change, and sending the test spot.
|
* 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.
|
||||||
*
|
*
|
||||||
* @param monitoredRoster `ChatController.getLstNotify_QSOSniffer_sniffedCallSignList`;
|
* @param monitoredRoster `ChatController.getLstNotify_QSOSniffer_sniffedCallSignList`;
|
||||||
* it lives on the controller, not in the preferences, which is why it is
|
* it lives on the controller, not in the preferences, which is why it is
|
||||||
* passed separately.
|
* passed separately.
|
||||||
* @param applyDxClusterServerEnabled starts or stops the server, the work the
|
* @param applyDxClusterServer brings the running server in line with the applied switch
|
||||||
* JavaFX checkbox did beyond writing the flag.
|
* and port: restarted while enabled, stopped otherwise. The switch and the port
|
||||||
* @param applyDxClusterServerPort restarts the server; called only when the port
|
* share it, so one apply restarts the server once, on the new port.
|
||||||
* actually changed, as the JavaFX field did.
|
|
||||||
* @param broadcastTestSpot delivers the spot and returns null on success or the
|
* @param broadcastTestSpot delivers the spot and returns null on success or the
|
||||||
* reason it could not be delivered.
|
* reason it could not be delivered.
|
||||||
*/
|
*/
|
||||||
class NotificationTabState(
|
class NotificationTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
private val monitoredRoster: SimpleRoster<String>,
|
private val monitoredRoster: SimpleRoster<String>,
|
||||||
private val applyDxClusterServerEnabled: (Boolean) -> Unit,
|
private val applyDxClusterServer: () -> Unit,
|
||||||
private val applyDxClusterServerPort: (Int) -> Unit,
|
|
||||||
private val broadcastTestSpot: (ChatMember) -> String?,
|
private val broadcastTestSpot: (ChatMember) -> String?,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
var notify_playSimpleSounds: Boolean
|
var notify_playSimpleSounds: Boolean by draft.setting(
|
||||||
get() = prefs.isNotify_playSimpleSounds()
|
{ prefs.isNotify_playSimpleSounds() },
|
||||||
set(value) { prefs.setNotify_playSimpleSounds(value) }
|
) { prefs.setNotify_playSimpleSounds(it) }
|
||||||
|
|
||||||
var notify_playCWCallsignsOnRxedPMs: Boolean
|
var notify_playCWCallsignsOnRxedPMs: Boolean by draft.setting(
|
||||||
get() = prefs.isNotify_playCWCallsignsOnRxedPMs()
|
{ prefs.isNotify_playCWCallsignsOnRxedPMs() },
|
||||||
set(value) { prefs.setNotify_playCWCallsignsOnRxedPMs(value) }
|
) { prefs.setNotify_playCWCallsignsOnRxedPMs(it) }
|
||||||
|
|
||||||
var notify_playVoiceCallsignsOnRxedPMs: Boolean
|
var notify_playVoiceCallsignsOnRxedPMs: Boolean by draft.setting(
|
||||||
get() = prefs.isNotify_playVoiceCallsignsOnRxedPMs()
|
{ prefs.isNotify_playVoiceCallsignsOnRxedPMs() },
|
||||||
set(value) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(value) }
|
) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(it) }
|
||||||
|
|
||||||
var notify_bandUpgradeHintOnLogEnabled: Boolean
|
var notify_bandUpgradeHintOnLogEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isNotify_bandUpgradeHintOnLogEnabled()
|
{ prefs.isNotify_bandUpgradeHintOnLogEnabled() },
|
||||||
set(value) { prefs.setNotify_bandUpgradeHintOnLogEnabled(value) }
|
) { prefs.setNotify_bandUpgradeHintOnLogEnabled(it) }
|
||||||
|
|
||||||
var notify_bandUpgradePriorityBoostEnabled: Boolean
|
var notify_bandUpgradePriorityBoostEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isNotify_bandUpgradePriorityBoostEnabled()
|
{ prefs.isNotify_bandUpgradePriorityBoostEnabled() },
|
||||||
set(value) { prefs.setNotify_bandUpgradePriorityBoostEnabled(value) }
|
) { 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) }
|
||||||
|
|
||||||
val notify_dxClusterServerEnabled: Boolean
|
val notify_dxClusterServerEnabled: Boolean
|
||||||
get() = prefs.isNotify_dxClusterServerEnabled()
|
get() = dxClusterServerEnabled
|
||||||
|
|
||||||
val notify_dxclusterServerPort: Int
|
val notify_dxclusterServerPort: Int
|
||||||
get() = prefs.getNotify_dxclusterServerPort()
|
get() = dxClusterServerPort
|
||||||
|
|
||||||
val notify_DXCSrv_SpottersCallSign: String
|
val notify_DXCSrv_SpottersCallSign: String
|
||||||
get() = prefs.getNotify_DXCSrv_SpottersCallSign().get()
|
get() = spotterCallSign
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* 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. It is shown as 144 here
|
||||||
*
|
* and written back as 144 by repairStoredSettings before the window is built -- never
|
||||||
* Stored as a prefix string, so an unreadable value is possible; reading it
|
* from this read, which runs during composition.
|
||||||
* 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
|
var notify_optionalFrequencyPrefix: Band
|
||||||
get() {
|
get() = fallbackBand
|
||||||
val stored = Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get())
|
set(value) { fallbackBand = value }
|
||||||
|
|
||||||
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(
|
val monitoredCallSigns = EditableListState(
|
||||||
monitoredRoster.snapshot(),
|
monitoredRoster.snapshot(),
|
||||||
rule = EntryRule { candidate, _, others -> judgeMonitoredCallSign(candidate, others) },
|
rule = EntryRule { candidate, _, others -> judgeMonitoredCallSign(candidate, others) },
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Writes the flag and lets the running session start or stop the server. */
|
/** Stages the server switch; applying it starts or stops the server. */
|
||||||
fun enableDxClusterServer(enabled: Boolean) {
|
fun enableDxClusterServer(enabled: Boolean) {
|
||||||
prefs.setNotify_dxClusterServerEnabled(enabled)
|
dxClusterServerEnabled = enabled
|
||||||
applyDxClusterServerEnabled(enabled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return null when applied, otherwise the reason the port was refused.
|
* @return null when staged, otherwise the reason the port was refused.
|
||||||
*/
|
*/
|
||||||
fun commitDxClusterServerPort(entered: String): String? {
|
fun commitDxClusterServerPort(entered: String): String? {
|
||||||
val previousPort = prefs.getNotify_dxclusterServerPort()
|
|
||||||
val port = entered.trim().toIntOrNull()
|
val port = entered.trim().toIntOrNull()
|
||||||
|
|
||||||
if (port == null || port < 1 || port > 65535) {
|
if (port == null || port < 1 || port > 65535) {
|
||||||
return "\"" + entered.trim() + "\" is not a valid TCP port. " +
|
return CurrentStrings.get().notificationInvalidPort(entered.trim())
|
||||||
"Enter a value from 1 to 65535."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.setNotify_dxclusterServerPort(port)
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Restarting on an unchanged port would drop a connected logger for nothing;
|
* The stored port staged again is no change, so it restarts nothing: restarting on
|
||||||
* the JavaFX field guarded the restart the same way.
|
* an unchanged port would drop a connected logger for nothing, and the JavaFX field
|
||||||
|
* guarded the restart the same way.
|
||||||
*/
|
*/
|
||||||
if (port != previousPort) {
|
dxClusterServerPort = port
|
||||||
applyDxClusterServerPort(port)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return null when stored, otherwise the reason the callsign was refused.
|
* @return null when staged, otherwise the reason the callsign was refused.
|
||||||
*/
|
*/
|
||||||
fun commitSpotterCallSign(entered: String): String? {
|
fun commitSpotterCallSign(entered: String): String? {
|
||||||
val spotterCallSign = entered.trim().uppercase(Locale.ROOT)
|
val spotterCallSign = entered.trim().uppercase(Locale.ROOT)
|
||||||
|
|
||||||
if (!GuiUtils.isCallSignSyntax(spotterCallSign)) {
|
if (!GuiUtils.isCallSignSyntax(spotterCallSign)) {
|
||||||
return "\"" + spotterCallSign + "\" is not a valid spotter callsign."
|
return CurrentStrings.get().notificationInvalidSpotter(spotterCallSign)
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.setNotify_DXCSrv_SpottersCallSign(spotterCallSign)
|
this.spotterCallSign = spotterCallSign
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Appends a callsign to the monitoring list and fills the roster at once, as the
|
* Appends a callsign to the monitoring list and stages the list.
|
||||||
* JavaFX dialog did.
|
|
||||||
*
|
*
|
||||||
* @return null when added, otherwise the reason it was refused.
|
* @return null when added, otherwise the reason it was refused.
|
||||||
*/
|
*/
|
||||||
@@ -151,7 +158,7 @@ class NotificationTabState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (verdict !is CommitOutcome.Stored) {
|
if (verdict !is CommitOutcome.Stored) {
|
||||||
return "Please enter a valid callsign."
|
return CurrentStrings.get().notificationInvalidCallsign
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -163,9 +170,14 @@ class NotificationTabState(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fills the monitoring roster from the list. */
|
/** Stages the list; applying it fills the monitoring roster. */
|
||||||
fun commitMonitoredCallSigns() {
|
fun commitMonitoredCallSigns() {
|
||||||
monitoredCallSigns.commitTo(monitoredRoster)
|
draft.stage(
|
||||||
|
MONITORED_CALL_SIGNS,
|
||||||
|
monitoredCallSigns.texts.toList(),
|
||||||
|
live = { monitoredRoster.snapshot() },
|
||||||
|
write = { monitoredCallSigns.commitTo(monitoredRoster) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -202,13 +214,20 @@ class NotificationTabState(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (baseCall == null || !GuiUtils.isCallSignSyntax(baseCall)) {
|
if (baseCall == null || !GuiUtils.isCallSignSyntax(baseCall)) {
|
||||||
return CommitOutcome.Refused("Please enter a valid callsign.")
|
return CommitOutcome.Refused(CurrentStrings.get().notificationInvalidCallsign)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (others.any { it != null && it.equals(baseCall, ignoreCase = true) }) {
|
if (others.any { it != null && it.equals(baseCall, ignoreCase = true) }) {
|
||||||
return CommitOutcome.Refused("This base callsign is already in the monitoring list.")
|
return CommitOutcome.Refused(CurrentStrings.get().notificationAlreadyMonitored)
|
||||||
}
|
}
|
||||||
|
|
||||||
return CommitOutcome.Stored(baseCall)
|
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("Apply") } },
|
confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text(strings.dialogApply) } },
|
||||||
dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } },
|
dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import kst4contest.controller.OperatorProfileManagementService
|
import kst4contest.controller.OperatorProfileManagementService
|
||||||
import kst4contest.model.OperatorProfile
|
import kst4contest.model.OperatorProfile
|
||||||
import kst4contest.model.OperatorProfileSelection
|
import kst4contest.model.OperatorProfileSelection
|
||||||
|
import kst4contest.view.i18n.CurrentStrings
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -57,7 +58,7 @@ class ProfilesTabState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val activeProfileName: String
|
val activeProfileName: String
|
||||||
get() = activeSelection()?.profile?.displayName ?: "unknown"
|
get() = activeSelection()?.profile?.displayName ?: CurrentStrings.get().profilesUnknown
|
||||||
|
|
||||||
val preferencesPath: String
|
val preferencesPath: String
|
||||||
get() = activeSelection()?.preferencesAbsolutePath ?: ""
|
get() = activeSelection()?.preferencesAbsolutePath ?: ""
|
||||||
@@ -90,9 +91,7 @@ class ProfilesTabState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val created = service.createProfile(name.trim(), sharedWorkedDatabase)
|
val created = service.createProfile(name.trim(), sharedWorkedDatabase)
|
||||||
?: return ProfileNotice.Error(
|
?: return ProfileNotice.Error(CurrentStrings.get().profilesCreateFailed)
|
||||||
"The profile could not be created. The profile registry could not be written."
|
|
||||||
)
|
|
||||||
|
|
||||||
refresh()
|
refresh()
|
||||||
selected = created
|
selected = created
|
||||||
@@ -101,10 +100,7 @@ class ProfilesTabState(
|
|||||||
* Said explicitly because a new profile that cannot log in looks broken
|
* Said explicitly because a new profile that cannot log in looks broken
|
||||||
* otherwise: callsign and password are deliberately not copied or invented.
|
* otherwise: callsign and password are deliberately not copied or invented.
|
||||||
*/
|
*/
|
||||||
return ProfileNotice.Info(
|
return ProfileNotice.Info(CurrentStrings.get().profilesCreatedWithoutCredentials(created.displayName))
|
||||||
"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? {
|
fun duplicateSelected(name: String): ProfileNotice? {
|
||||||
@@ -115,7 +111,7 @@ class ProfilesTabState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val duplicate = service.duplicateProfile(profile, name)
|
val duplicate = service.duplicateProfile(profile, name)
|
||||||
?: return ProfileNotice.Error("The profile could not be duplicated.")
|
?: return ProfileNotice.Error(CurrentStrings.get().profilesDuplicateFailed)
|
||||||
|
|
||||||
refresh()
|
refresh()
|
||||||
selected = duplicate
|
selected = duplicate
|
||||||
@@ -124,7 +120,7 @@ class ProfilesTabState(
|
|||||||
|
|
||||||
/** The suggested name of a duplicate, the one the JavaFX dialog pre-filled. */
|
/** The suggested name of a duplicate, the one the JavaFX dialog pre-filled. */
|
||||||
fun duplicateNameSuggestion(): String =
|
fun duplicateNameSuggestion(): String =
|
||||||
selected?.let { "Copy of " + it.displayName } ?: ""
|
selected?.let { CurrentStrings.get().profilesCopyOf(it.displayName) } ?: ""
|
||||||
|
|
||||||
fun renameSelected(name: String): ProfileNotice? {
|
fun renameSelected(name: String): ProfileNotice? {
|
||||||
val profile = selected ?: return notASelection()
|
val profile = selected ?: return notASelection()
|
||||||
@@ -142,20 +138,16 @@ class ProfilesTabState(
|
|||||||
val profile = selected ?: return notASelection()
|
val profile = selected ?: return notASelection()
|
||||||
|
|
||||||
if (profile.isRootProfile) {
|
if (profile.isRootProfile) {
|
||||||
return ProfileNotice.Error(
|
return ProfileNotice.Error(CurrentStrings.get().profilesRootNotDeletable)
|
||||||
"The default profile uses the files of the installation itself and cannot be deleted."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isActive(profile)) {
|
if (isActive(profile)) {
|
||||||
return ProfileNotice.Error(
|
return ProfileNotice.Error(CurrentStrings.get().profilesActiveNotDeletable)
|
||||||
"The profile currently in use cannot be deleted. Switch to another profile first."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!service.deleteProfile(profile)) {
|
if (!service.deleteProfile(profile)) {
|
||||||
refresh()
|
refresh()
|
||||||
return ProfileNotice.Error("The profile could not be deleted.")
|
return ProfileNotice.Error(CurrentStrings.get().profilesDeleteFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh()
|
refresh()
|
||||||
@@ -164,22 +156,18 @@ class ProfilesTabState(
|
|||||||
|
|
||||||
/** The folder the deletion confirmation names, and what happens to worked stations. */
|
/** The folder the deletion confirmation names, and what happens to worked stations. */
|
||||||
fun describeDeletion(profile: OperatorProfile): String =
|
fun describeDeletion(profile: OperatorProfile): String =
|
||||||
"The following folder is removed permanently:\n" +
|
CurrentStrings.get().profilesDeletionFolder(service.getProfileDirectory(profile).toString()) + "\n\n" +
|
||||||
service.getProfileDirectory(profile) + "\n\n" +
|
|
||||||
if (profile.isSharedWorkedDatabase) {
|
if (profile.isSharedWorkedDatabase) {
|
||||||
"The common station worked stations are not touched."
|
CurrentStrings.get().profilesDeletionKeepsShared
|
||||||
} else {
|
} else {
|
||||||
"The worked stations of this profile are deleted as well."
|
CurrentStrings.get().profilesDeletionRemovesOwn
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changeWorkedDataModeOfSelected(sharedWorkedDatabase: Boolean): ProfileNotice? {
|
fun changeWorkedDataModeOfSelected(sharedWorkedDatabase: Boolean): ProfileNotice? {
|
||||||
val profile = selected ?: return notASelection()
|
val profile = selected ?: return notASelection()
|
||||||
|
|
||||||
if (profile.isRootProfile) {
|
if (profile.isRootProfile) {
|
||||||
return ProfileNotice.Error(
|
return ProfileNotice.Error(CurrentStrings.get().profilesRootAlwaysShared)
|
||||||
"The default profile always uses the common station worked stations, because " +
|
|
||||||
"that database is the one of the installation itself."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sharedWorkedDatabase == profile.isSharedWorkedDatabase) {
|
if (sharedWorkedDatabase == profile.isSharedWorkedDatabase) {
|
||||||
@@ -190,7 +178,7 @@ class ProfilesTabState(
|
|||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
if (isActive(profile)) {
|
if (isActive(profile)) {
|
||||||
return ProfileNotice.Info("The change takes effect after switching to this profile again.")
|
return ProfileNotice.Info(CurrentStrings.get().profilesChangeAfterSwitch)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -200,7 +188,7 @@ class ProfilesTabState(
|
|||||||
val profile = selected ?: return notASelection()
|
val profile = selected ?: return notASelection()
|
||||||
|
|
||||||
if (isActive(profile)) {
|
if (isActive(profile)) {
|
||||||
return ProfileNotice.Info("This profile is already active.")
|
return ProfileNotice.Info(CurrentStrings.get().profilesAlreadyActive)
|
||||||
}
|
}
|
||||||
|
|
||||||
requestActivation(profile)
|
requestActivation(profile)
|
||||||
@@ -217,7 +205,7 @@ class ProfilesTabState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun notASelection(): ProfileNotice =
|
private fun notASelection(): ProfileNotice =
|
||||||
ProfileNotice.Info("Select a profile in the table first.")
|
ProfileNotice.Info(CurrentStrings.get().profilesSelectFirst)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@@ -230,9 +218,9 @@ class ProfilesTabState(
|
|||||||
*/
|
*/
|
||||||
fun describeWorkedDataMode(profile: OperatorProfile): String =
|
fun describeWorkedDataMode(profile: OperatorProfile): String =
|
||||||
if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
|
if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
|
||||||
"common station database"
|
CurrentStrings.get().profilesSharedDatabase
|
||||||
} else {
|
} else {
|
||||||
"own database"
|
CurrentStrings.get().profilesOwnDatabase
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Blank for a profile never used, rather than an epoch date. */
|
/** 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.model.ChatPreferences
|
||||||
import kst4contest.view.compose.EditableListState
|
import kst4contest.view.compose.EditableListState
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State of the shortcuts-and-snippets tab: the two single-column lists from
|
* State of the shortcuts-and-snippets tab: the two single-column lists from
|
||||||
* `grdPnlShorts`.
|
* `grdPnlShorts`.
|
||||||
*
|
*
|
||||||
* Both lists write through to their roster on every committed change. The JavaFX
|
* Every committed change of a list is staged in the window's [SettingsDraft]. Applying
|
||||||
* tables did the same — their Add buttons called `mutate` on the roster directly —
|
* it fills the roster and rebuilds what is built from it -- the shortcut buttons above the
|
||||||
* and the shortcut buttons above the message field are rebuilt from the roster, so
|
* message field, the snippet menus -- once per apply, however many edits went before.
|
||||||
* a list that only wrote on Save would leave those buttons stale.
|
|
||||||
*
|
*
|
||||||
* @param refreshShortcutButtons rebuilds the button row above the message field,
|
* @param refreshShortcutButtons rebuilds the button row above the message field,
|
||||||
* as `refreshShortcutButtons` did after every edit commit and move.
|
* as `refreshShortcutButtons` did after every edit commit and move.
|
||||||
@@ -19,6 +19,7 @@ import kst4contest.view.compose.EditableListState
|
|||||||
*/
|
*/
|
||||||
class ShortcutsTabState(
|
class ShortcutsTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
private val refreshShortcutButtons: () -> Unit,
|
private val refreshShortcutButtons: () -> Unit,
|
||||||
private val refreshTextSnippetContextMenus: () -> Unit,
|
private val refreshTextSnippetContextMenus: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -27,16 +28,26 @@ class ShortcutsTabState(
|
|||||||
|
|
||||||
val snippets = EditableListState(prefs.getLst_txtSnipList().snapshot())
|
val snippets = EditableListState(prefs.getLst_txtSnipList().snapshot())
|
||||||
|
|
||||||
/** Fills the shortcut roster and rebuilds the buttons built from it. */
|
/** Stages the shortcut list; applying it fills the roster and rebuilds the buttons. */
|
||||||
fun commitShortcuts() {
|
fun commitShortcuts() {
|
||||||
shortcuts.commitTo(prefs.getLst_txtShortCutBtnList())
|
draft.stage(
|
||||||
refreshShortcutButtons()
|
SHORTCUTS,
|
||||||
|
shortcuts.texts.toList(),
|
||||||
|
live = { prefs.getLst_txtShortCutBtnList().snapshot() },
|
||||||
|
write = { shortcuts.commitTo(prefs.getLst_txtShortCutBtnList()) },
|
||||||
|
effect = refreshShortcutButtons,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fills the snippet roster and rebuilds the context menus built from it. */
|
/** Stages the snippet list; applying it fills the roster and rebuilds the menus. */
|
||||||
fun commitSnippets() {
|
fun commitSnippets() {
|
||||||
snippets.commitTo(prefs.getLst_txtSnipList())
|
draft.stage(
|
||||||
refreshTextSnippetContextMenus()
|
SNIPPETS,
|
||||||
|
snippets.texts.toList(),
|
||||||
|
live = { prefs.getLst_txtSnipList().snapshot() },
|
||||||
|
write = { snippets.commitTo(prefs.getLst_txtSnipList()) },
|
||||||
|
effect = refreshTextSnippetContextMenus,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -47,5 +58,8 @@ class ShortcutsTabState(
|
|||||||
*/
|
*/
|
||||||
const val NEW_ENTRY_TEMPLATE =
|
const val NEW_ENTRY_TEMPLATE =
|
||||||
"CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"
|
"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,11 +11,13 @@ import kst4contest.view.compose.Form
|
|||||||
/**
|
/**
|
||||||
* The Station tab: own station, server, active bands and path-analysis defaults.
|
* The Station tab: own station, server, active bands and path-analysis defaults.
|
||||||
*
|
*
|
||||||
* No confirmation callback: StationTabState writes through to ChatPreferences, the
|
* The controls stage their edits in the window's draft (see StationTabState); only the
|
||||||
* way the JavaFX controls did. "Save settings" persists, it does not collect.
|
* login credentials write through at once.
|
||||||
|
*
|
||||||
|
* @param onRefused shows why a committed entry was refused (the own ground height)
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun StationTab(state: StationTabState) {
|
fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
|
||||||
val strings = LocalStrings.current
|
val strings = LocalStrings.current
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -94,12 +96,39 @@ fun StationTab(state: StationTabState) {
|
|||||||
Form.int(strings.stationRotatorPort, state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it }
|
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.section(strings.stationPathSection) {
|
||||||
Form.decimal(strings.stationOwnTxPower, state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it }
|
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.stationOwnAntennaGain, state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it }
|
||||||
Form.decimal(strings.stationOwnAntennaHeight, state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = 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.stationTargetTxPower, state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it }
|
||||||
Form.decimal(strings.stationTargetAntennaGain, state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = 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 }
|
Form.text(strings.stationDemDirectory, state.stn_pathAnalysisDemRootDirectory) { state.stn_pathAnalysisDemRootDirectory = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
|
import kst4contest.controller.On4KstConnectionState
|
||||||
import kst4contest.model.ChatCategory
|
import kst4contest.model.ChatCategory
|
||||||
import kst4contest.model.ChatPreferences
|
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.
|
* The Station tab as a typed facade over ChatPreferences.
|
||||||
*
|
*
|
||||||
* Every property writes through immediately, and there is deliberately no apply().
|
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||||
* That mirrors the JavaFX window it replaces: its controls each wrote into
|
* applies them (Apply/Close prefs, Save settings). The login credentials are the exception
|
||||||
* ChatPreferences the moment they changed, and its two buttons only persisted
|
* and still write through at once: callsign, names, locator, password and both chat
|
||||||
* ("Save settings" calls writePreferencesToXmlFile) or closed the window
|
* categories. They are locked while a session exists ([loginFieldsEnabled]), and without
|
||||||
* ("Apply/Close prefs" just hides it). Buffering edits until a confirmation would
|
* one they take effect at the next login anyway, so holding them back would only make
|
||||||
* change behaviour an operator may rely on: today a changed setting takes effect
|
* Connect use something other than what the tab shows.
|
||||||
* 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
|
* The property list was derived mechanically from the setters the JavaFX tab
|
||||||
* called, intersected with the setters ChatPreferences declares.
|
* called, intersected with the setters ChatPreferences declares.
|
||||||
*/
|
*/
|
||||||
class StationTabState(
|
class StationTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
/**
|
/**
|
||||||
* Applies the chat category to the running session. Passed in rather than taken
|
* 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,
|
* from a ChatController so the rules stay testable without building a controller,
|
||||||
@@ -26,6 +34,16 @@ class StationTabState(
|
|||||||
*/
|
*/
|
||||||
private val applyMainCategory: (ChatCategory?) -> Unit,
|
private val applyMainCategory: (ChatCategory?) -> Unit,
|
||||||
private val applySecondCategory: (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?
|
var loginChatCategoryMain: ChatCategory?
|
||||||
@@ -37,45 +55,25 @@ class StationTabState(
|
|||||||
get() = prefs.getLoginChatCategorySecond()
|
get() = prefs.getLoginChatCategorySecond()
|
||||||
set(value) { prefs.setLoginChatCategorySecond(value) }
|
set(value) { prefs.setLoginChatCategorySecond(value) }
|
||||||
|
|
||||||
var stn_antennaBeamWidthDeg: Double
|
var stn_antennaBeamWidthDeg: Double by draft.setting({ prefs.getStn_antennaBeamWidthDeg() }) { prefs.setStn_antennaBeamWidthDeg(it) }
|
||||||
get() = prefs.getStn_antennaBeamWidthDeg()
|
|
||||||
set(value) { prefs.setStn_antennaBeamWidthDeg(value) }
|
|
||||||
|
|
||||||
var stn_bandActive10G: Boolean
|
var stn_bandActive10G: Boolean by draft.setting({ prefs.isStn_bandActive10G() }) { prefs.setStn_bandActive10G(it) }
|
||||||
get() = prefs.isStn_bandActive10G()
|
|
||||||
set(value) { prefs.setStn_bandActive10G(value) }
|
|
||||||
|
|
||||||
var stn_bandActive1240: Boolean
|
var stn_bandActive1240: Boolean by draft.setting({ prefs.isStn_bandActive1240() }) { prefs.setStn_bandActive1240(it) }
|
||||||
get() = prefs.isStn_bandActive1240()
|
|
||||||
set(value) { prefs.setStn_bandActive1240(value) }
|
|
||||||
|
|
||||||
var stn_bandActive144: Boolean
|
var stn_bandActive144: Boolean by draft.setting({ prefs.isStn_bandActive144() }) { prefs.setStn_bandActive144(it) }
|
||||||
get() = prefs.isStn_bandActive144()
|
|
||||||
set(value) { prefs.setStn_bandActive144(value) }
|
|
||||||
|
|
||||||
var stn_bandActive2300: Boolean
|
var stn_bandActive2300: Boolean by draft.setting({ prefs.isStn_bandActive2300() }) { prefs.setStn_bandActive2300(it) }
|
||||||
get() = prefs.isStn_bandActive2300()
|
|
||||||
set(value) { prefs.setStn_bandActive2300(value) }
|
|
||||||
|
|
||||||
var stn_bandActive3400: Boolean
|
var stn_bandActive3400: Boolean by draft.setting({ prefs.isStn_bandActive3400() }) { prefs.setStn_bandActive3400(it) }
|
||||||
get() = prefs.isStn_bandActive3400()
|
|
||||||
set(value) { prefs.setStn_bandActive3400(value) }
|
|
||||||
|
|
||||||
var stn_bandActive432: Boolean
|
var stn_bandActive432: Boolean by draft.setting({ prefs.isStn_bandActive432() }) { prefs.setStn_bandActive432(it) }
|
||||||
get() = prefs.isStn_bandActive432()
|
|
||||||
set(value) { prefs.setStn_bandActive432(value) }
|
|
||||||
|
|
||||||
var stn_bandActive50: Boolean
|
var stn_bandActive50: Boolean by draft.setting({ prefs.isStn_bandActive50() }) { prefs.setStn_bandActive50(it) }
|
||||||
get() = prefs.isStn_bandActive50()
|
|
||||||
set(value) { prefs.setStn_bandActive50(value) }
|
|
||||||
|
|
||||||
var stn_bandActive5600: Boolean
|
var stn_bandActive5600: Boolean by draft.setting({ prefs.isStn_bandActive5600() }) { prefs.setStn_bandActive5600(it) }
|
||||||
get() = prefs.isStn_bandActive5600()
|
|
||||||
set(value) { prefs.setStn_bandActive5600(value) }
|
|
||||||
|
|
||||||
var stn_bandActive70: Boolean
|
var stn_bandActive70: Boolean by draft.setting({ prefs.isStn_bandActive70() }) { prefs.setStn_bandActive70(it) }
|
||||||
get() = prefs.isStn_bandActive70()
|
|
||||||
set(value) { prefs.setStn_bandActive70(value) }
|
|
||||||
|
|
||||||
var stn_loginCallSign: String
|
var stn_loginCallSign: String
|
||||||
get() = prefs.getStn_loginCallSign()
|
get() = prefs.getStn_loginCallSign()
|
||||||
@@ -97,57 +95,123 @@ class StationTabState(
|
|||||||
get() = prefs.getStn_loginPassword()
|
get() = prefs.getStn_loginPassword()
|
||||||
set(value) { prefs.setStn_loginPassword(value) }
|
set(value) { prefs.setStn_loginPassword(value) }
|
||||||
|
|
||||||
var stn_maxQRBDefault: Double
|
var stn_maxQRBDefault: Double by draft.setting({ prefs.getStn_maxQRBDefault() }) { prefs.setStn_maxQRBDefault(it) }
|
||||||
get() = prefs.getStn_maxQRBDefault()
|
|
||||||
set(value) { prefs.setStn_maxQRBDefault(value) }
|
|
||||||
|
|
||||||
var stn_on4kstServersDns: String
|
var stn_on4kstServersDns: String by draft.setting({ prefs.getStn_on4kstServersDns() }) { prefs.setStn_on4kstServersDns(it) }
|
||||||
get() = prefs.getStn_on4kstServersDns()
|
|
||||||
set(value) { prefs.setStn_on4kstServersDns(value) }
|
|
||||||
|
|
||||||
var stn_on4kstServersPort: Int
|
var stn_on4kstServersPort: Int by draft.setting({ prefs.getStn_on4kstServersPort() }) { prefs.setStn_on4kstServersPort(it) }
|
||||||
get() = prefs.getStn_on4kstServersPort()
|
|
||||||
set(value) { prefs.setStn_on4kstServersPort(value) }
|
|
||||||
|
|
||||||
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double
|
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi() }) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(it) }
|
||||||
get() = prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi()
|
|
||||||
set(value) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(value) }
|
|
||||||
|
|
||||||
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double
|
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts() }) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(it) }
|
||||||
get() = prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts()
|
|
||||||
set(value) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(value) }
|
|
||||||
|
|
||||||
var stn_pathAnalysisDemRootDirectory: String
|
var stn_pathAnalysisDemRootDirectory: String by draft.setting({ prefs.getStn_pathAnalysisDemRootDirectory() }) { prefs.setStn_pathAnalysisDemRootDirectory(it) }
|
||||||
get() = prefs.getStn_pathAnalysisDemRootDirectory()
|
|
||||||
set(value) { prefs.setStn_pathAnalysisDemRootDirectory(value) }
|
|
||||||
|
|
||||||
var stn_pathAnalysisOwnAntennaGainDbi: Double
|
var stn_pathAnalysisOwnAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaGainDbi() }) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(it) }
|
||||||
get() = prefs.getStn_pathAnalysisOwnAntennaGainDbi()
|
|
||||||
set(value) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(value) }
|
|
||||||
|
|
||||||
var stn_pathAnalysisOwnAntennaHeightMeters: Double
|
var stn_pathAnalysisOwnAntennaHeightMeters: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(it) }
|
||||||
get() = prefs.getStn_pathAnalysisOwnAntennaHeightMeters()
|
|
||||||
set(value) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(value) }
|
|
||||||
|
|
||||||
var stn_pathAnalysisOwnTxPowerWatts: Double
|
var stn_pathAnalysisOwnTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisOwnTxPowerWatts() }) { prefs.setStn_pathAnalysisOwnTxPowerWatts(it) }
|
||||||
get() = prefs.getStn_pathAnalysisOwnTxPowerWatts()
|
|
||||||
set(value) { prefs.setStn_pathAnalysisOwnTxPowerWatts(value) }
|
|
||||||
|
|
||||||
var stn_pstRotatorEnabled: Boolean
|
/**
|
||||||
get() = prefs.isStn_pstRotatorEnabled()
|
* Own ground elevation above sea level in metres, NaN while the elevation model decides.
|
||||||
set(value) { prefs.setStn_pstRotatorEnabled(value) }
|
* 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_pstRotatorHost: String
|
/** Whether the path ends at the highest point of the remote locator square. */
|
||||||
get() = prefs.getStn_pstRotatorHost()
|
var stn_pathAnalysisDxHighestPointInSquare: Boolean by draft.setting({ prefs.isStn_pathAnalysisDxHighestPointInSquare() }) { prefs.setStn_pathAnalysisDxHighestPointInSquare(it) }
|
||||||
set(value) { prefs.setStn_pstRotatorHost(value) }
|
|
||||||
|
|
||||||
var stn_pstRotatorPort: Int
|
/**
|
||||||
get() = prefs.getStn_pstRotatorPort()
|
* The entry last committed to the own-height field, as the operator typed it.
|
||||||
set(value) { prefs.setStn_pstRotatorPort(value) }
|
*
|
||||||
|
* [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_qtfDefault: Double
|
/**
|
||||||
get() = prefs.getStn_qtfDefault()
|
* The own ground elevation as its field shows it: empty while the elevation model
|
||||||
set(value) { prefs.setStn_qtfDefault(value) }
|
* 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) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chat category is held twice: in ChatPreferences and in ChatController.
|
* The chat category is held twice: in ChatPreferences and in ChatController.
|
||||||
@@ -164,17 +228,21 @@ class StationTabState(
|
|||||||
* pickers it governs. It decides what the Connect button does, so it belongs with
|
* pickers it governs. It decides what the Connect button does, so it belongs with
|
||||||
* the login settings and not with the display options.
|
* the login settings and not with the display options.
|
||||||
*/
|
*/
|
||||||
var loginToSecondChatEnabled: Boolean
|
var loginToSecondChatEnabled: Boolean by draft.setting({ prefs.isLoginToSecondChatEnabled() }) { prefs.setLoginToSecondChatEnabled(it) }
|
||||||
get() = prefs.isLoginToSecondChatEnabled()
|
|
||||||
set(value) { prefs.setLoginToSecondChatEnabled(value) }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the login fields may be edited. The JavaFX window disabled callsign,
|
* Whether the login fields may be edited. The JavaFX window disabled callsign,
|
||||||
* password, name, locator and both category pickers once the session was logged
|
* 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
|
* in: changing them there would not reach the running connection, so an edit
|
||||||
* would look applied and be ignored until the next login.
|
* 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.
|
||||||
*/
|
*/
|
||||||
var loginFieldsEnabled: Boolean = true
|
val loginFieldsEnabled: Boolean
|
||||||
|
get() = connectionState() == On4KstConnectionState.DISCONNECTED
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The categories the operator can log into, built the way the JavaFX choice box
|
* The categories the operator can log into, built the way the JavaFX choice box
|
||||||
@@ -210,4 +278,40 @@ class StationTabState(
|
|||||||
loginChatCategorySecond = category
|
loginChatCategorySecond = category
|
||||||
applySecondCategory(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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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,8 +20,7 @@ import kst4contest.view.compose.Form
|
|||||||
* QRG update needs an enabled source *and* valid incoming RadioInfo or Win-Test STATUS
|
* 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".
|
* data, so a checked box must not read as "my QRG is being kept current".
|
||||||
*
|
*
|
||||||
* No confirmation callback: TrxSynchTabState writes through to ChatPreferences, the way
|
* The controls stage their edits in the window's draft (see TrxSynchTabState).
|
||||||
* the JavaFX controls did.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TrxSynchTab(state: TrxSynchTabState) {
|
fun TrxSynchTab(state: TrxSynchTabState) {
|
||||||
@@ -63,6 +62,24 @@ 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) {
|
Form.section(strings.trxSynchHintSection) {
|
||||||
Text(strings.trxSynchHint)
|
Text(strings.trxSynchHint)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The TRX synch tab as a typed facade over ChatPreferences.
|
* The TRX synch tab as a typed facade over ChatPreferences.
|
||||||
*
|
*
|
||||||
* Every property writes through immediately and there is deliberately no apply(), the
|
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||||
* way the JavaFX controls behaved.
|
* applies them.
|
||||||
*
|
*
|
||||||
* The tab carries the two sources that may update the own QRG. Both live under the
|
* 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
|
* logsynch_ and trxSynch_ prefixes, but the JavaFX window put both controls on this
|
||||||
@@ -18,34 +20,42 @@ import kst4contest.model.ChatPreferences
|
|||||||
* Win-Test STATUS data. [QRG_SOURCE_HINT_KEY] names the text the tab shows so an operator
|
* 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".
|
* 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
|
* @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled
|
||||||
* and detaches it otherwise; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
|
* and detaches it otherwise, once per apply however many sources changed; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
|
||||||
* plus the tooltip on the own-QRG field. Not a ChatPreferences setter, so no coverage
|
* 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.
|
* test can see it, and passed in so the rules stay testable without a controller.
|
||||||
*/
|
*/
|
||||||
class TrxSynchTabState(
|
class TrxSynchTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
|
private val applySecondRotator: () -> Unit = {},
|
||||||
private val applyOwnQrgFollower: (Boolean) -> Unit,
|
private val applyOwnQrgFollower: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
var trxSynch_ucxLogUDPListenerEnabled: Boolean
|
var trxSynch_ucxLogUDPListenerEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isTrxSynch_ucxLogUDPListenerEnabled()
|
{ prefs.isTrxSynch_ucxLogUDPListenerEnabled() },
|
||||||
set(value) {
|
effectKey = OWN_QRG_FOLLOWER,
|
||||||
prefs.setTrxSynch_ucxLogUDPListenerEnabled(value)
|
effect = ::applyFollower,
|
||||||
applyOwnQrgFollower(anyQrgSourceEnabled)
|
) { prefs.setTrxSynch_ucxLogUDPListenerEnabled(it) }
|
||||||
}
|
|
||||||
|
|
||||||
var logsynch_wintestQrgSyncEnabled: Boolean
|
var logsynch_wintestQrgSyncEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isLogsynch_wintestQrgSyncEnabled()
|
{ prefs.isLogsynch_wintestQrgSyncEnabled() },
|
||||||
set(value) {
|
effectKey = OWN_QRG_FOLLOWER,
|
||||||
prefs.setLogsynch_wintestQrgSyncEnabled(value)
|
effect = ::applyFollower,
|
||||||
applyOwnQrgFollower(anyQrgSourceEnabled)
|
) { prefs.setLogsynch_wintestQrgSyncEnabled(it) }
|
||||||
}
|
|
||||||
|
|
||||||
/** Use the Win-Test pass frequency from STATUS instead of the own QRG. */
|
/** Use the Win-Test pass frequency from STATUS instead of the own QRG. */
|
||||||
var logsynch_wintestUsePassQrg: Boolean
|
var logsynch_wintestUsePassQrg: Boolean by draft.setting(
|
||||||
get() = prefs.isLogsynch_wintestUsePassQrg()
|
{ prefs.isLogsynch_wintestUsePassQrg() },
|
||||||
set(value) { prefs.setLogsynch_wintestUsePassQrg(value) }
|
) { 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) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Win-Test station name filter. An empty filter accepts every station, which is
|
* Win-Test station name filter. An empty filter accepts every station, which is
|
||||||
@@ -53,8 +63,29 @@ class TrxSynchTabState(
|
|||||||
* way the JavaFX field committed it.
|
* way the JavaFX field committed it.
|
||||||
*/
|
*/
|
||||||
var logsynch_wintestNetworkStationNameOfWintestClient1: String
|
var logsynch_wintestNetworkStationNameOfWintestClient1: String
|
||||||
get() = prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()
|
get() = stationFilter
|
||||||
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(value.trim()) }
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when at least one source may update the own QRG. One source switched off is
|
* True when at least one source may update the own QRG. One source switched off is
|
||||||
@@ -74,5 +105,8 @@ class TrxSynchTabState(
|
|||||||
* would read whichever one the tab happened to use.
|
* would read whichever one the tab happened to use.
|
||||||
*/
|
*/
|
||||||
const val QRG_SOURCE_HINT_KEY: String = "trxSynch.hint"
|
const val QRG_SOURCE_HINT_KEY: String = "trxSynch.hint"
|
||||||
|
|
||||||
|
/** The effect key both QRG sources share. */
|
||||||
|
private const val OWN_QRG_FOLLOWER = "ownQrgFollower"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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,6 +322,26 @@ class DataTableStateTest {
|
|||||||
|
|
||||||
assertSame(kept, table.selectedRow)
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import kst4contest.view.compose.tabs.MessageHandlingTabState
|
|||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
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.
|
* The four ways the drawing layer went wrong while the build was green.
|
||||||
@@ -41,7 +43,7 @@ import org.junit.jupiter.api.Test
|
|||||||
* these were written, because the spec left it open and a net that only works on a
|
* 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.
|
* developer's desk is worth saying so about.
|
||||||
*/
|
*/
|
||||||
class DrawingLayerRegressionTest {
|
class DrawingLayerRegressionTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
private fun themed(content: @Composable () -> Unit): @Composable () -> Unit = {
|
private fun themed(content: @Composable () -> Unit): @Composable () -> Unit = {
|
||||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { content() }
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { content() }
|
||||||
@@ -52,9 +54,9 @@ class DrawingLayerRegressionTest {
|
|||||||
@OptIn(ExperimentalTestApi::class)
|
@OptIn(ExperimentalTestApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
|
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
prefs.setMessageHandling_autoAnswerTextMainCat("")
|
prefs.setMessageHandling_autoAnswerTextMainCat("")
|
||||||
val state = MessageHandlingTabState(prefs)
|
val state = MessageHandlingTabState(prefs, ImmediateDraft())
|
||||||
|
|
||||||
// The real tab, not a field in isolation: the fault was in the wiring between them.
|
// The real tab, not a field in isolation: the fault was in the wiring between them.
|
||||||
setContent(themed { MessageHandlingTab(state) })
|
setContent(themed { MessageHandlingTab(state) })
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu starts at the bar's bottom edge, like any menu bar's menu. Material3's
|
||||||
|
* DropdownMenu cannot do this: its position provider refuses any y closer than 48 dp
|
||||||
|
* (MenuVerticalMargin) to the window's top edge, and the bar sits exactly there, so
|
||||||
|
* every menu opened with a visible gap hanging below the bar.
|
||||||
|
*
|
||||||
|
* The popup-semantics node is a scene-filling layout that positions the menu inside
|
||||||
|
* itself, so its own bounds say nothing; the first item is measured instead, which
|
||||||
|
* sits [MENU_POPUP_VERTICAL_PADDING] below the menu's top edge.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun theMenuOpensFlushUnderTheBar() = runComposeUiTest {
|
||||||
|
showRow(onlineState(), RecordingActions())
|
||||||
|
|
||||||
|
onNodeWithText("File").performClick()
|
||||||
|
val firstItem = awaitNode(inPopup("Connect to 144/432 MHz"))
|
||||||
|
|
||||||
|
val rowBottom = onNodeWithTag(rowTag).fetchSemanticsNode().boundsInWindow.bottom
|
||||||
|
val itemTop = firstItem.fetchSemanticsNode().boundsInWindow.top
|
||||||
|
val paddingPx = 8f
|
||||||
|
assertEquals(rowBottom + paddingPx, itemTop, 0.5f, "the menu does not start at the bar's bottom edge")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,6 +1,7 @@
|
|||||||
package kst4contest.view.compose
|
package kst4contest.view.compose
|
||||||
|
|
||||||
import kst4contest.controller.On4KstConnectionState
|
import kst4contest.controller.On4KstConnectionState
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -123,36 +124,79 @@ class MainWindowFrameTest {
|
|||||||
* The title carries the whole connection line while the chat is usable.
|
* 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
|
* The JavaFX window did that -- "Connected to: 2: 144/432 MHz as DN5PW (Testing) in
|
||||||
* JO50JP (73 users online, 73 shown), 29 messages total." -- and the Compose window
|
* JO50JP (73 users online, 73 shown), 29 messages total." The first Compose title took
|
||||||
* opened with a fixed "KST4Contest (Compose)" instead, which told a tiling window
|
* the connection manager's diagnostic instead and read "ON4KST session is authenticated
|
||||||
* manager nothing and told the operator less.
|
* and synchronized" for the whole contest.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `the title carries the live connection line once the chat is usable`() {
|
fun `the title carries the live connection line once the chat is usable`() {
|
||||||
val detail = "Connected to: 2: 144/432 MHz as DN5PW (Testing) in JO50JP " +
|
val line = MainWindowFrame.onlineChatState(
|
||||||
"(73 users online, 73 shown), 29 messages total."
|
mainCategory = ChatCategory(ChatCategory.VUHF),
|
||||||
|
secondCategory = null,
|
||||||
|
callSign = "DN5PW",
|
||||||
|
name = "Testing",
|
||||||
|
locator = "JO50JP",
|
||||||
|
usersOnline = 73,
|
||||||
|
shown = 70,
|
||||||
|
messagesTotal = 29,
|
||||||
|
)
|
||||||
|
|
||||||
assertEquals(detail, MainWindowFrame.chatState(On4KstConnectionState.ONLINE, detail))
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an unusable chat says what it is doing instead`() {
|
fun `an unusable chat says what it is doing instead`() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING),
|
MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING),
|
||||||
MainWindowFrame.chatState(On4KstConnectionState.CONNECTING, "ignored while connecting"),
|
MainWindowFrame.chatState(On4KstConnectionState.CONNECTING) { error("not online") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a blank detail falls through rather than leaving the title empty`() {
|
fun `no state at all reads as disconnected without building the online line`() {
|
||||||
// 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(
|
assertEquals(
|
||||||
MainWindowFrame.offlineChatState(null),
|
MainWindowFrame.offlineChatState(null),
|
||||||
MainWindowFrame.chatState(null, null),
|
MainWindowFrame.chatState(null) { error("not online") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,8 @@ import kst4contest.model.ChatPreferences
|
|||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
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
|
* The shortcut and snippet rosters are ChatPreferences.SimpleRoster, which notifies
|
||||||
@@ -13,11 +15,11 @@ import org.junit.jupiter.api.Test
|
|||||||
* above the send line follow the settings window — the reason ShortcutsTabState has been
|
* above the send line follow the settings window — the reason ShortcutsTabState has been
|
||||||
* calling refreshShortcutButtons into thin air since Etappe 3b.
|
* calling refreshShortcutButtons into thin air since Etappe 3b.
|
||||||
*/
|
*/
|
||||||
class MainWindowStateRosterTest {
|
class MainWindowStateRosterTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the shortcuts start out as what the profile holds`() {
|
fun `the shortcuts start out as what the profile holds`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "pse sked"))
|
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "pse sked"))
|
||||||
|
|
||||||
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
|
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
|
||||||
@@ -31,7 +33,7 @@ class MainWindowStateRosterTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `a roster change reaches the mirror when it is refreshed`() {
|
fun `a roster change reaches the mirror when it is refreshed`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
|
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
|
||||||
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
|
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ class MainWindowStateRosterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an emptied roster empties the mirror`() {
|
fun `an emptied roster empties the mirror`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
|
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
|
||||||
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
|
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@ class MainWindowStateRosterTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `drawing the buttons subscribes to the mirror`() {
|
fun `drawing the buttons subscribes to the mirror`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
|
prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
|
||||||
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
|
val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
|
||||||
|
|
||||||
@@ -79,9 +81,9 @@ class MainWindowStateRosterTest {
|
|||||||
* A mismatch here is silent and total: SplitterState falls back to an even split, so
|
* 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.
|
* every operator would lose the layout they arranged without any error saying so.
|
||||||
*/
|
*/
|
||||||
class MainWindowSplitterDefaultsTest {
|
class MainWindowSplitterDefaultsTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
private val prefs = ChatPreferences()
|
private val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the message column has as many dividers as the profile stores positions`() {
|
fun `the message column has as many dividers as the profile stores positions`() {
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntRect
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu row's own popup placement, as pure arithmetic.
|
||||||
|
*
|
||||||
|
* Material3's DropdownMenuPositionProvider refuses any y closer than 48 dp to the window's
|
||||||
|
* top edge, which pushed every menu of the bar down to that margin. This provider must not:
|
||||||
|
* a menu-bar menu starts flush under its anchor, whatever the distance to the window edge.
|
||||||
|
*/
|
||||||
|
class MenuRowPopupPositionProviderTest {
|
||||||
|
|
||||||
|
private val window = IntSize(800, 600)
|
||||||
|
private val menu = IntSize(200, 150)
|
||||||
|
|
||||||
|
private fun positionOf(
|
||||||
|
anchor: IntRect,
|
||||||
|
window: IntSize = this.window,
|
||||||
|
direction: LayoutDirection = LayoutDirection.Ltr,
|
||||||
|
menu: IntSize = this.menu,
|
||||||
|
) = MenuRowPopupPositionProvider.calculatePosition(
|
||||||
|
anchorBounds = anchor,
|
||||||
|
windowSize = window,
|
||||||
|
layoutDirection = direction,
|
||||||
|
popupContentSize = menu,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The regression itself: an anchor at the very top gets its menu directly below it. */
|
||||||
|
@Test
|
||||||
|
fun opensFlushUnderAnAnchorAtTheTopOfTheWindow() {
|
||||||
|
val position = positionOf(IntRect(0, 0, 40, 30))
|
||||||
|
assertEquals(IntOffset(0, 30), position)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun staysInsideTheWindowWhenTheAnchorIsNearTheRightEdge() {
|
||||||
|
val position = positionOf(IntRect(760, 0, 800, 30))
|
||||||
|
assertEquals(IntOffset(600, 30), position)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun opensAboveTheAnchorWhenThereIsNoRoomBelow() {
|
||||||
|
val position = positionOf(IntRect(0, 560, 40, 590))
|
||||||
|
assertEquals(IntOffset(0, 410), position)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No room on either side: pinned to the window's bottom edge rather than clipped. */
|
||||||
|
@Test
|
||||||
|
fun staysInsideAWindowTooSmallForTheMenu() {
|
||||||
|
val position = positionOf(IntRect(0, 40, 40, 70), window = IntSize(800, 100), menu = IntSize(200, 90))
|
||||||
|
assertEquals(IntOffset(0, 10), position)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun alignsTheRightEdgesInRightToLeftLayouts() {
|
||||||
|
val position = positionOf(IntRect(600, 0, 640, 30), direction = LayoutDirection.Rtl)
|
||||||
|
assertEquals(IntOffset(440, 30), position)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.ComposeUiTest
|
||||||
|
import androidx.compose.ui.test.getBoundsInRoot
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
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.Assertions.assertTrue
|
||||||
|
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>,
|
||||||
|
splitter: SplitterState = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5)) {},
|
||||||
|
) {
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
Box(Modifier.size(600.dp, 400.dp)) {
|
||||||
|
MonitorContent(cluster, qsos, splitter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two tables used to get half the height each, fixed, and the stored position the
|
||||||
|
* JavaFX SplitPane had written was read by nobody (#144). The QSO heading is the top of
|
||||||
|
* the lower pane, so where it sits says where the divider is.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun theStoredDividerPositionSplitsTheWindow() = runComposeUiTest {
|
||||||
|
show(clusterTable(), qsoTable(), SplitterState(paneCount = 2, stored = doubleArrayOf(0.8)) {})
|
||||||
|
|
||||||
|
val window = onRoot().getBoundsInRoot()
|
||||||
|
val qsoHeadingTop = onNodeWithText("QSO of the other").getBoundsInRoot().top
|
||||||
|
val share = (qsoHeadingTop - window.top) / (window.bottom - window.top)
|
||||||
|
|
||||||
|
assertTrue(share in 0.75f..0.85f, "QSO pane starts at $share of the height, expected about 0.8")
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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,6 +5,8 @@ import kst4contest.model.ChatPreferences
|
|||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
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
|
* ChatPreferences hands out MutableValue, which notifies its own listeners and nothing
|
||||||
@@ -15,11 +17,11 @@ import org.junit.jupiter.api.Test
|
|||||||
* The JavaFX window solved it with attachQtfFollower() and attachOwnQrgFollower(); this is
|
* The JavaFX window solved it with attachQtfFollower() and attachOwnQrgFollower(); this is
|
||||||
* the same idea, ending in Compose state instead of a TextField.
|
* the same idea, ending in Compose state instead of a TextField.
|
||||||
*/
|
*/
|
||||||
class ObservedValueTest {
|
class ObservedValueTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the mirror starts at the stored value`() {
|
fun `the mirror starts at the stored value`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
prefs.getMYQRGFirstCat().set("144.370")
|
prefs.getMYQRGFirstCat().set("144.370")
|
||||||
|
|
||||||
val observed = ObservedValue(prefs.getMYQRGFirstCat())
|
val observed = ObservedValue(prefs.getMYQRGFirstCat())
|
||||||
@@ -30,7 +32,7 @@ class ObservedValueTest {
|
|||||||
/** The rotator moving must reach the screen without anything asking it to. */
|
/** The rotator moving must reach the screen without anything asking it to. */
|
||||||
@Test
|
@Test
|
||||||
fun `a change to the underlying value reaches the mirror by itself`() {
|
fun `a change to the underlying value reaches the mirror by itself`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
prefs.getActualQTF().set(90.0)
|
prefs.getActualQTF().set(90.0)
|
||||||
val observed = ObservedValue(prefs.getActualQTF())
|
val observed = ObservedValue(prefs.getActualQTF())
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ class ObservedValueTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a null value is carried through rather than turned into a default`() {
|
fun `a null value is carried through rather than turned into a default`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
val observed = ObservedValue(prefs.getMYQRGSecondCat())
|
val observed = ObservedValue(prefs.getMYQRGSecondCat())
|
||||||
|
|
||||||
prefs.getMYQRGSecondCat().set(null)
|
prefs.getMYQRGSecondCat().set(null)
|
||||||
@@ -55,7 +57,7 @@ class ObservedValueTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `drawing from the mirror subscribes to it`() {
|
fun `drawing from the mirror subscribes to it`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
prefs.getMYQRGFirstCat().set("144.370")
|
prefs.getMYQRGFirstCat().set("144.370")
|
||||||
val observed = ObservedValue(prefs.getMYQRGFirstCat())
|
val observed = ObservedValue(prefs.getMYQRGFirstCat())
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ class ObservedValueTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `releasing the mirror stops it following`() {
|
fun `releasing the mirror stops it following`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
prefs.getMYQRGFirstCat().set("144.370")
|
prefs.getMYQRGFirstCat().set("144.370")
|
||||||
val observed = ObservedValue(prefs.getMYQRGFirstCat())
|
val observed = ObservedValue(prefs.getMYQRGFirstCat())
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ class ObservedValueTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `releasing twice is harmless`() {
|
fun `releasing twice is harmless`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
val observed = ObservedValue(prefs.getMYQRGFirstCat())
|
val observed = ObservedValue(prefs.getMYQRGFirstCat())
|
||||||
observed.release()
|
observed.release()
|
||||||
observed.release()
|
observed.release()
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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,6 +7,8 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
|||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.io.File
|
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.
|
* The factory binds the store to one profile's preferences and one profile's stylesheet.
|
||||||
@@ -15,7 +17,7 @@ import java.io.File
|
|||||||
* another's colours. The stylesheet path is injected here rather than derived inside, so the
|
* 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.
|
* test does not have to write into the operator's home directory to check which file is read.
|
||||||
*/
|
*/
|
||||||
class PaletteStoreFactoryTest {
|
class PaletteStoreFactoryTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
private val daylight = OperatorProfilePaletteFiles(
|
private val daylight = OperatorProfilePaletteFiles(
|
||||||
dayFile = File("/nonexistent/day.css"),
|
dayFile = File("/nonexistent/day.css"),
|
||||||
@@ -24,7 +26,7 @@ class PaletteStoreFactoryTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aChangeLandsInTheDesignsOwnPreference() {
|
fun aChangeLandsInTheDesignsOwnPreference() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
|
|
||||||
val store = PaletteStoreFactory.create(prefs, daylight)
|
val store = PaletteStoreFactory.create(prefs, daylight)
|
||||||
store.set(darkMode = false, role = PaletteRole.ACCENT, colour = "#FF0000")
|
store.set(darkMode = false, role = PaletteRole.ACCENT, colour = "#FF0000")
|
||||||
@@ -35,7 +37,7 @@ class PaletteStoreFactoryTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun anEveningChangeLandsInTheEveningPreference() {
|
fun anEveningChangeLandsInTheEveningPreference() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
|
|
||||||
val store = PaletteStoreFactory.create(prefs, daylight)
|
val store = PaletteStoreFactory.create(prefs, daylight)
|
||||||
store.set(darkMode = true, role = PaletteRole.ACCENT, colour = "#FF0000")
|
store.set(darkMode = true, role = PaletteRole.ACCENT, colour = "#FF0000")
|
||||||
@@ -47,7 +49,7 @@ class PaletteStoreFactoryTest {
|
|||||||
@Test
|
@Test
|
||||||
fun whatIsAlreadyStoredIsInForceFromTheStart() {
|
fun whatIsAlreadyStoredIsInForceFromTheStart() {
|
||||||
// The store is built at startup; a colour saved last session has to be there at once.
|
// The store is built at startup; a colour saved last session has to be there at once.
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
prefs.guiOptions_paletteOverridesDay = "accent=#FF0000"
|
prefs.guiOptions_paletteOverridesDay = "accent=#FF0000"
|
||||||
|
|
||||||
val store = PaletteStoreFactory.create(prefs, daylight)
|
val store = PaletteStoreFactory.create(prefs, daylight)
|
||||||
@@ -57,7 +59,7 @@ class PaletteStoreFactoryTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aMissingStylesheetLeavesEveryRoleWithTheShippedSheet() {
|
fun aMissingStylesheetLeavesEveryRoleWithTheShippedSheet() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
|
|
||||||
val store = PaletteStoreFactory.create(prefs, daylight)
|
val store = PaletteStoreFactory.create(prefs, daylight)
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ class PaletteStoreFactoryTest {
|
|||||||
file.writeText(".root { -fx-accent: #123456; }")
|
file.writeText(".root { -fx-accent: #123456; }")
|
||||||
|
|
||||||
val store = PaletteStoreFactory.create(
|
val store = PaletteStoreFactory.create(
|
||||||
ChatPreferences(),
|
ChatPreferences(tempDir.resolve("preferences.xml")),
|
||||||
OperatorProfilePaletteFiles(dayFile = file, eveningFile = file),
|
OperatorProfilePaletteFiles(dayFile = file, eveningFile = file),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
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,6 +39,42 @@ class SelectedStationStateTest {
|
|||||||
|
|
||||||
private fun member(call: String) = ChatMember().apply { callSign = call }
|
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
|
@Test
|
||||||
fun `without a selection there is nothing to act on`() {
|
fun `without a selection there is nothing to act on`() {
|
||||||
val state = Session().state()
|
val state = Session().state()
|
||||||
@@ -223,4 +259,57 @@ class SelectedStationStateTest {
|
|||||||
|
|
||||||
assertTrue(session.failMarked.isEmpty())
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,4 +162,27 @@ class StationColumnsTest {
|
|||||||
"reachability is computed by the controller, not carried by the row")
|
"reachability is computed by the controller, not carried by the row")
|
||||||
assertEquals("250", columns.first { it.id == "score" }.value(member))
|
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)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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,6 +131,108 @@ class StationFilterStateTest {
|
|||||||
assertEquals(setOf(StationFilter.MAX_QRB), filters.activeFilters)
|
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
|
@Test
|
||||||
fun `a search text filters while it is there and stops when it is cleared`() {
|
fun `a search text filters while it is there and stops when it is cleared`() {
|
||||||
val filters = StationFilterState()
|
val filters = StationFilterState()
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+473
@@ -0,0 +1,473 @@
|
|||||||
|
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,16 +1,24 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
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.Test
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
class AirscoutTabStateTest {
|
class AirscoutTabStateTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
private class Session {
|
/*
|
||||||
val prefs = ChatPreferences()
|
* The rules below are pinned with a draft that applies at once; what the draft itself
|
||||||
val state by lazy { AirscoutTabState(prefs) }
|
* 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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -32,7 +40,39 @@ class AirscoutTabStateTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `writing reaches the preferences at once, without a confirmation step`() {
|
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`() {
|
||||||
val s = Session()
|
val s = Session()
|
||||||
|
|
||||||
s.state.airScout_asUDPListenerEnabled = true
|
s.state.airScout_asUDPListenerEnabled = true
|
||||||
|
|||||||
@@ -2,22 +2,34 @@ package kst4contest.view.compose.tabs
|
|||||||
|
|
||||||
import kst4contest.controller.ChatController
|
import kst4contest.controller.ChatController
|
||||||
import kst4contest.model.ChatPreferences
|
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.Test
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
class BeaconTabStateTest {
|
class BeaconTabStateTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
private class Session(private val templateRefusal: String? = null) {
|
/*
|
||||||
val prefs = ChatPreferences()
|
* 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"))
|
||||||
var timerRestarts = 0
|
var timerRestarts = 0
|
||||||
var validated = mutableListOf<String>()
|
var validated = mutableListOf<String>()
|
||||||
|
|
||||||
fun state() = BeaconTabState(
|
fun state() = BeaconTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
validateBeaconTemplate = { template ->
|
validateBeaconTemplate = { template ->
|
||||||
validated.add(template)
|
validated.add(template)
|
||||||
templateRefusal?.let { throw IllegalArgumentException(it) }
|
templateRefusal?.let { throw IllegalArgumentException(it) }
|
||||||
@@ -29,7 +41,46 @@ class BeaconTabStateTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the enable flags write through at once`() {
|
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`() {
|
||||||
val s = Session()
|
val s = Session()
|
||||||
val state = s.state()
|
val state = s.state()
|
||||||
|
|
||||||
|
|||||||
+227
@@ -0,0 +1,227 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
+30
-11
@@ -1,6 +1,8 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.ImmediateDraft
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
import kst4contest.view.i18n.LanguageStore
|
import kst4contest.view.i18n.LanguageStore
|
||||||
import kst4contest.view.i18n.LanguageStoreFactory
|
import kst4contest.view.i18n.LanguageStoreFactory
|
||||||
import kst4contest.view.i18n.SYSTEM_LANGUAGE
|
import kst4contest.view.i18n.SYSTEM_LANGUAGE
|
||||||
@@ -9,8 +11,10 @@ import java.util.Locale
|
|||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
class GuiOptionsTabStateTest {
|
class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Over lambdas rather than over the preferences, so switching the language in a test does
|
* Over lambdas rather than over the preferences, so switching the language in a test does
|
||||||
@@ -33,31 +37,46 @@ class GuiOptionsTabStateTest {
|
|||||||
* that gets saved, and it has to do so through the store, which is what repaints the
|
* 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.
|
* open windows. A store wired to anything else would pass a weaker test.
|
||||||
*/
|
*/
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
val state = GuiOptionsTabState(prefs, LanguageStoreFactory.create(prefs, Locale.UK))
|
val draft = SettingsDraft()
|
||||||
|
val state = GuiOptionsTabState(prefs, draft, LanguageStoreFactory.create(prefs, Locale.UK))
|
||||||
|
|
||||||
state.language = "de"
|
state.language = "de"
|
||||||
|
|
||||||
assertEquals("de", state.language)
|
assertEquals("de", state.language)
|
||||||
assertEquals("de", prefs.guiOptions_language)
|
assertEquals("de", prefs.guiOptions_language, "the language switches at once, without Apply")
|
||||||
|
assertFalse(draft.hasPendingChanges)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `reading comes straight from the preferences`() {
|
fun `reading comes straight from the preferences`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
prefs.setGUI_darkModeActiveByDefault(true)
|
prefs.setGUI_darkModeActiveByDefault(true)
|
||||||
|
|
||||||
assertTrue(GuiOptionsTabState(prefs, languageStore()).gUI_darkModeActiveByDefault)
|
assertTrue(GuiOptionsTabState(prefs, SettingsDraft(), languageStore()).gUI_darkModeActiveByDefault)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `writing reaches the preferences at once`() {
|
fun `an edit waits for apply, and discarding drops it`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
val state = GuiOptionsTabState(prefs, languageStore())
|
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())
|
||||||
|
|
||||||
state.guiOptions_defaultFilterPublicMsgs = true
|
state.guiOptions_defaultFilterPublicMsgs = true
|
||||||
state.guiOptions_showFreshCallHintInBandColumns = true
|
state.guiOptions_showFreshCallHintInBandColumns = true
|
||||||
|
draft.apply()
|
||||||
|
|
||||||
assertTrue(prefs.isGuiOptions_defaultFilterPublicMsgs())
|
assertTrue(prefs.isGuiOptions_defaultFilterPublicMsgs())
|
||||||
assertTrue(prefs.isGuiOptions_showFreshCallHintInBandColumns())
|
assertTrue(prefs.isGuiOptions_showFreshCallHintInBandColumns())
|
||||||
@@ -68,8 +87,8 @@ class GuiOptionsTabStateTest {
|
|||||||
// The JavaFX tab offered them as four separate checkboxes, not a choice of
|
// 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
|
// one. Turning them into a radio group would take a combination away that
|
||||||
// an operator may have configured.
|
// an operator may have configured.
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
val state = GuiOptionsTabState(prefs, languageStore())
|
val state = GuiOptionsTabState(prefs, ImmediateDraft(), languageStore())
|
||||||
|
|
||||||
state.guiOptions_defaultFilterPublicMsgs = true
|
state.guiOptions_defaultFilterPublicMsgs = true
|
||||||
state.guiOptions_defaultFilterPmToMe = true
|
state.guiOptions_defaultFilterPmToMe = true
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.hasSetTextAction
|
||||||
|
import androidx.compose.ui.test.hasText
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.compose.ui.test.performTextReplacement
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.ImmediateDraft
|
||||||
|
import kst4contest.observe.SimpleRoster
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import kst4contest.view.compose.awaitNode
|
||||||
|
import kst4contest.view.compose.awaitGone
|
||||||
|
import kst4contest.view.compose.inDialog
|
||||||
|
import kst4contest.view.compose.inPopup
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two tabs built around a list, drawn: Shortcuts and the worked-station database (#107).
|
||||||
|
*
|
||||||
|
* The shortcut lists are what the button row above the send line and the snippet menus are
|
||||||
|
* built from. Their write-through has a history: the refresh hook was called into thin air
|
||||||
|
* after the JavaFX buttons were unplugged, so an edited shortcut reached the operator only
|
||||||
|
* after the window was rebuilt (`fe4215bc`, Remove JavaFX). The hook is what these tests see.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class ListTabsScreenTest {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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"))
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Shortcuts
|
||||||
|
|
||||||
|
private class Shortcuts(isolated: ChatPreferences) {
|
||||||
|
val prefs = isolated.apply {
|
||||||
|
lst_txtShortCutBtnList.setAll(listOf("MYQRG", "pse sked"))
|
||||||
|
lst_txtSnipList.setAll(listOf("tnx qso 73"))
|
||||||
|
}
|
||||||
|
var shortcutRefreshes = 0
|
||||||
|
var snippetRefreshes = 0
|
||||||
|
val state = ShortcutsTabState(prefs, ImmediateDraft(), { shortcutRefreshes++ }, { snippetRefreshes++ })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shortcutsShowsBothListsWithWhatTheProfileHolds() = runComposeUiTest {
|
||||||
|
val s = Shortcuts(preferences())
|
||||||
|
showTab { ShortcutsTab(s.state) { } }
|
||||||
|
|
||||||
|
onNodeWithText("Shortcut buttons above the message field").assertExists()
|
||||||
|
onNodeWithText("Text snippets (the first 10 use Ctrl+1 through Ctrl+0)").assertExists()
|
||||||
|
onNode(hasSetTextAction() and hasText("pse sked")).assertExists()
|
||||||
|
onNode(hasSetTextAction() and hasText("tnx qso 73")).assertExists()
|
||||||
|
onNodeWithText("Add shortcut").assertExists()
|
||||||
|
onNodeWithText("Add new snippet").assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anEditedShortcutIsWrittenAndTheButtonsAreRefreshed() = runComposeUiTest {
|
||||||
|
val s = Shortcuts(preferences())
|
||||||
|
showTab { ShortcutsTab(s.state) { } }
|
||||||
|
|
||||||
|
val row = onNode(hasSetTextAction() and hasText("pse sked"))
|
||||||
|
row.performScrollTo().performClick()
|
||||||
|
row.performTextReplacement("pse sked 432")
|
||||||
|
onNode(hasSetTextAction() and hasText("pse sked 432")).performKeyInput { pressKey(Key.Enter) }
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(listOf("MYQRG", "pse sked 432"), s.prefs.lst_txtShortCutBtnList.snapshot())
|
||||||
|
assertTrue(s.shortcutRefreshes > 0, "the shortcut buttons were not told")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addingASnippetPutsTheTemplateOnTopAndWritesIt() = runComposeUiTest {
|
||||||
|
val s = Shortcuts(preferences())
|
||||||
|
showTab { ShortcutsTab(s.state) { } }
|
||||||
|
|
||||||
|
onNodeWithText("Add new snippet").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(ShortcutsTabState.NEW_ENTRY_TEMPLATE, "tnx qso 73"),
|
||||||
|
s.prefs.lst_txtSnipList.snapshot(),
|
||||||
|
)
|
||||||
|
assertTrue(s.snippetRefreshes > 0, "the snippet menus were not told")
|
||||||
|
assertEquals(listOf("MYQRG", "pse sked"), s.prefs.lst_txtShortCutBtnList.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Worked database
|
||||||
|
|
||||||
|
private class Worked(affected: Int = 12) {
|
||||||
|
val roster = SimpleRoster<ChatMember>().apply {
|
||||||
|
setAll(listOf(ChatMember().apply { callSign = "DL1ABC"; isWorked = true; isWorked144 = true }))
|
||||||
|
}
|
||||||
|
var resets = 0
|
||||||
|
var guiRefreshes = 0
|
||||||
|
var viewRefreshes = 0
|
||||||
|
val state = WorkedDatabaseTabState(
|
||||||
|
resetInDatabase = { resets++; affected },
|
||||||
|
refreshGuiLists = { guiRefreshes++ },
|
||||||
|
refreshView = { viewRefreshes++ },
|
||||||
|
workedRoster = roster,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theWorkedDatabaseShowsItsRows() = runComposeUiTest {
|
||||||
|
val w = Worked()
|
||||||
|
showTab { WorkedDatabaseTab(w.state) }
|
||||||
|
|
||||||
|
onNodeWithText("Worked station database").assertExists()
|
||||||
|
onNodeWithText("DL1ABC").assertExists()
|
||||||
|
onNodeWithText("wkd").assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun refreshReReadsTheRoster() = runComposeUiTest {
|
||||||
|
val w = Worked()
|
||||||
|
showTab { WorkedDatabaseTab(w.state) }
|
||||||
|
|
||||||
|
runOnIdle { w.roster.setAll(listOf(ChatMember().apply { callSign = "OK1XYZ" })) }
|
||||||
|
onNodeWithText("Refresh worked database").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(1, w.viewRefreshes)
|
||||||
|
onNodeWithText("OK1XYZ").assertExists()
|
||||||
|
onNodeWithText("DL1ABC").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nothing is touched until the dialog is confirmed; then the database goes first. */
|
||||||
|
@Test
|
||||||
|
fun aResetAsksFirstAndReportsWhatItDid() = runComposeUiTest {
|
||||||
|
val w = Worked(affected = 12)
|
||||||
|
showTab { WorkedDatabaseTab(w.state) }
|
||||||
|
|
||||||
|
onNodeWithText("Reset worked, NOT-QRV and grid data...").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
awaitNode(inDialog("Reset contest data"))
|
||||||
|
assertEquals(0, w.resets, "the reset ran before it was confirmed")
|
||||||
|
|
||||||
|
onNode(inDialog("Cancel")).performClick()
|
||||||
|
awaitGone(inDialog("Reset contest data"))
|
||||||
|
assertEquals(0, w.resets)
|
||||||
|
|
||||||
|
onNodeWithText("Reset worked, NOT-QRV and grid data...").performClick()
|
||||||
|
waitForIdle()
|
||||||
|
awaitNode(inDialog("Reset data")).performClick()
|
||||||
|
awaitGone(inDialog("Reset data"))
|
||||||
|
|
||||||
|
assertEquals(1, w.resets)
|
||||||
|
assertEquals(1, w.guiRefreshes)
|
||||||
|
onNodeWithText("Last reset affected 12 rows.").assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFailedResetIsSaidAndLeavesTheListsAlone() = runComposeUiTest {
|
||||||
|
val w = Worked(affected = -1)
|
||||||
|
showTab { WorkedDatabaseTab(w.state) }
|
||||||
|
|
||||||
|
onNodeWithText("Reset worked, NOT-QRV and grid data...").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
awaitNode(inDialog("Reset data")).performClick()
|
||||||
|
awaitGone(inDialog("Reset data"))
|
||||||
|
|
||||||
|
awaitNode(
|
||||||
|
inDialog(
|
||||||
|
"The contest data could not be reset. The internal database may be unavailable or " +
|
||||||
|
"inconsistent. No successful reset was confirmed.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(0, w.guiRefreshes, "the lists were cleared although the database was not")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
class LogSynchTabStateTest {
|
class LogSynchTabStateTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
private class Session(detected: String? = null) {
|
private inner class Session {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
var restarts = 0
|
var restarts = 0
|
||||||
var stops = 0
|
var stops = 0
|
||||||
val detectedBroadcast = detected
|
val draft = SettingsDraft()
|
||||||
|
|
||||||
fun state() = LogSynchTabState(
|
fun state() = LogSynchTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
restartWintestListener = { restarts++ },
|
restartWintestListener = { restarts++ },
|
||||||
stopWintestListener = { stops++ },
|
stopWintestListener = { stops++ },
|
||||||
detectWintestBroadcastAddress = { detectedBroadcast },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,23 +45,44 @@ class LogSynchTabStateTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `writing reaches the preferences at once, without a confirmation step`() {
|
fun `an edit waits for apply, the chosen log file does not`() {
|
||||||
// The JavaFX window had no value-collecting confirmation: each control wrote
|
|
||||||
// straight into ChatPreferences, and "Save settings" only persisted them.
|
|
||||||
val s = Session()
|
val s = Session()
|
||||||
|
s.prefs.setLogsynch_ucxUDPWkdCallListenerPort(12060)
|
||||||
|
s.prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(false)
|
||||||
val state = s.state()
|
val state = s.state()
|
||||||
|
|
||||||
state.logsynch_fileBasedWkdCallInterpreterEnabled = true
|
state.logsynch_fileBasedWkdCallInterpreterEnabled = true
|
||||||
state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = "/home/op/log.adi"
|
state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = "/home/op/log.adi"
|
||||||
state.logsynch_ucxUDPWkdCallListenerEnabled = true
|
state.logsynch_ucxUDPWkdCallListenerEnabled = true
|
||||||
state.logsynch_ucxUDPWkdCallListenerPort = 12060
|
state.logsynch_ucxUDPWkdCallListenerPort = 12061
|
||||||
state.logsynch_wintestNetworkPort = 9871
|
state.logsynch_wintestNetworkPort = 9879
|
||||||
|
|
||||||
|
// The file chooser is an action: the chosen path is written when chosen.
|
||||||
|
assertEquals("/home/op/log.adi", s.prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly())
|
||||||
|
assertFalse(s.prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled(), "an edit took effect before Apply")
|
||||||
|
assertEquals(12060, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
|
||||||
|
assertEquals(12061, state.logsynch_ucxUDPWkdCallListenerPort, "the tab shows the edit")
|
||||||
|
|
||||||
|
s.draft.apply()
|
||||||
|
|
||||||
assertTrue(s.prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled())
|
assertTrue(s.prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled())
|
||||||
assertEquals("/home/op/log.adi", s.prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly())
|
|
||||||
assertTrue(s.prefs.isLogsynch_ucxUDPWkdCallListenerEnabled())
|
assertTrue(s.prefs.isLogsynch_ucxUDPWkdCallListenerEnabled())
|
||||||
assertEquals(12060, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
|
assertEquals(12061, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
|
||||||
assertEquals(9871, s.prefs.getLogsynch_wintestNetworkPort())
|
assertEquals(9879, s.prefs.getLogsynch_wintestNetworkPort())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a discarded edit changes nothing and touches no listener`() {
|
||||||
|
val s = Session()
|
||||||
|
val state = s.state()
|
||||||
|
val enabled = s.prefs.isLogsynch_wintestNetworkListenerEnabled()
|
||||||
|
|
||||||
|
state.logsynch_wintestNetworkListenerEnabled = !enabled
|
||||||
|
state.logsynch_wintestNetworkPort = 9879
|
||||||
|
s.draft.discard()
|
||||||
|
|
||||||
|
assertEquals(enabled, s.prefs.isLogsynch_wintestNetworkListenerEnabled())
|
||||||
|
assertEquals(0, s.restarts + s.stops)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -66,91 +90,84 @@ class LogSynchTabStateTest {
|
|||||||
// The JavaFX fields committed getText().trim(); a stray space in a station
|
// The JavaFX fields committed getText().trim(); a stray space in a station
|
||||||
// name or a broadcast address is a protocol value, not decoration.
|
// name or a broadcast address is a protocol value, not decoration.
|
||||||
val s = Session()
|
val s = Session()
|
||||||
|
s.prefs.setLogsynch_wintestNetworkStationNameOfKST("KST")
|
||||||
val state = s.state()
|
val state = s.state()
|
||||||
|
|
||||||
state.logsynch_wintestNetworkStationNameOfKST = " KST "
|
state.logsynch_wintestNetworkStationNameOfKST = "KST "
|
||||||
state.logsynch_wintestNetworkBroadcastAddress = " 192.168.1.255 "
|
assertFalse(s.draft.hasPendingChanges, "a trailing space is no change")
|
||||||
|
|
||||||
assertEquals("KST", s.prefs.getLogsynch_wintestNetworkStationNameOfKST())
|
state.logsynch_wintestNetworkStationNameOfKST = " KST2 "
|
||||||
|
state.logsynch_wintestNetworkBroadcastAddress = " 192.168.1.255 "
|
||||||
|
s.draft.apply()
|
||||||
|
|
||||||
|
assertEquals("KST2", s.prefs.getLogsynch_wintestNetworkStationNameOfKST())
|
||||||
assertEquals("192.168.1.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
|
assertEquals("192.168.1.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `enabling the Win-Test listener starts it and disabling stops it`() {
|
fun `applying the Win-Test switch starts or stops the listener`() {
|
||||||
val s = Session()
|
val s = Session()
|
||||||
|
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
|
||||||
val state = s.state()
|
val state = s.state()
|
||||||
|
|
||||||
state.logsynch_wintestNetworkListenerEnabled = true
|
state.logsynch_wintestNetworkListenerEnabled = true
|
||||||
|
assertEquals(0, s.restarts, "the listener followed the box before Apply")
|
||||||
|
s.draft.apply()
|
||||||
|
|
||||||
assertTrue(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
|
assertTrue(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
|
||||||
assertEquals(1, s.restarts, "the running listener must follow the setting")
|
assertEquals(1, s.restarts, "the running listener must follow the setting")
|
||||||
|
|
||||||
state.logsynch_wintestNetworkListenerEnabled = false
|
state.logsynch_wintestNetworkListenerEnabled = false
|
||||||
|
s.draft.apply()
|
||||||
|
|
||||||
assertFalse(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
|
assertFalse(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
|
||||||
assertEquals(1, s.stops)
|
assertEquals(1, s.stops)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a committed port change rebinds the listener only while it is enabled`() {
|
fun `an applied port change rebinds the listener only while it is enabled`() {
|
||||||
// Deliberately not on every keystroke: the port write goes through at once,
|
|
||||||
// but rebinding a UDP socket is bound to the commit, as the JavaFX field's
|
|
||||||
// focus-lost handler did.
|
|
||||||
val s = Session()
|
val s = Session()
|
||||||
val state = s.state()
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Switched off explicitly: ChatPreferences ships
|
* Switched off explicitly: ChatPreferences ships
|
||||||
* logsynch_wintestNetworkListenerEnabled = true (ChatPreferences.java:254), so
|
* logsynch_wintestNetworkListenerEnabled = true (ChatPreferences.java:254), so
|
||||||
* a fresh configuration already listens.
|
* a fresh configuration already listens.
|
||||||
*/
|
*/
|
||||||
state.logsynch_wintestNetworkListenerEnabled = false
|
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
|
||||||
val restartsAfterDisable = s.restarts
|
val state = s.state()
|
||||||
|
|
||||||
state.logsynch_wintestNetworkPort = 9872
|
state.logsynch_wintestNetworkPort = 9872
|
||||||
assertEquals(restartsAfterDisable, s.restarts, "a keystroke must not rebind the socket")
|
s.draft.apply()
|
||||||
|
assertEquals(0, s.restarts, "a disabled listener has nothing to rebind")
|
||||||
|
|
||||||
state.commitWintestNetworkPort()
|
s.prefs.setLogsynch_wintestNetworkListenerEnabled(true)
|
||||||
assertEquals(restartsAfterDisable, s.restarts, "a disabled listener has nothing to rebind")
|
|
||||||
|
|
||||||
state.logsynch_wintestNetworkListenerEnabled = true
|
|
||||||
val restartsAfterEnable = s.restarts
|
|
||||||
state.logsynch_wintestNetworkPort = 9873
|
state.logsynch_wintestNetworkPort = 9873
|
||||||
state.commitWintestNetworkPort()
|
assertEquals(0, s.restarts, "a keystroke must not rebind the socket")
|
||||||
|
s.draft.apply()
|
||||||
|
|
||||||
assertEquals(9873, s.prefs.getLogsynch_wintestNetworkPort())
|
assertEquals(9873, s.prefs.getLogsynch_wintestNetworkPort())
|
||||||
assertEquals(restartsAfterEnable + 1, s.restarts)
|
assertEquals(1, s.restarts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Switch and port in one apply: one restart, on the new port. Two restarts in a row can
|
||||||
|
* fail on the port the first has not released yet.
|
||||||
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `a still-default broadcast address is replaced by the detected one`() {
|
fun `switch and port applied together rebind once, on the new port`() {
|
||||||
val s = Session(detected = "192.168.178.255")
|
val s = Session()
|
||||||
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
|
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
|
||||||
|
var portAtRestart = -1
|
||||||
|
val state = LogSynchTabState(
|
||||||
|
s.prefs, s.draft,
|
||||||
|
restartWintestListener = { s.restarts++; portAtRestart = s.prefs.getLogsynch_wintestNetworkPort() },
|
||||||
|
stopWintestListener = { s.stops++ },
|
||||||
|
)
|
||||||
|
|
||||||
s.state()
|
state.logsynch_wintestNetworkListenerEnabled = true
|
||||||
|
state.logsynch_wintestNetworkPort = 9874
|
||||||
|
s.draft.apply()
|
||||||
|
|
||||||
assertEquals("192.168.178.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress(),
|
assertEquals(1, s.restarts)
|
||||||
"the JavaFX window auto-detected the subnet broadcast while the stored "
|
assertEquals(9874, portAtRestart)
|
||||||
+ "value was still the catch-all default")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a broadcast address the operator chose is never overwritten`() {
|
|
||||||
val s = Session(detected = "192.168.178.255")
|
|
||||||
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("10.0.0.255")
|
|
||||||
|
|
||||||
s.state()
|
|
||||||
|
|
||||||
assertEquals("10.0.0.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a failed detection leaves the default in place`() {
|
|
||||||
val s = Session(detected = null)
|
|
||||||
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
|
|
||||||
|
|
||||||
s.state()
|
|
||||||
|
|
||||||
assertEquals("255.255.255.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+48
-10
@@ -1,33 +1,71 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
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.Test
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
class MessageHandlingTabStateTest {
|
class MessageHandlingTabStateTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the state starts from the stored preferences`() {
|
fun `the state starts from the stored preferences`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
prefs.setMessageHandling_autoAnswerEnabled(true)
|
prefs.setMessageHandling_autoAnswerEnabled(true)
|
||||||
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true)
|
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true)
|
||||||
prefs.setMessageHandling_autoAnswerTextMainCat("qrv 144.300")
|
prefs.setMessageHandling_autoAnswerTextMainCat("qrv 144.300")
|
||||||
|
|
||||||
val state = MessageHandlingTabState(prefs)
|
val state = MessageHandlingTabState(prefs, ImmediateDraft())
|
||||||
|
|
||||||
assertTrue(state.autoAnswerEnabled)
|
assertTrue(state.autoAnswerEnabled)
|
||||||
assertTrue(state.autoAnswerToQRGRequestEnabled)
|
assertTrue(state.autoAnswerToQRGRequestEnabled)
|
||||||
assertEquals("qrv 144.300", state.autoAnswerText)
|
assertEquals("qrv 144.300", state.autoAnswerText)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an edit waits for apply, and the recording follows only then`() {
|
||||||
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
val recording = mutableListOf<Boolean>()
|
||||||
|
val state = MessageHandlingTabState(prefs, draft) { recording += it }
|
||||||
|
|
||||||
|
state.autoAnswerText = "qrv 432.200"
|
||||||
|
state.debugModeToFileEnabled = !prefs.isMessageHandling_debugModeToFileEnabled()
|
||||||
|
val wanted = state.debugModeToFileEnabled
|
||||||
|
|
||||||
|
assertEquals("qrv 432.200", state.autoAnswerText, "the tab shows the edit")
|
||||||
|
assertTrue(recording.isEmpty(), "the recording followed before Apply")
|
||||||
|
|
||||||
|
draft.apply()
|
||||||
|
|
||||||
|
assertEquals("qrv 432.200", prefs.getMessageHandling_autoAnswerTextSecondCat())
|
||||||
|
assertEquals(listOf(wanted), recording)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a discarded edit changes nothing`() {
|
||||||
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
|
prefs.setMessageHandling_autoAnswerEnabled(false)
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
val state = MessageHandlingTabState(prefs, draft)
|
||||||
|
|
||||||
|
state.autoAnswerEnabled = true
|
||||||
|
draft.discard()
|
||||||
|
|
||||||
|
assertFalse(prefs.isMessageHandling_autoAnswerEnabled())
|
||||||
|
assertFalse(state.autoAnswerEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `one checkbox drives both category flags`() {
|
fun `one checkbox drives both category flags`() {
|
||||||
// The JavaFX window offered a single checkbox and wrote both preferences from
|
// The JavaFX window offered a single checkbox and wrote both preferences from
|
||||||
// it; keeping only the main one would leave the second chat on its old value.
|
// it; keeping only the main one would leave the second chat on its old value.
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
val state = MessageHandlingTabState(prefs)
|
val state = MessageHandlingTabState(prefs, ImmediateDraft())
|
||||||
|
|
||||||
state.autoAnswerEnabled = true
|
state.autoAnswerEnabled = true
|
||||||
|
|
||||||
@@ -42,8 +80,8 @@ class MessageHandlingTabStateTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `one text field drives both category texts`() {
|
fun `one text field drives both category texts`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
val state = MessageHandlingTabState(prefs)
|
val state = MessageHandlingTabState(prefs, ImmediateDraft())
|
||||||
|
|
||||||
state.autoAnswerText = "qrv 432.200"
|
state.autoAnswerText = "qrv 432.200"
|
||||||
|
|
||||||
@@ -52,9 +90,9 @@ class MessageHandlingTabStateTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the QRG reply flag writes through on its own`() {
|
fun `the QRG reply flag is stored on its own`() {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
val state = MessageHandlingTabState(prefs)
|
val state = MessageHandlingTabState(prefs, ImmediateDraft())
|
||||||
|
|
||||||
state.autoAnswerToQRGRequestEnabled = true
|
state.autoAnswerToQRGRequestEnabled = true
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertIsOff
|
||||||
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
|
import androidx.compose.ui.test.hasSetTextAction
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onLast
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.compose.ui.test.performTextReplacement
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.ImmediateDraft
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.observe.SimpleRoster
|
||||||
|
import kst4contest.view.i18n.LanguageStore
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import kst4contest.view.compose.awaitNode
|
||||||
|
import kst4contest.view.compose.awaitGone
|
||||||
|
import kst4contest.view.compose.inDialog
|
||||||
|
import kst4contest.view.compose.inPopup
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tabs that decide what this station says and hears, drawn: Message handling, GUI,
|
||||||
|
* Beacon and Notification (#107).
|
||||||
|
*
|
||||||
|
* Each is composed headless, shows its sections and key controls, and an edit made through
|
||||||
|
* the drawn control reaches the preferences. The message-handling tab carries the
|
||||||
|
* debug-to-file switch that the Compose redraw had dropped and `c8ce0e98` gave back.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class MessagingTabsScreenTest {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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"))
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Message handling
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageHandlingWritesBothCategoriesAndAppliesTheFileRecording() = runComposeUiTest {
|
||||||
|
val prefs = preferences().apply {
|
||||||
|
isMessageHandling_autoAnswerEnabled = false
|
||||||
|
isMessageHandling_debugModeToFileEnabled = false
|
||||||
|
}
|
||||||
|
val recording = mutableListOf<Boolean>()
|
||||||
|
val state = MessageHandlingTabState(prefs, ImmediateDraft()) { recording += it }
|
||||||
|
showTab { MessageHandlingTab(state) }
|
||||||
|
|
||||||
|
onNodeWithText("Automatic answer to private messages").assertExists()
|
||||||
|
onNodeWithText("Diagnostics").assertExists()
|
||||||
|
|
||||||
|
toggle("Answer private messages automatically")
|
||||||
|
assertTrue(prefs.isMessageHandling_autoAnswerEnabled)
|
||||||
|
assertTrue(prefs.isMessageHandling_autoAnswerEnabledSecondCat, "the second category was left behind")
|
||||||
|
|
||||||
|
type("Answer text", "qrv 432.200")
|
||||||
|
assertEquals("qrv 432.200", prefs.messageHandling_autoAnswerTextMainCat)
|
||||||
|
assertEquals("qrv 432.200", prefs.messageHandling_autoAnswerTextSecondCat)
|
||||||
|
|
||||||
|
// The c8ce0e98 control: it writes the preference and asks the application to act.
|
||||||
|
toggle("Record the whole session to file")
|
||||||
|
assertTrue(prefs.isMessageHandling_debugModeToFileEnabled)
|
||||||
|
assertEquals(listOf(true), recording)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ GUI
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun guiOptionsWritesTheStartupChoicesAndTheLanguage() = runComposeUiTest {
|
||||||
|
val prefs = preferences().apply { isGUI_darkModeActiveByDefault = false }
|
||||||
|
var storedLanguage = "en"
|
||||||
|
val languages = LanguageStore({ storedLanguage }, { storedLanguage = it }, Locale.UK)
|
||||||
|
val state = GuiOptionsTabState(prefs, ImmediateDraft(), languages)
|
||||||
|
showTab { GuiOptionsTab(state) }
|
||||||
|
|
||||||
|
onNodeWithText("Startup design").assertExists()
|
||||||
|
onNodeWithText("Message filters active at startup").assertExists()
|
||||||
|
checkBeside("Start in the evening design").assertIsOff()
|
||||||
|
|
||||||
|
toggle("Start in the evening design")
|
||||||
|
assertTrue(prefs.isGUI_darkModeActiveByDefault)
|
||||||
|
|
||||||
|
val before = prefs.isGuiOptions_defaultFilterPublicMsgs
|
||||||
|
toggle("Public messages")
|
||||||
|
assertEquals(!before, prefs.isGuiOptions_defaultFilterPublicMsgs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
onNodeWithText("EN").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
awaitNode(inPopup("DE")).performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals("de", storedLanguage, "the picked language was not stored")
|
||||||
|
assertEquals("de", languages.language)
|
||||||
|
} finally {
|
||||||
|
// use() also sets the JVM-wide CurrentStrings; leave the next test the base language.
|
||||||
|
runOnIdle { languages.use("en") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Beacon
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A draft that applies at once by default, so the tests below keep pinning the field's
|
||||||
|
* commit rules (#128) against the stored value; the drafted behaviour is pinned by the
|
||||||
|
* tests that pass a real draft.
|
||||||
|
*/
|
||||||
|
private class Beacon(
|
||||||
|
val prefs: ChatPreferences,
|
||||||
|
val refuse: Boolean = false,
|
||||||
|
val draft: SettingsDraft = ImmediateDraft(),
|
||||||
|
) {
|
||||||
|
var restarts = 0
|
||||||
|
val refusals = mutableListOf<String>()
|
||||||
|
val state = BeaconTabState(
|
||||||
|
prefs,
|
||||||
|
draft,
|
||||||
|
validateBeaconTemplate = { if (refuse) throw IllegalArgumentException("no variable %foo%") },
|
||||||
|
restartBeaconTimer = { restarts++ },
|
||||||
|
mainCategoryName = "Microwave",
|
||||||
|
secondCategoryName = "144/432 MHz",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun beaconShowsOneSectionPerCategoryAndCommitsOnEnter() = runComposeUiTest {
|
||||||
|
val s = Beacon(preferences())
|
||||||
|
showTab { BeaconTab(s.state) { s.refusals += it } }
|
||||||
|
|
||||||
|
onNodeWithText("CQ beacon for Microwave").assertExists()
|
||||||
|
onNodeWithText("CQ beacon for 144/432 MHz").assertExists()
|
||||||
|
onNodeWithText("Shared interval").assertExists()
|
||||||
|
|
||||||
|
typeAndCommit("Beacon message", "cq de dn9apw %MYQRG%", occurrence = 1)
|
||||||
|
assertEquals("cq de dn9apw %MYQRG%", s.prefs.bcn_beaconTextSecondCat)
|
||||||
|
|
||||||
|
typeAndCommit("Interval in whole minutes", "7")
|
||||||
|
assertEquals(7, s.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||||
|
assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A refused template is reported, not stored, and the field goes back to what is stored. */
|
||||||
|
@Test
|
||||||
|
fun beaconRefusesAnInvalidTemplateAndPutsTheFieldBack() = runComposeUiTest {
|
||||||
|
val s = Beacon(preferences(), refuse = true)
|
||||||
|
s.prefs.bcn_beaconTextMainCat = "cq cq"
|
||||||
|
showTab { BeaconTab(s.state) { s.refusals += it } }
|
||||||
|
|
||||||
|
typeAndCommit("Beacon message", "cq %foo%")
|
||||||
|
|
||||||
|
assertEquals("cq cq", s.prefs.bcn_beaconTextMainCat)
|
||||||
|
assertEquals(1, s.refusals.size)
|
||||||
|
fieldBeside("Beacon message").assertTextEquals("cq cq")
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Opening the tab used to commit all three fields once, because a committed field's
|
||||||
|
* focus callback fires with "not focused" when it is first attached: the beacon timer
|
||||||
|
* was restarted and a stored template the validator refuses was reported, although
|
||||||
|
* the operator had done nothing but look.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun openingTheBeaconTabNeitherRestartsTheTimerNorValidates() = runComposeUiTest {
|
||||||
|
val s = Beacon(preferences(), refuse = true)
|
||||||
|
s.prefs.bcn_beaconTextMainCat = "cq %foo%"
|
||||||
|
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
|
||||||
|
showTab { BeaconTab(s.state) { s.refusals += it } }
|
||||||
|
|
||||||
|
assertEquals(0, s.restarts, "opening the tab restarted the beacon timer")
|
||||||
|
assertTrue(s.refusals.isEmpty(), "opening the tab reported: ${s.refusals}")
|
||||||
|
assertEquals("cq %foo%", s.prefs.bcn_beaconTextMainCat)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The tab reads the interval from a plain preference getter, so nothing recomposes it
|
||||||
|
* after Enter commits. Compared with that stale value, leaving the field afterwards
|
||||||
|
* looked like a second edit and restarted the timer again.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun enterAndThenLeavingTheFieldRestartsTheTimerOnce() = runComposeUiTest {
|
||||||
|
val s = Beacon(preferences())
|
||||||
|
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
|
||||||
|
showTab { BeaconTab(s.state) { s.refusals += it } }
|
||||||
|
|
||||||
|
typeAndCommit("Interval in whole minutes", "9")
|
||||||
|
fieldBeside("Beacon message").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||||
|
assertEquals(1, s.restarts, "one edit restarted the beacon timer more than once")
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Pins what happens to a typed but uncommitted entry when the field leaves the screen
|
||||||
|
* while it has the focus -- the settings window switching to another tab, or closing.
|
||||||
|
* Compose clears the focus of a removed focused node and reports that to the field as a
|
||||||
|
* focus loss, so the entry is committed, exactly once. That was so before the field
|
||||||
|
* learned to tell a real focus loss from its first attachment, and must stay so: an
|
||||||
|
* operator who typed a value and switched tabs expects it to count.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aFieldRemovedWhileTypingCommitsTheEntryOnce() = runComposeUiTest {
|
||||||
|
val s = Beacon(preferences())
|
||||||
|
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
|
||||||
|
val visible = mutableStateOf(true)
|
||||||
|
showTab { if (visible.value) BeaconTab(s.state) { s.refusals += it } }
|
||||||
|
|
||||||
|
val field = fieldBeside("Interval in whole minutes")
|
||||||
|
field.performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
field.performTextReplacement("9")
|
||||||
|
waitForIdle()
|
||||||
|
runOnIdle { visible.value = false }
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the typed entry was lost")
|
||||||
|
assertEquals(1, s.restarts)
|
||||||
|
assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The stored value can change under a field -- a profile switch puts 5 back after the field
|
||||||
|
* committed 9. The field used to keep showing 9 then, because nothing recomposed the tab
|
||||||
|
* after a commit, and #128 made leaving it commit the shown 9 again. Since the edits go
|
||||||
|
* through the settings draft, which is Compose state, a commit recomposes the tab: the field
|
||||||
|
* follows the stored value, shows the 5, and leaving it commits nothing. Either way what the
|
||||||
|
* field shows is what is stored once the operator has left it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aValueResetBehindTheFieldIsShownAndNotCommittedAgain() = runComposeUiTest {
|
||||||
|
val s = Beacon(preferences())
|
||||||
|
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
|
||||||
|
val generation = mutableStateOf(0)
|
||||||
|
val reportA: (String) -> Unit = { s.refusals += it }
|
||||||
|
val reportB: (String) -> Unit = { s.refusals += it }
|
||||||
|
// A different callback per generation, so bumping it recomposes BeaconTab itself.
|
||||||
|
showTab { BeaconTab(s.state, if (generation.value % 2 == 0) reportA else reportB) }
|
||||||
|
|
||||||
|
typeAndCommit("Interval in whole minutes", "9")
|
||||||
|
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||||
|
|
||||||
|
runOnIdle {
|
||||||
|
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
|
||||||
|
generation.value++
|
||||||
|
}
|
||||||
|
waitForIdle()
|
||||||
|
fieldBeside("Interval in whole minutes").assertTextEquals("5")
|
||||||
|
|
||||||
|
fieldBeside("Beacon message").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(5, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the field committed a value it no longer showed")
|
||||||
|
assertEquals(1, s.restarts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leaving the field commits what was typed, without Enter, and restarts the timer once. */
|
||||||
|
@Test
|
||||||
|
fun aTypedIntervalIsCommittedWhenTheFieldIsLeft() = runComposeUiTest {
|
||||||
|
val s = Beacon(preferences())
|
||||||
|
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
|
||||||
|
showTab { BeaconTab(s.state) { s.refusals += it } }
|
||||||
|
|
||||||
|
typeAndLeave("Interval in whole minutes", "9", leaveTo = "Beacon message")
|
||||||
|
|
||||||
|
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||||
|
assertEquals(1, s.restarts)
|
||||||
|
|
||||||
|
typeAndLeave("Beacon message", "cq de dn9apw", leaveTo = "Beacon message", leaveToOccurrence = 1)
|
||||||
|
|
||||||
|
assertEquals("cq de dn9apw", s.prefs.bcn_beaconTextMainCat)
|
||||||
|
assertEquals(1, s.restarts, "leaving a template field restarted the timer")
|
||||||
|
assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Notification
|
||||||
|
|
||||||
|
private class Notification(val prefs: ChatPreferences, val draft: SettingsDraft = ImmediateDraft()) {
|
||||||
|
init {
|
||||||
|
prefs.isNotify_dxClusterServerEnabled = false
|
||||||
|
}
|
||||||
|
val monitored = SimpleRoster<String>()
|
||||||
|
val serverEnabled = mutableListOf<Boolean>()
|
||||||
|
val ports = mutableListOf<Int>()
|
||||||
|
val spots = mutableListOf<ChatMember>()
|
||||||
|
val refusals = mutableListOf<String>()
|
||||||
|
val state = NotificationTabState(
|
||||||
|
prefs,
|
||||||
|
draft,
|
||||||
|
monitored,
|
||||||
|
// What the server is brought to on apply: its switch, and the port it binds.
|
||||||
|
applyDxClusterServer = {
|
||||||
|
serverEnabled += prefs.isNotify_dxClusterServerEnabled
|
||||||
|
ports += prefs.notify_dxclusterServerPort
|
||||||
|
},
|
||||||
|
broadcastTestSpot = { spots += it; "no logger connected" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun notificationStoresItsFieldsAndStartsTheClusterServer() = runComposeUiTest {
|
||||||
|
val s = Notification(preferences())
|
||||||
|
showTab { NotificationTab(s.state) { s.refusals += it } }
|
||||||
|
|
||||||
|
listOf("Sounds", "Band upgrade hints", "Local DX Cluster server", "QSO monitoring")
|
||||||
|
.forEach { onNodeWithText(it).assertExists() }
|
||||||
|
|
||||||
|
val before = s.prefs.isNotify_playSimpleSounds
|
||||||
|
toggle("Play simple sounds")
|
||||||
|
assertEquals(!before, s.prefs.isNotify_playSimpleSounds)
|
||||||
|
|
||||||
|
toggle("Run the DX Cluster server")
|
||||||
|
assertTrue(s.prefs.isNotify_dxClusterServerEnabled)
|
||||||
|
assertEquals(listOf(true), s.serverEnabled)
|
||||||
|
s.ports.clear()
|
||||||
|
|
||||||
|
typeAndCommit("Spotter callsign", "dn9apw")
|
||||||
|
assertEquals("DN9APW", s.prefs.notify_DXCSrv_SpottersCallSign.get())
|
||||||
|
|
||||||
|
val storedPort = s.prefs.notify_dxclusterServerPort
|
||||||
|
typeAndCommit("TCP port", "0")
|
||||||
|
assertEquals(1, s.refusals.size, "an impossible port was not refused")
|
||||||
|
assertEquals(storedPort, s.prefs.notify_dxclusterServerPort)
|
||||||
|
assertTrue(s.ports.isEmpty(), "the server was moved to a refused port")
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Opening the tab used to commit the port and the spotter callsign once, because a
|
||||||
|
* committed field's focus callback fires with "not focused" when it is first attached.
|
||||||
|
* A stored value the rule refuses was then reported the moment the tab was looked at.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun openingTheNotificationTabCommitsNothing() = runComposeUiTest {
|
||||||
|
val s = Notification(preferences())
|
||||||
|
s.prefs.setNotify_DXCSrv_SpottersCallSign("x")
|
||||||
|
showTab { NotificationTab(s.state) { s.refusals += it } }
|
||||||
|
|
||||||
|
assertTrue(s.refusals.isEmpty(), "opening the tab reported: ${s.refusals}")
|
||||||
|
assertTrue(s.ports.isEmpty(), "opening the tab moved the cluster server")
|
||||||
|
assertEquals("x", s.prefs.notify_DXCSrv_SpottersCallSign.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leaving the field commits what was typed, without Enter. */
|
||||||
|
@Test
|
||||||
|
fun aTypedSpotterCallsignAndPortAreCommittedWhenTheFieldIsLeft() = runComposeUiTest {
|
||||||
|
val s = Notification(preferences())
|
||||||
|
s.prefs.notify_dxclusterServerPort = 7300
|
||||||
|
showTab { NotificationTab(s.state) { s.refusals += it } }
|
||||||
|
|
||||||
|
typeAndLeave("Spotter callsign", "do5amf", leaveTo = "TCP port")
|
||||||
|
assertEquals("DO5AMF", s.prefs.notify_DXCSrv_SpottersCallSign.get())
|
||||||
|
|
||||||
|
typeAndLeave("TCP port", "7301", leaveTo = "Spotter callsign")
|
||||||
|
assertEquals(7301, s.prefs.notify_dxclusterServerPort)
|
||||||
|
assertEquals(listOf(7301), s.ports)
|
||||||
|
assertTrue(s.refusals.isEmpty(), "a valid entry was refused: ${s.refusals}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theFallbackBandAndTheTestSpotReachTheirTargets() = runComposeUiTest {
|
||||||
|
val s = Notification(preferences())
|
||||||
|
showTab { NotificationTab(s.state) { s.refusals += it } }
|
||||||
|
|
||||||
|
val current = s.state.notify_optionalFrequencyPrefix
|
||||||
|
onNodeWithText(current.prefix + " MHz").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
val other = Band.entries.first { it != current }
|
||||||
|
awaitNode(inPopup(other.prefix + " MHz")).performClick()
|
||||||
|
waitForIdle()
|
||||||
|
assertEquals(other.prefix, s.prefs.notify_optionalFrequencyPrefix.get())
|
||||||
|
|
||||||
|
onNodeWithText("Send a test spot").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
assertEquals("DO5AMF", s.spots.single().callSign)
|
||||||
|
assertEquals(listOf("no logger connected"), s.refusals, "the outcome was not reported")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The add button asks for the callsign first; what is confirmed reaches the roster. */
|
||||||
|
@Test
|
||||||
|
fun aMonitoredCallsignAddedThroughTheDialogReachesTheRoster() = runComposeUiTest {
|
||||||
|
val s = Notification(preferences())
|
||||||
|
showTab { NotificationTab(s.state) { s.refusals += it } }
|
||||||
|
|
||||||
|
onNodeWithText("Add monitored callsign").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
awaitNode(inDialog("Add a callsign to the monitoring list"))
|
||||||
|
|
||||||
|
onNodeWithTag(MONITORED_CALLSIGN_FIELD_TAG).performTextReplacement("DL1ABC")
|
||||||
|
waitForIdle()
|
||||||
|
awaitNode(inDialog("OK")).performClick()
|
||||||
|
awaitGone(inDialog("OK"))
|
||||||
|
|
||||||
|
assertEquals(listOf("DL1ABC"), s.monitored.snapshot())
|
||||||
|
assertFalse(s.refusals.isNotEmpty(), "the callsign was refused: ${s.refusals}")
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
-18
@@ -4,6 +4,8 @@ import kst4contest.model.Band
|
|||||||
import kst4contest.model.ChatMember
|
import kst4contest.model.ChatMember
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
import kst4contest.observe.SimpleRoster
|
import kst4contest.observe.SimpleRoster
|
||||||
|
import kst4contest.view.compose.ImmediateDraft
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
@@ -11,28 +13,104 @@ import org.junit.jupiter.api.Assertions.assertNotNull
|
|||||||
import org.junit.jupiter.api.Assertions.assertNull
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
import org.junit.jupiter.api.Assertions.assertSame
|
import org.junit.jupiter.api.Assertions.assertSame
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
class NotificationTabStateTest {
|
class NotificationTabStateTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
private class Session {
|
/*
|
||||||
val prefs = ChatPreferences()
|
* 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(val draft: SettingsDraft = ImmediateDraft()) {
|
||||||
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
val monitored = SimpleRoster<String>()
|
val monitored = SimpleRoster<String>()
|
||||||
var serverEnabledApplied: Boolean? = null
|
var serverApplies = 0
|
||||||
var portApplied: Int? = null
|
var enabledAtApply: Boolean? = null
|
||||||
|
var portAtApply: Int? = null
|
||||||
var spotSent: ChatMember? = null
|
var spotSent: ChatMember? = null
|
||||||
var spotRefusal: String? = null
|
var spotRefusal: String? = null
|
||||||
|
|
||||||
fun state() = NotificationTabState(
|
fun state() = NotificationTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
monitored,
|
monitored,
|
||||||
applyDxClusterServerEnabled = { serverEnabledApplied = it },
|
applyDxClusterServer = {
|
||||||
applyDxClusterServerPort = { portApplied = it },
|
serverApplies++
|
||||||
|
enabledAtApply = prefs.isNotify_dxClusterServerEnabled()
|
||||||
|
portAtApply = prefs.getNotify_dxclusterServerPort()
|
||||||
|
},
|
||||||
broadcastTestSpot = { spotSent = it; spotRefusal },
|
broadcastTestSpot = { spotSent = it; spotRefusal },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the sound settings read from and write to the preferences at once`() {
|
fun `an edit waits for apply, and discarding drops it`() {
|
||||||
|
val s = Session(SettingsDraft())
|
||||||
|
s.prefs.setNotify_playSimpleSounds(true)
|
||||||
|
val state = s.state()
|
||||||
|
|
||||||
|
state.notify_playSimpleSounds = false
|
||||||
|
state.notify_optionalFrequencyPrefix = Band.B_432
|
||||||
|
assertNull(state.commitSpotterCallSign("DO5AMF"))
|
||||||
|
|
||||||
|
assertTrue(s.prefs.isNotify_playSimpleSounds(), "an edit took effect before Apply")
|
||||||
|
assertFalse(state.notify_playSimpleSounds, "the tab shows the edit")
|
||||||
|
assertEquals("DO5AMF", state.notify_DXCSrv_SpottersCallSign)
|
||||||
|
|
||||||
|
s.draft.discard()
|
||||||
|
assertTrue(s.prefs.isNotify_playSimpleSounds())
|
||||||
|
assertTrue(state.notify_playSimpleSounds)
|
||||||
|
|
||||||
|
state.notify_playSimpleSounds = false
|
||||||
|
s.draft.apply()
|
||||||
|
assertFalse(s.prefs.isNotify_playSimpleSounds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the server is touched on apply only, and once for switch and port together`() {
|
||||||
|
val s = Session(SettingsDraft())
|
||||||
|
s.prefs.setNotify_dxClusterServerEnabled(false)
|
||||||
|
s.prefs.setNotify_dxclusterServerPort(8000)
|
||||||
|
val state = s.state()
|
||||||
|
|
||||||
|
state.enableDxClusterServer(true)
|
||||||
|
assertNull(state.commitDxClusterServerPort("7300"))
|
||||||
|
assertEquals(0, s.serverApplies, "the server moved before Apply")
|
||||||
|
|
||||||
|
s.draft.apply()
|
||||||
|
|
||||||
|
assertEquals(1, s.serverApplies, "two restarts in a row can fail on the port")
|
||||||
|
assertEquals(true, s.enabledAtApply)
|
||||||
|
assertEquals(7300, s.portAtApply, "the server must come up on the new port")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the monitoring list reaches the roster on apply`() {
|
||||||
|
val s = Session(SettingsDraft())
|
||||||
|
val state = s.state()
|
||||||
|
|
||||||
|
assertNull(state.addMonitoredCallSign("DN9APW"))
|
||||||
|
assertTrue(s.monitored.snapshot().isEmpty(), "the list took effect before Apply")
|
||||||
|
assertTrue(s.draft.hasPendingChanges)
|
||||||
|
|
||||||
|
s.draft.apply()
|
||||||
|
assertEquals(listOf("DN9APW"), s.monitored.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the test spot is an action and needs no apply`() {
|
||||||
|
val s = Session(SettingsDraft())
|
||||||
|
val state = s.state()
|
||||||
|
|
||||||
|
assertNull(state.sendTestSpot())
|
||||||
|
|
||||||
|
assertNotNull(s.spotSent)
|
||||||
|
assertFalse(s.draft.hasPendingChanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the sound settings read from and write to the preferences`() {
|
||||||
val s = Session()
|
val s = Session()
|
||||||
s.prefs.setNotify_playSimpleSounds(true)
|
s.prefs.setNotify_playSimpleSounds(true)
|
||||||
val state = s.state()
|
val state = s.state()
|
||||||
@@ -49,7 +127,7 @@ class NotificationTabStateTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the band-upgrade hints write through`() {
|
fun `the band-upgrade hints reach the preferences`() {
|
||||||
val s = Session()
|
val s = Session()
|
||||||
val state = s.state()
|
val state = s.state()
|
||||||
|
|
||||||
@@ -63,12 +141,13 @@ class NotificationTabStateTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `enabling the DX cluster server also tells the running session`() {
|
fun `enabling the DX cluster server also tells the running session`() {
|
||||||
val s = Session()
|
val s = Session()
|
||||||
|
s.prefs.setNotify_dxClusterServerEnabled(false)
|
||||||
val state = s.state()
|
val state = s.state()
|
||||||
|
|
||||||
state.enableDxClusterServer(true)
|
state.enableDxClusterServer(true)
|
||||||
|
|
||||||
assertTrue(s.prefs.isNotify_dxClusterServerEnabled())
|
assertTrue(s.prefs.isNotify_dxClusterServerEnabled())
|
||||||
assertEquals(true, s.serverEnabledApplied,
|
assertEquals(true, s.enabledAtApply,
|
||||||
"the JavaFX checkbox started or stopped the server as well, and the "
|
"the JavaFX checkbox started or stopped the server as well, and the "
|
||||||
+ "settings coverage test cannot see that call")
|
+ "settings coverage test cannot see that call")
|
||||||
}
|
}
|
||||||
@@ -81,7 +160,7 @@ class NotificationTabStateTest {
|
|||||||
assertNull(state.commitDxClusterServerPort("7300"))
|
assertNull(state.commitDxClusterServerPort("7300"))
|
||||||
|
|
||||||
assertEquals(7300, s.prefs.getNotify_dxclusterServerPort())
|
assertEquals(7300, s.prefs.getNotify_dxclusterServerPort())
|
||||||
assertEquals(7300, s.portApplied)
|
assertEquals(7300, s.portAtApply)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -94,7 +173,7 @@ class NotificationTabStateTest {
|
|||||||
|
|
||||||
assertNotNull(refusal, "the JavaFX field alerted and restored the previous port")
|
assertNotNull(refusal, "the JavaFX field alerted and restored the previous port")
|
||||||
assertEquals(8000, s.prefs.getNotify_dxclusterServerPort())
|
assertEquals(8000, s.prefs.getNotify_dxclusterServerPort())
|
||||||
assertNull(s.portApplied, "an refused port must not restart the server")
|
assertEquals(0, s.serverApplies, "an refused port must not restart the server")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -115,7 +194,7 @@ class NotificationTabStateTest {
|
|||||||
|
|
||||||
assertNull(state.commitDxClusterServerPort("8000"))
|
assertNull(state.commitDxClusterServerPort("8000"))
|
||||||
|
|
||||||
assertNull(s.portApplied,
|
assertEquals(0, s.serverApplies,
|
||||||
"the JavaFX field only restarted the server when the port actually changed")
|
"the JavaFX field only restarted the server when the port actually changed")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,15 +232,15 @@ class NotificationTabStateTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an unsupported stored prefix falls back to 144 and is written back`() {
|
fun `an unsupported stored prefix is shown as 144 without writing anything`() {
|
||||||
val s = Session()
|
val s = Session(SettingsDraft())
|
||||||
s.prefs.setNotify_optionalFrequencyPrefix("4711")
|
s.prefs.setNotify_optionalFrequencyPrefix("4711")
|
||||||
|
|
||||||
val state = s.state()
|
val state = s.state()
|
||||||
|
|
||||||
assertEquals(Band.B_144, state.notify_optionalFrequencyPrefix)
|
assertEquals(Band.B_144, state.notify_optionalFrequencyPrefix)
|
||||||
assertEquals("144", s.prefs.getNotify_optionalFrequencyPrefix().get(),
|
assertEquals("4711", s.prefs.getNotify_optionalFrequencyPrefix().get(),
|
||||||
"the JavaFX window repaired the stored value the same way")
|
"reading the tab wrote the preferences; repairStoredSettings does that, once")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -182,7 +261,7 @@ class NotificationTabStateTest {
|
|||||||
assertEquals(listOf("DN9APW"), state.monitoredCallSigns.texts,
|
assertEquals(listOf("DN9APW"), state.monitoredCallSigns.texts,
|
||||||
"monitoring works on the base call, so every SSID maps to the same entry")
|
"monitoring works on the base call, so every SSID maps to the same entry")
|
||||||
assertEquals(listOf("DN9APW"), s.monitored.snapshot(),
|
assertEquals(listOf("DN9APW"), s.monitored.snapshot(),
|
||||||
"the JavaFX button added to the roster at once")
|
"applied, the roster holds the entry")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.test.ComposeUiTest
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import kst4contest.controller.OperatorProfileManagementService
|
||||||
|
import kst4contest.controller.OperatorProfileStore
|
||||||
|
import kst4contest.model.OperatorProfile
|
||||||
|
import kst4contest.model.PaletteRole
|
||||||
|
import kst4contest.view.compose.JavaFxStylesheet
|
||||||
|
import kst4contest.view.compose.OperatorProfilePaletteFiles
|
||||||
|
import kst4contest.view.compose.PaletteSource
|
||||||
|
import kst4contest.view.compose.PaletteStore
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import kst4contest.view.compose.awaitNode
|
||||||
|
import kst4contest.view.compose.awaitGone
|
||||||
|
import kst4contest.view.compose.inDialog
|
||||||
|
import kst4contest.view.compose.inPopup
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two tabs that belong to the operator profile, drawn: Profiles and Colours (#107).
|
||||||
|
*
|
||||||
|
* Profiles: only what refuses before the service writes anything, or what writes into a
|
||||||
|
* temporary registry, is exercised. Creating, renaming and deleting a real profile reach
|
||||||
|
* into the application directory under the operator's home ([ProfilesTabStateTest] says why
|
||||||
|
* that is not done here either).
|
||||||
|
*
|
||||||
|
* Colours: a store made of lambdas and stylesheet files in a temporary directory, so nothing
|
||||||
|
* reaches the operator's profile. The theme reading the store is `ThemeReadsPaletteStoreTest`;
|
||||||
|
* this is the tab that writes into it.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class ProfileTabsScreenTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
lateinit var directory: Path
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Profiles
|
||||||
|
|
||||||
|
private fun profilesState(activated: MutableList<OperatorProfile> = mutableListOf()) =
|
||||||
|
ProfilesTabState(
|
||||||
|
OperatorProfileManagementService(
|
||||||
|
OperatorProfileStore(directory.resolve("profiles.xml").toString())
|
||||||
|
),
|
||||||
|
{ null },
|
||||||
|
{ activated += it },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun profilesShowsTheDefaultProfileAndItsActions() = runComposeUiTest {
|
||||||
|
val state = profilesState()
|
||||||
|
showTab { ProfilesTab(state) }
|
||||||
|
|
||||||
|
onNodeWithText("Active profile:").assertExists()
|
||||||
|
onNodeWithText("Profile").assertExists()
|
||||||
|
onNodeWithText(state.profiles.single().displayName).assertExists()
|
||||||
|
listOf("New profile...", "Duplicate...", "Rename...", "Delete...",
|
||||||
|
"Change worked stations...", "Switch to selected profile...")
|
||||||
|
.forEach { onNodeWithText(it).assertExists() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default profile is the installation's own; deleting it is refused, and said so. */
|
||||||
|
@Test
|
||||||
|
fun deletingTheDefaultProfileIsRefusedInADialog() = runComposeUiTest {
|
||||||
|
val state = profilesState()
|
||||||
|
showTab { ProfilesTab(state) }
|
||||||
|
|
||||||
|
onNodeWithText("Delete...").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
// A selected profile is always asked about first; the refusal comes on confirmation.
|
||||||
|
awaitNode(inDialog("Delete operator profile"))
|
||||||
|
onNode(inDialog("Delete profile")).performClick()
|
||||||
|
|
||||||
|
awaitNode(
|
||||||
|
inDialog("The default profile uses the files of the installation itself and cannot be deleted."),
|
||||||
|
)
|
||||||
|
onNode(inDialog("OK")).performClick()
|
||||||
|
awaitGone(inDialog("Operator profiles"))
|
||||||
|
assertEquals(1, state.profiles.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun switchingAsksTheApplicationToActivateTheSelectedProfile() = runComposeUiTest {
|
||||||
|
val activated = mutableListOf<OperatorProfile>()
|
||||||
|
val state = profilesState(activated)
|
||||||
|
showTab { ProfilesTab(state) }
|
||||||
|
|
||||||
|
onNodeWithText("Switch to selected profile...").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(listOf(state.profiles.single()), activated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The New dialog opens and, cancelled, creates nothing. */
|
||||||
|
@Test
|
||||||
|
fun aCancelledNewProfileDialogCreatesNothing() = runComposeUiTest {
|
||||||
|
val state = profilesState()
|
||||||
|
showTab { ProfilesTab(state) }
|
||||||
|
|
||||||
|
onNodeWithText("New profile...").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
awaitNode(inDialog("New operator profile"))
|
||||||
|
|
||||||
|
onNode(inDialog("Cancel")).performClick()
|
||||||
|
awaitGone(inDialog("New operator profile"))
|
||||||
|
assertEquals(1, state.profiles.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Colours
|
||||||
|
|
||||||
|
private class Colours(directory: Path) {
|
||||||
|
val stored = mutableMapOf(false to "", true to "")
|
||||||
|
val store = PaletteStore(
|
||||||
|
shippedOf = { dark ->
|
||||||
|
JavaFxStylesheet.read(
|
||||||
|
if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
fileRolesOf = { emptyMap() },
|
||||||
|
storedOverridesOf = { dark -> stored.getValue(dark) },
|
||||||
|
storeOverrides = { dark, encoded -> stored[dark] = encoded },
|
||||||
|
)
|
||||||
|
val files = OperatorProfilePaletteFiles(
|
||||||
|
dayFile = directory.resolve("day.css").toFile(),
|
||||||
|
eveningFile = directory.resolve("evening.css").toFile(),
|
||||||
|
)
|
||||||
|
val state = ColoursTabState(store, { false }, files)
|
||||||
|
val refusals = mutableListOf<String>()
|
||||||
|
val outcomes = mutableListOf<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ComposeUiTest.show(c: Colours) {
|
||||||
|
showTab { ColoursTab(c.state, { c.refusals += it }, { c.outcomes += it }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The label a role row carries right now: its name and where its colour came from. */
|
||||||
|
private fun Colours.labelOf(role: PaletteRole, name: String): String {
|
||||||
|
val provenance = when (state.roles().single { it.role == role }.source) {
|
||||||
|
PaletteSource.SHIPPED -> "shipped"
|
||||||
|
PaletteSource.FILE -> "from file"
|
||||||
|
PaletteSource.CHANGED -> "changed"
|
||||||
|
}
|
||||||
|
return "$name ($provenance)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun coloursShowsTheSixRolesAndTheWaysBack() = runComposeUiTest {
|
||||||
|
val c = Colours(directory)
|
||||||
|
show(c)
|
||||||
|
|
||||||
|
onNodeWithText("Colours of this design").assertExists()
|
||||||
|
onNodeWithText("Ways back").assertExists()
|
||||||
|
listOf(
|
||||||
|
PaletteRole.SURFACE to "Surface (menu strip, selected rows)",
|
||||||
|
PaletteRole.WINDOW_SURFACE to "Window surface (the windows themselves)",
|
||||||
|
PaletteRole.FIELD_INTERIOR to "Field interior (fields, lists, buttons)",
|
||||||
|
PaletteRole.TEXT to "Text",
|
||||||
|
PaletteRole.ACCENT to "Accent",
|
||||||
|
PaletteRole.SEPARATOR to "Separator line",
|
||||||
|
).forEach { (role, name) ->
|
||||||
|
fieldBeside(c.labelOf(role, name)).assertTextEquals(c.state.roles().single { it.role == role }.hex)
|
||||||
|
}
|
||||||
|
onNodeWithText(
|
||||||
|
"Reset to the shipped colours (${ColourShortcuts.labelFor(ColourAction.RESET_TO_SHIPPED)})",
|
||||||
|
).assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTypedColourIsCommittedIntoTheStore() = runComposeUiTest {
|
||||||
|
val c = Colours(directory)
|
||||||
|
show(c)
|
||||||
|
|
||||||
|
typeAndCommit(c.labelOf(PaletteRole.ACCENT, "Accent"), "#3C7A4B")
|
||||||
|
|
||||||
|
assertEquals("#3C7A4B", c.state.roles().single { it.role == PaletteRole.ACCENT }.hex)
|
||||||
|
assertEquals(Color(0xFF3C7A4B), c.store.resolved(false).palette.accent)
|
||||||
|
assertTrue(c.refusals.isEmpty(), "a valid colour was refused: ${c.refusals}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Opening the tab used to commit every role field once, because a committed field's
|
||||||
|
* focus callback fires with "not focused" when it is first attached. Each role then
|
||||||
|
* became an override of itself: the labels said "changed" instead of "shipped", and
|
||||||
|
* after "Save settings" the overrides hid the profile's stylesheet file for good.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun openingTheTabWritesNoColour() = runComposeUiTest {
|
||||||
|
val c = Colours(directory)
|
||||||
|
show(c)
|
||||||
|
|
||||||
|
assertEquals(mapOf(false to "", true to ""), c.stored, "opening the tab wrote overrides")
|
||||||
|
c.state.roles().forEach { row ->
|
||||||
|
assertEquals(PaletteSource.SHIPPED, row.source, "${row.role} is no longer the shipped colour")
|
||||||
|
}
|
||||||
|
assertTrue(c.refusals.isEmpty(), "opening the tab reported: ${c.refusals}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Passing through a field without typing is not an edit either. */
|
||||||
|
@Test
|
||||||
|
fun enteringAndLeavingAFieldWithoutTypingWritesNothing() = runComposeUiTest {
|
||||||
|
val c = Colours(directory)
|
||||||
|
show(c)
|
||||||
|
val accent = c.labelOf(PaletteRole.ACCENT, "Accent")
|
||||||
|
val text = c.labelOf(PaletteRole.TEXT, "Text")
|
||||||
|
|
||||||
|
fieldBeside(accent).performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
fieldBeside(text).performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
fieldBeside(accent).performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(mapOf(false to "", true to ""), c.stored, "passing through a field wrote an override")
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The field shows the colour in upper case. Typing the same colour in lower case names
|
||||||
|
* no other colour, so it is no edit and must not become an override of itself.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun theSameColourInOtherCaseIsNoChange() = runComposeUiTest {
|
||||||
|
val c = Colours(directory)
|
||||||
|
show(c)
|
||||||
|
val accent = c.labelOf(PaletteRole.ACCENT, "Accent")
|
||||||
|
val shown = c.state.roles().single { it.role == PaletteRole.ACCENT }.hex
|
||||||
|
|
||||||
|
typeAndCommit(accent, shown.lowercase())
|
||||||
|
fieldBeside(c.labelOf(PaletteRole.TEXT, "Text")).performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(mapOf(false to "", true to ""), c.stored, "an unchanged colour was written as an override")
|
||||||
|
assertEquals(PaletteSource.SHIPPED, c.state.roles().single { it.role == PaletteRole.ACCENT }.source)
|
||||||
|
assertTrue(c.refusals.isEmpty(), "the colour was refused: ${c.refusals}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leaving the field still commits what was typed, without Enter. */
|
||||||
|
@Test
|
||||||
|
fun aTypedColourIsCommittedWhenTheFieldIsLeft() = runComposeUiTest {
|
||||||
|
val c = Colours(directory)
|
||||||
|
show(c)
|
||||||
|
|
||||||
|
typeAndLeave(
|
||||||
|
c.labelOf(PaletteRole.ACCENT, "Accent"),
|
||||||
|
"#3C7A4B",
|
||||||
|
leaveTo = c.labelOf(PaletteRole.TEXT, "Text"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val accent = c.state.roles().single { it.role == PaletteRole.ACCENT }
|
||||||
|
assertEquals("#3C7A4B", accent.hex)
|
||||||
|
assertEquals(PaletteSource.CHANGED, accent.source)
|
||||||
|
assertEquals(
|
||||||
|
PaletteSource.SHIPPED,
|
||||||
|
c.state.roles().single { it.role == PaletteRole.TEXT }.source,
|
||||||
|
"the field that was only entered became an override",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aMistypedColourIsRefusedAndChangesNothing() = runComposeUiTest {
|
||||||
|
val c = Colours(directory)
|
||||||
|
show(c)
|
||||||
|
val before = c.state.roles().single { it.role == PaletteRole.TEXT }.hex
|
||||||
|
|
||||||
|
typeAndCommit(c.labelOf(PaletteRole.TEXT, "Text"), "#12")
|
||||||
|
|
||||||
|
assertEquals(1, c.refusals.size)
|
||||||
|
assertEquals(before, c.state.roles().single { it.role == PaletteRole.TEXT }.hex)
|
||||||
|
fieldBeside(c.labelOf(PaletteRole.TEXT, "Text")).assertTextEquals(before)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resetToShippedPutsEveryRoleBack() = runComposeUiTest {
|
||||||
|
val c = Colours(directory)
|
||||||
|
show(c)
|
||||||
|
typeAndCommit(c.labelOf(PaletteRole.ACCENT, "Accent"), "#3C7A4B")
|
||||||
|
|
||||||
|
onNodeWithText(
|
||||||
|
"Reset to the shipped colours (${ColourShortcuts.labelFor(ColourAction.RESET_TO_SHIPPED)})",
|
||||||
|
).performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(c.store.shipped(false).accent, c.store.resolved(false).palette.accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The export writes the shipped sheet into the profile, and never over an existing file. */
|
||||||
|
@Test
|
||||||
|
fun theExportWritesTheStylesheetOnceAndThenLeavesItAlone() = runComposeUiTest {
|
||||||
|
val c = Colours(directory)
|
||||||
|
show(c)
|
||||||
|
val day: File = c.files.dayFile
|
||||||
|
|
||||||
|
onNodeWithText("Write this design's stylesheet into my profile").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertTrue(day.isFile, "nothing was written")
|
||||||
|
assertEquals(listOf("Written to ${day.path}. Edit it and restart to see the result."), c.outcomes)
|
||||||
|
|
||||||
|
day.writeText("/* the operator's own */")
|
||||||
|
onNodeWithText("Write this design's stylesheet into my profile").performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals("/* the operator's own */", day.readText(), "an existing stylesheet was replaced")
|
||||||
|
assertEquals("${day.path} is already there and was left untouched.", c.outcomes.last())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.isDialog
|
||||||
|
import androidx.compose.ui.test.hasAnyAncestor
|
||||||
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
|
import androidx.compose.ui.test.hasSetTextAction
|
||||||
|
import androidx.compose.ui.test.hasText
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.compose.ui.test.performTextReplacement
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.Form
|
||||||
|
import kst4contest.view.compose.Kst4ContestTheme
|
||||||
|
import kst4contest.view.compose.SettingsQuestion
|
||||||
|
import kst4contest.view.compose.runOnce
|
||||||
|
import kst4contest.view.compose.SettingsContent
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.SettingsNotice
|
||||||
|
import kst4contest.view.compose.SettingsNotices
|
||||||
|
import kst4contest.view.compose.SettingsTab
|
||||||
|
import kst4contest.view.compose.applyAndSave
|
||||||
|
import kst4contest.view.compose.SettingsWindow
|
||||||
|
import kst4contest.view.compose.applyChecked
|
||||||
|
import kst4contest.view.compose.awaitGone
|
||||||
|
import kst4contest.view.compose.awaitNode
|
||||||
|
import kst4contest.view.compose.inDialog
|
||||||
|
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 org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings window around its tabs: edits wait in the draft, Apply/Close applies them,
|
||||||
|
* Save applies before it writes, and the X asks about what is still waiting.
|
||||||
|
*
|
||||||
|
* Drawn with the Beacon tab because it has all three kinds of control that matter here: a
|
||||||
|
* checkbox, a field that commits on Enter or focus loss, and an effect (the timer restart).
|
||||||
|
* The button bar is a stand-in: the real one needs a ChatController, which opens a database;
|
||||||
|
* it calls the same [applyChecked] and [applyAndSave] this one does.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class SettingsApplyScreenTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
lateinit var directory: Path
|
||||||
|
|
||||||
|
private inner class Window(refuse: Boolean = false, timerFails: Boolean = false) {
|
||||||
|
val prefs = ChatPreferences(directory.resolve("preferences.xml")).apply {
|
||||||
|
bcn_beaconIntervalInMinutesMainCat = 5
|
||||||
|
isBcn_beaconsEnabledMainCat = false
|
||||||
|
}
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
val notices = SettingsNotices()
|
||||||
|
val question = SettingsQuestion()
|
||||||
|
var closed = false
|
||||||
|
var restarts = 0
|
||||||
|
|
||||||
|
/** The title bar's X. */
|
||||||
|
fun x() = question.settleClose(draft, notices)
|
||||||
|
|
||||||
|
/** A profile switch or the exit, as SettingsWindow.settleThen hands it on. */
|
||||||
|
fun switchOrExit(action: () -> Unit) = question.settle(draft, notices, then = action, nothingPending = action)
|
||||||
|
val state = BeaconTabState(
|
||||||
|
prefs,
|
||||||
|
draft,
|
||||||
|
validateBeaconTemplate = { if (refuse) throw IllegalArgumentException("no variable %foo%") },
|
||||||
|
restartBeaconTimer = {
|
||||||
|
restarts++
|
||||||
|
if (timerFails) throw IllegalStateException("timer gone")
|
||||||
|
},
|
||||||
|
mainCategoryName = "Microwave",
|
||||||
|
secondCategoryName = "144/432 MHz",
|
||||||
|
)
|
||||||
|
val station = StationTabState(prefs, draft, { }, { })
|
||||||
|
val airscout = AirscoutTabState(prefs, draft)
|
||||||
|
val tabs = listOf(
|
||||||
|
SettingsTab("Beacon") {
|
||||||
|
androidx.compose.foundation.layout.Column {
|
||||||
|
BeaconTab(state, notices::problem)
|
||||||
|
StationTab(station)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun androidx.compose.ui.test.ComposeUiTest.show(w: Window, tabs: List<SettingsTab> = w.tabs) {
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
SettingsContent(
|
||||||
|
tabs = tabs,
|
||||||
|
notices = w.notices,
|
||||||
|
buttons = { close ->
|
||||||
|
Form.button(APPLY_AND_CLOSE) { if (applyChecked(w.draft, w.notices)) close() }
|
||||||
|
},
|
||||||
|
close = { w.closed = true },
|
||||||
|
draft = w.draft,
|
||||||
|
question = w.question,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anEditTakesEffectOnlyWithApplyAndClose() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
show(w)
|
||||||
|
|
||||||
|
toggle("Send the beacon in this category")
|
||||||
|
typeAndCommit("Interval in whole minutes", "9")
|
||||||
|
|
||||||
|
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the box took effect before Apply")
|
||||||
|
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||||
|
assertEquals(0, w.restarts, "the timer restarted before Apply")
|
||||||
|
|
||||||
|
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||||
|
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||||
|
assertEquals(1, w.restarts)
|
||||||
|
assertTrue(w.closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A click on a button leaves the focus in the text field, so the field has not committed
|
||||||
|
* when the button runs. Apply commits it first; otherwise the entry would reach the draft
|
||||||
|
* only when the closing window removed the field -- after the apply, into nothing.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun applyAndCloseTakesTheEntryStillInTheFocusedField() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
show(w)
|
||||||
|
|
||||||
|
val field = fieldBeside("Interval in whole minutes")
|
||||||
|
field.performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
field.performTextReplacement("9")
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat, "the typed entry was lost")
|
||||||
|
assertEquals(1, w.restarts)
|
||||||
|
assertTrue(w.closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A refused entry stops the button, so the reason is not closed away with the window. */
|
||||||
|
@Test
|
||||||
|
fun aRefusedEntryKeepsTheWindowOpenAndShowsWhy() = runComposeUiTest {
|
||||||
|
val w = Window(refuse = true)
|
||||||
|
show(w)
|
||||||
|
|
||||||
|
val field = fieldBeside("Beacon message")
|
||||||
|
field.performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
field.performTextReplacement("cq %foo%")
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertFalse(w.closed, "the window closed over a refused entry")
|
||||||
|
assertTrue(w.notices.current is SettingsNotice.Problem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A refusal stops the apply before anything goes into effect: the valid edit beside the
|
||||||
|
* refused one is still pending, so the operator can still discard everything.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aRefusalAppliesNothingSoDiscardIsStillPossible() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
show(w)
|
||||||
|
toggle("Send the beacon in this category")
|
||||||
|
type("Server port", "23x")
|
||||||
|
|
||||||
|
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertFalse(w.closed)
|
||||||
|
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the valid edit went into effect despite the refusal")
|
||||||
|
assertTrue(w.draft.hasPendingChanges)
|
||||||
|
runOnIdle { w.notices.dismiss() }
|
||||||
|
|
||||||
|
runOnIdle { w.draft.discard() }
|
||||||
|
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A number field showing text that does not parse holds the last valid value in the draft.
|
||||||
|
* Applying that value would put something into effect the operator no longer sees.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun anInvalidNumberRefusesTheApplyAndNamesTheField() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
val storedPort = w.prefs.stn_on4kstServersPort
|
||||||
|
show(w)
|
||||||
|
|
||||||
|
type("Server port", "23005")
|
||||||
|
type("Server port", "23x")
|
||||||
|
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertFalse(w.closed, "the window closed with the old port applied")
|
||||||
|
assertEquals(storedPort, w.prefs.stn_on4kstServersPort)
|
||||||
|
val notice = w.notices.current
|
||||||
|
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("Server port") &&
|
||||||
|
notice.message.contains("23x"), "the notice does not name the field: $notice")
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The same for the guarded fields of the external interfaces. */
|
||||||
|
@Test
|
||||||
|
fun anInvalidGuardedEntryRefusesTheApplyAndNamesTheField() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
show(w, listOf(SettingsTab("Airscout") { AirscoutTab(w.airscout) }))
|
||||||
|
|
||||||
|
type(AIRSCOUT_PORT, "0")
|
||||||
|
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertFalse(w.closed)
|
||||||
|
val notice = w.notices.current
|
||||||
|
assertTrue(notice is SettingsNotice.Problem && notice.message.contains(AIRSCOUT_PORT),
|
||||||
|
"the notice does not name the field: $notice")
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A failing effect is reported, the window stays open, and the other changes are in. */
|
||||||
|
@Test
|
||||||
|
fun aFailingEffectIsReportedAndTheRestIsApplied() = runComposeUiTest {
|
||||||
|
val w = Window(timerFails = true)
|
||||||
|
show(w)
|
||||||
|
toggle("Send the beacon in this category")
|
||||||
|
typeAndCommit("Interval in whole minutes", "9")
|
||||||
|
|
||||||
|
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertFalse(w.closed, "the failure was closed away")
|
||||||
|
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||||
|
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||||
|
val notice = w.notices.current
|
||||||
|
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("timer gone"), "$notice")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theXClosesAtOnceWhenNothingIsPending() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
show(w)
|
||||||
|
|
||||||
|
runOnIdle { w.x() }
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertTrue(w.closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theXAsksAboutPendingEditsAndApplyAppliesThem() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
show(w)
|
||||||
|
toggle("Send the beacon in this category")
|
||||||
|
|
||||||
|
runOnIdle { w.x() }
|
||||||
|
awaitNode(inDialog(PENDING_QUESTION))
|
||||||
|
assertFalse(w.closed, "the window closed without asking")
|
||||||
|
|
||||||
|
awaitNode(inDialog("Apply")).performClick()
|
||||||
|
awaitGone(inDialog(PENDING_QUESTION))
|
||||||
|
|
||||||
|
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||||
|
assertTrue(w.closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun discardingOnTheXChangesNothing() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
show(w)
|
||||||
|
toggle("Send the beacon in this category")
|
||||||
|
typeAndCommit("Interval in whole minutes", "9")
|
||||||
|
|
||||||
|
runOnIdle { w.x() }
|
||||||
|
awaitNode(inDialog("Discard")).performClick()
|
||||||
|
awaitGone(inDialog(PENDING_QUESTION))
|
||||||
|
|
||||||
|
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||||
|
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||||
|
assertEquals(0, w.restarts)
|
||||||
|
assertTrue(w.closed)
|
||||||
|
assertFalse(w.draft.hasPendingChanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun goingBackKeepsTheWindowAndTheEdits() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
show(w)
|
||||||
|
toggle("Send the beacon in this category")
|
||||||
|
|
||||||
|
runOnIdle { w.x() }
|
||||||
|
awaitNode(inDialog("Back to the settings")).performClick()
|
||||||
|
awaitGone(inDialog(PENDING_QUESTION))
|
||||||
|
|
||||||
|
assertFalse(w.closed)
|
||||||
|
assertTrue(w.draft.hasPendingChanges)
|
||||||
|
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||||
|
checkBeside("Send the beacon in this category").assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An entry typed just before the X counts as pending, although no Enter committed it. */
|
||||||
|
@Test
|
||||||
|
fun theXAsksAboutAnEntryStillInTheFocusedField() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
show(w)
|
||||||
|
|
||||||
|
val field = fieldBeside("Interval in whole minutes")
|
||||||
|
field.performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
field.performTextReplacement("9")
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
runOnIdle { w.x() }
|
||||||
|
awaitNode(inDialog(PENDING_QUESTION))
|
||||||
|
|
||||||
|
assertFalse(w.closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A list row still being edited counts too: the lists commit a row on Enter or focus loss. */
|
||||||
|
@Test
|
||||||
|
fun theXAsksAboutARowStillBeingEdited() = runComposeUiTest {
|
||||||
|
val prefs = ChatPreferences(directory.resolve("preferences.xml")).apply {
|
||||||
|
lst_txtShortCutBtnList.setAll(listOf("MYQRG", "pse sked"))
|
||||||
|
}
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
val question = SettingsQuestion()
|
||||||
|
val notices = SettingsNotices()
|
||||||
|
var closed = false
|
||||||
|
val state = ShortcutsTabState(prefs, draft, { }, { })
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
SettingsContent(
|
||||||
|
tabs = listOf(SettingsTab("Shortcuts") { ShortcutsTab(state) { } }),
|
||||||
|
notices = notices,
|
||||||
|
buttons = { },
|
||||||
|
close = { closed = true },
|
||||||
|
draft = draft,
|
||||||
|
question = question,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
val row = onNode(hasSetTextAction() and hasText("pse sked"))
|
||||||
|
row.performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
row.performTextReplacement("pse sked 432")
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
runOnIdle { question.settleClose(draft, notices) }
|
||||||
|
awaitNode(inDialog("Apply")).performClick()
|
||||||
|
awaitGone(inDialog(PENDING_QUESTION))
|
||||||
|
|
||||||
|
assertEquals(listOf("MYQRG", "pse sked 432"), prefs.lst_txtShortCutBtnList.snapshot())
|
||||||
|
assertTrue(closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A profile switch or the exit asks the same question. Apply applies the edits to the
|
||||||
|
* profile still in force and goes on; the window itself is left to the teardown.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aSwitchOrExitAsksAndApplyGoesOnWithTheEditsApplied() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
var wentOn = false
|
||||||
|
show(w)
|
||||||
|
toggle("Send the beacon in this category")
|
||||||
|
|
||||||
|
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||||
|
awaitNode(inDialog("Apply")).performClick()
|
||||||
|
awaitGone(inDialog(PENDING_QUESTION))
|
||||||
|
|
||||||
|
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||||
|
assertTrue(wentOn)
|
||||||
|
assertFalse(w.closed, "the window closed itself instead of leaving that to the teardown")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun goingBackCancelsTheSwitchOrExit() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
var wentOn = false
|
||||||
|
show(w)
|
||||||
|
toggle("Send the beacon in this category")
|
||||||
|
|
||||||
|
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||||
|
awaitNode(inDialog("Back to the settings")).performClick()
|
||||||
|
awaitGone(inDialog(PENDING_QUESTION))
|
||||||
|
|
||||||
|
assertFalse(wentOn, "the switch or exit went ahead after Back")
|
||||||
|
assertTrue(w.draft.hasPendingChanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun discardingGoesOnWithNothingApplied() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
var wentOn = false
|
||||||
|
show(w)
|
||||||
|
toggle("Send the beacon in this category")
|
||||||
|
|
||||||
|
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||||
|
awaitNode(inDialog("Discard")).performClick()
|
||||||
|
awaitGone(inDialog(PENDING_QUESTION))
|
||||||
|
|
||||||
|
assertTrue(wentOn)
|
||||||
|
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRefusalAtTheQuestionStopsTheSwitchOrExit() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
var wentOn = false
|
||||||
|
show(w)
|
||||||
|
toggle("Send the beacon in this category")
|
||||||
|
|
||||||
|
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||||
|
awaitNode(inDialog(PENDING_QUESTION))
|
||||||
|
type("Server port", "23x")
|
||||||
|
awaitNode(inDialog("Apply")).performClick()
|
||||||
|
awaitGone(inDialog(PENDING_QUESTION))
|
||||||
|
|
||||||
|
assertFalse(wentOn, "went on over a refused entry")
|
||||||
|
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "applied despite the refusal")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun withNothingPendingTheSwitchOrExitGoesOnAtOnce() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
var wentOn = false
|
||||||
|
show(w)
|
||||||
|
|
||||||
|
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertTrue(wentOn)
|
||||||
|
assertFalse(w.closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An X while the exit or switch question is open must not replace its continuation:
|
||||||
|
* Apply would then only close the window, and the exit would be dropped without a word.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun theXDoesNotReplaceAnOpenSwitchOrExitQuestion() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
var wentOn = false
|
||||||
|
show(w)
|
||||||
|
toggle("Send the beacon in this category")
|
||||||
|
|
||||||
|
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||||
|
awaitNode(inDialog(PENDING_QUESTION))
|
||||||
|
runOnIdle { w.x() }
|
||||||
|
awaitNode(inDialog("Apply")).performClick()
|
||||||
|
awaitGone(inDialog(PENDING_QUESTION))
|
||||||
|
|
||||||
|
assertTrue(wentOn, "the exit or switch was dropped")
|
||||||
|
assertFalse(w.closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Invalid text in a field refuses Apply, but must not trap the operator: the question is
|
||||||
|
* shown anyway, with the reason, and Discard closes.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun anInvalidFieldStillLetsTheXDiscard() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
show(w)
|
||||||
|
toggle("Send the beacon in this category")
|
||||||
|
type("Server port", "23x")
|
||||||
|
|
||||||
|
runOnIdle { w.x() }
|
||||||
|
awaitNode(inDialog(PENDING_QUESTION))
|
||||||
|
awaitNode(inDialog("Apply")).assertIsNotEnabled()
|
||||||
|
onNode(hasText("23x", substring = true) and hasAnyAncestor(isDialog())).assertExists()
|
||||||
|
|
||||||
|
awaitNode(inDialog("Discard")).performClick()
|
||||||
|
awaitGone(inDialog(PENDING_QUESTION))
|
||||||
|
|
||||||
|
assertTrue(w.closed)
|
||||||
|
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "something was applied")
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The same for a switch or the exit, even when only the invalid field is unsettled. */
|
||||||
|
@Test
|
||||||
|
fun anInvalidFieldStillLetsASwitchOrExitDiscard() = runComposeUiTest {
|
||||||
|
val w = Window()
|
||||||
|
var wentOn = false
|
||||||
|
show(w)
|
||||||
|
type("Server port", "23x")
|
||||||
|
|
||||||
|
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||||
|
assertFalse(wentOn, "went on past a field that refuses its text")
|
||||||
|
awaitNode(inDialog("Discard")).performClick()
|
||||||
|
awaitGone(inDialog(PENDING_QUESTION))
|
||||||
|
|
||||||
|
assertTrue(wentOn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aContinuationRunsOnlyOnce() {
|
||||||
|
var runs = 0
|
||||||
|
val once = runOnce { runs++ }
|
||||||
|
|
||||||
|
once.run()
|
||||||
|
once.run()
|
||||||
|
|
||||||
|
assertEquals(1, runs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Without a settings window there is nothing to ask: the action runs at once, synchronously. */
|
||||||
|
@Test
|
||||||
|
fun settleThenRunsAtOnceWithoutAnOpenWindow() {
|
||||||
|
var ran = false
|
||||||
|
SettingsWindow.settleThen { ran = true }
|
||||||
|
assertTrue(ran)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A failed effect does not keep the file from being written; its setting was stored. */
|
||||||
|
@Test
|
||||||
|
fun saveStillWritesWhenOnlyAnEffectFailed() {
|
||||||
|
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
|
||||||
|
prefs.bcn_beaconIntervalInMinutesMainCat = 5
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
val notices = SettingsNotices()
|
||||||
|
val state = BeaconTabState(prefs, draft, { }, { throw IllegalStateException("timer gone") }, "Microwave", "144/432 MHz")
|
||||||
|
var saved = false
|
||||||
|
|
||||||
|
state.commitBeaconInterval("9")
|
||||||
|
applyAndSave(draft, notices) {
|
||||||
|
saved = true
|
||||||
|
"preferences.xml"
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(saved)
|
||||||
|
val notice = notices.current
|
||||||
|
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("timer gone") &&
|
||||||
|
notice.message.contains("preferences.xml"), "$notice")
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Save applies first, so the file holds what the window shows; the window stays open. */
|
||||||
|
@Test
|
||||||
|
fun saveAppliesBeforeItWrites() {
|
||||||
|
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
|
||||||
|
prefs.bcn_beaconIntervalInMinutesMainCat = 5
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
val notices = SettingsNotices()
|
||||||
|
val state = BeaconTabState(prefs, draft, { }, { }, "Microwave", "144/432 MHz")
|
||||||
|
var intervalWhenSaved = -1
|
||||||
|
|
||||||
|
state.commitBeaconInterval("9")
|
||||||
|
applyAndSave(draft, notices) {
|
||||||
|
intervalWhenSaved = prefs.bcn_beaconIntervalInMinutesMainCat
|
||||||
|
"preferences.xml"
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(9, intervalWhenSaved, "the file was written before the draft was applied")
|
||||||
|
assertFalse(draft.hasPendingChanges)
|
||||||
|
assertTrue(notices.current is SettingsNotice.Info)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val APPLY_AND_CLOSE = "Apply/Close prefs"
|
||||||
|
const val AIRSCOUT_PORT = "AirScout UDP port [9872] — reconnect after changing:"
|
||||||
|
const val PENDING_QUESTION =
|
||||||
|
"Some settings have been changed but not applied yet. Apply them first?"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.semantics.SemanticsNode
|
||||||
|
import androidx.compose.ui.semantics.SemanticsProperties
|
||||||
|
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.hasText
|
||||||
|
import androidx.compose.ui.test.isToggleable
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.compose.ui.test.performTextReplacement
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import kst4contest.view.compose.Kst4ContestTheme
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Shared plumbing for the settings-tab screen tests (#107).
|
||||||
|
*
|
||||||
|
* The form controls draw a label beside a field, not a labelled field: there is no semantic
|
||||||
|
* link between the two. These helpers find a control by the label the operator reads, on the
|
||||||
|
* same row, which is exactly how the operator finds it -- and which breaks, as it should,
|
||||||
|
* when a label moves away from its field.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws one tab the way the settings window does: themed, in a column that scrolls, so a
|
||||||
|
* control further down can be scrolled to and clicked.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
internal fun ComposeUiTest.showTab(content: @Composable () -> Unit) {
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
private fun ComposeUiTest.labelNode(label: String, occurrence: Int): SemanticsNode {
|
||||||
|
val labels = onAllNodes(hasText(label, substring = false)).fetchSemanticsNodes()
|
||||||
|
check(labels.size > occurrence) { "no label '$label' (#$occurrence) on screen; found ${labels.size}" }
|
||||||
|
return labels.sortedBy { it.boundsInRoot.top }[occurrence]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The control matching [matcher] on the row of [label], picked by [choose] among candidates. */
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
private fun ComposeUiTest.besideLabel(
|
||||||
|
label: String,
|
||||||
|
occurrence: Int,
|
||||||
|
matcher: SemanticsMatcher,
|
||||||
|
choose: (label: SemanticsNode, candidates: List<SemanticsNode>) -> SemanticsNode?,
|
||||||
|
): SemanticsNodeInteraction {
|
||||||
|
val anchor = labelNode(label, occurrence)
|
||||||
|
val all = onAllNodes(matcher).fetchSemanticsNodes()
|
||||||
|
val sameRow = all.filter { node ->
|
||||||
|
node.boundsInRoot.center.y in anchor.boundsInRoot.top..anchor.boundsInRoot.bottom
|
||||||
|
}
|
||||||
|
val chosen = choose(anchor, sameRow) ?: error("no control beside '$label' (#$occurrence)")
|
||||||
|
return onAllNodes(matcher)[all.indexOf(chosen)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text field to the right of [label]. Matched by its editable text rather than by its
|
||||||
|
* set-text action, which a disabled field does not offer, so a locked field is found too.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
internal fun ComposeUiTest.fieldBeside(label: String, occurrence: Int = 0): SemanticsNodeInteraction =
|
||||||
|
besideLabel(label, occurrence, SemanticsMatcher.keyIsDefined(SemanticsProperties.EditableText)) { anchor, candidates ->
|
||||||
|
candidates.filter { it.boundsInRoot.left >= anchor.boundsInRoot.right - 1f }
|
||||||
|
.minByOrNull { it.boundsInRoot.left }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The checkbox directly to the left of [label], as [kst4contest.view.compose.Form.check] draws it. */
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
internal fun ComposeUiTest.checkBeside(label: String, occurrence: Int = 0): SemanticsNodeInteraction =
|
||||||
|
besideLabel(label, occurrence, isToggleable()) { anchor, candidates ->
|
||||||
|
candidates.filter { it.boundsInRoot.right <= anchor.boundsInRoot.left + 1f }
|
||||||
|
.maxByOrNull { it.boundsInRoot.right }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticks or unticks the checkbox beside [label], scrolling to it first. */
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
internal fun ComposeUiTest.toggle(label: String, occurrence: Int = 0) {
|
||||||
|
checkBeside(label, occurrence).performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the text of the field beside [label], as typing a whole new value would. */
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
internal fun ComposeUiTest.type(label: String, text: String, occurrence: Int = 0) {
|
||||||
|
fieldBeside(label, occurrence).performTextReplacement(text)
|
||||||
|
waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types into a field that commits on Enter or on leaving it, the way
|
||||||
|
* [kst4contest.view.compose.Form.committed] does, and presses Enter.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
internal fun ComposeUiTest.typeAndCommit(label: String, text: String, occurrence: Int = 0) {
|
||||||
|
val field = fieldBeside(label, occurrence)
|
||||||
|
field.performScrollTo().performClick()
|
||||||
|
field.performTextReplacement(text)
|
||||||
|
field.performKeyInput { pressKey(Key.Enter) }
|
||||||
|
waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types into a field that commits on leaving it, then leaves it the way the operator does:
|
||||||
|
* by clicking into the field beside [leaveTo]. No Enter is pressed, so only the focus loss
|
||||||
|
* can commit the entry.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
internal fun ComposeUiTest.typeAndLeave(
|
||||||
|
label: String,
|
||||||
|
text: String,
|
||||||
|
leaveTo: String,
|
||||||
|
occurrence: Int = 0,
|
||||||
|
leaveToOccurrence: Int = 0,
|
||||||
|
) {
|
||||||
|
val field = fieldBeside(label, occurrence)
|
||||||
|
field.performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
field.performTextReplacement(text)
|
||||||
|
waitForIdle()
|
||||||
|
fieldBeside(leaveTo, leaveToOccurrence).performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
}
|
||||||
@@ -1,20 +1,29 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
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.Test
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertSame
|
import org.junit.jupiter.api.Assertions.assertSame
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
class ShortcutsTabStateTest {
|
class ShortcutsTabStateTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
private class Session {
|
/*
|
||||||
val prefs = ChatPreferences()
|
* 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(val draft: SettingsDraft = ImmediateDraft()) {
|
||||||
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
var shortcutRefreshes = 0
|
var shortcutRefreshes = 0
|
||||||
var snippetRefreshes = 0
|
var snippetRefreshes = 0
|
||||||
|
|
||||||
fun state() = ShortcutsTabState(
|
fun state() = ShortcutsTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
refreshShortcutButtons = { shortcutRefreshes++ },
|
refreshShortcutButtons = { shortcutRefreshes++ },
|
||||||
refreshTextSnippetContextMenus = { snippetRefreshes++ },
|
refreshTextSnippetContextMenus = { snippetRefreshes++ },
|
||||||
)
|
)
|
||||||
@@ -33,7 +42,42 @@ class ShortcutsTabStateTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a new shortcut lands at the top and reaches the roster at once`() {
|
fun `a list edit waits for apply and rebuilds the buttons once`() {
|
||||||
|
val s = Session(SettingsDraft())
|
||||||
|
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt"))
|
||||||
|
val state = s.state()
|
||||||
|
|
||||||
|
state.shortcuts.addAtTop("cq")
|
||||||
|
state.commitShortcuts()
|
||||||
|
state.shortcuts.addAtTop("tnx")
|
||||||
|
state.commitShortcuts()
|
||||||
|
|
||||||
|
assertEquals(listOf("alt"), s.prefs.getLst_txtShortCutBtnList().snapshot(),
|
||||||
|
"the list took effect before Apply")
|
||||||
|
assertEquals(0, s.shortcutRefreshes)
|
||||||
|
|
||||||
|
s.draft.apply()
|
||||||
|
|
||||||
|
assertEquals(listOf("tnx", "cq", "alt"), s.prefs.getLst_txtShortCutBtnList().snapshot())
|
||||||
|
assertEquals(1, s.shortcutRefreshes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a discarded list edit leaves the roster alone`() {
|
||||||
|
val s = Session(SettingsDraft())
|
||||||
|
s.prefs.getLst_txtSnipList().setAll(listOf("73"))
|
||||||
|
val state = s.state()
|
||||||
|
|
||||||
|
state.snippets.commitAt(0, "")
|
||||||
|
state.commitSnippets()
|
||||||
|
s.draft.discard()
|
||||||
|
|
||||||
|
assertEquals(listOf("73"), s.prefs.getLst_txtSnipList().snapshot())
|
||||||
|
assertEquals(0, s.snippetRefreshes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a new shortcut lands at the top and reaches the roster`() {
|
||||||
val s = Session()
|
val s = Session()
|
||||||
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt"))
|
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt"))
|
||||||
val state = s.state()
|
val state = s.state()
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertIsOff
|
||||||
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
|
import androidx.compose.ui.test.assertIsOn
|
||||||
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onFirst
|
||||||
|
import androidx.compose.ui.test.onLast
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.compose.ui.test.performTextInput
|
||||||
|
import androidx.compose.ui.test.performTextReplacement
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.performTextClearance
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import kst4contest.controller.On4KstConnectionState
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.observe.UiDispatcher
|
||||||
|
import kst4contest.view.compose.MainWindowSurroundings
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.feed.ConnectionStateFeed
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import kst4contest.view.compose.awaitNode
|
||||||
|
import kst4contest.view.compose.awaitGone
|
||||||
|
import kst4contest.view.compose.inDialog
|
||||||
|
import kst4contest.view.compose.inPopup
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Station tab, drawn (#107).
|
||||||
|
*
|
||||||
|
* [StationTabStateTest] pins the facade; this pins that the drawn controls show what the
|
||||||
|
* profile holds and that editing one writes through, which is the class of fault the
|
||||||
|
* settings window shipped repeatedly with a green build (`83e23f55`, Etappe 7).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class StationTabScreenTest {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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 class Session(isolated: ChatPreferences) {
|
||||||
|
val prefs = isolated.apply {
|
||||||
|
stn_loginCallSign = "DN9APW"
|
||||||
|
stn_on4kstServersPort = 23001
|
||||||
|
isStn_bandActive144 = true
|
||||||
|
isStn_bandActive10G = false
|
||||||
|
}
|
||||||
|
val mainApplied = mutableListOf<ChatCategory?>()
|
||||||
|
val link = mutableStateOf(On4KstConnectionState.DISCONNECTED)
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
val state = StationTabState(prefs, draft, { mainApplied += it }, { }, { link.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theTabShowsItsSectionsAndWhatTheProfileHolds() = runComposeUiTest {
|
||||||
|
val s = Session(preferences())
|
||||||
|
showTab { StationTab(s.state) }
|
||||||
|
|
||||||
|
listOf("Own station", "Chat category", "ON4KST server", "Active bands", "Defaults",
|
||||||
|
"PSTRotator", "Path analysis defaults").forEach { onNodeWithText(it).assertExists() }
|
||||||
|
|
||||||
|
fieldBeside("Callsign").assertTextEquals("DN9APW")
|
||||||
|
fieldBeside("Server port").assertTextEquals("23001")
|
||||||
|
checkBeside("144 MHz").assertIsOn()
|
||||||
|
checkBeside("10 GHz").assertIsOff()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The credentials write through; every other field waits for Apply. */
|
||||||
|
@Test
|
||||||
|
fun editingTheTextFieldsStagesAllButTheCredentials() = runComposeUiTest {
|
||||||
|
val s = Session(preferences())
|
||||||
|
val storedHost = s.prefs.stn_on4kstServersDns
|
||||||
|
showTab { StationTab(s.state) }
|
||||||
|
|
||||||
|
type("Callsign", "DO5AMF")
|
||||||
|
type("Locator", "JO51IJ")
|
||||||
|
type("Server host name", "www.on4kst.info")
|
||||||
|
|
||||||
|
assertEquals("DO5AMF", s.prefs.stn_loginCallSign)
|
||||||
|
assertEquals("JO51IJ", s.prefs.stn_loginLocatorMainCat)
|
||||||
|
assertEquals(storedHost, s.prefs.stn_on4kstServersDns, "the host took effect before Apply")
|
||||||
|
fieldBeside("Server host name").assertTextEquals("www.on4kst.info")
|
||||||
|
|
||||||
|
runOnIdle { s.draft.apply() }
|
||||||
|
assertEquals("www.on4kst.info", s.prefs.stn_on4kstServersDns)
|
||||||
|
fieldBeside("Callsign").assertTextEquals("DO5AMF")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A number field writes only what parses; a half-typed entry leaves the stored value. */
|
||||||
|
@Test
|
||||||
|
fun theNumberFieldsWriteOnlyWhatParses() = runComposeUiTest {
|
||||||
|
val s = Session(preferences())
|
||||||
|
showTab { StationTab(s.state) }
|
||||||
|
|
||||||
|
type("Server port", "23000")
|
||||||
|
runOnIdle { s.draft.apply() }
|
||||||
|
assertEquals(23000, s.prefs.stn_on4kstServersPort)
|
||||||
|
|
||||||
|
type("Server port", "23x")
|
||||||
|
runOnIdle { s.draft.apply() }
|
||||||
|
assertEquals(23000, s.prefs.stn_on4kstServersPort, "an unparsable port was written")
|
||||||
|
|
||||||
|
type("QTF", "135,5")
|
||||||
|
runOnIdle { s.draft.apply() }
|
||||||
|
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The draft is Compose state, so a staged number comes straight back to its field. The
|
||||||
|
* field must keep the operator's text rather than redraw the number: "1" shown as "1.0"
|
||||||
|
* before the next key would make "135,5" impossible to type.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aDecimalCanBeTypedKeyByKey() = runComposeUiTest {
|
||||||
|
val s = Session(preferences())
|
||||||
|
showTab { StationTab(s.state) }
|
||||||
|
|
||||||
|
val field = fieldBeside("QTF")
|
||||||
|
field.performScrollTo().performClick()
|
||||||
|
field.performTextReplacement("")
|
||||||
|
"135,5".forEach { key ->
|
||||||
|
field.performTextInput(key.toString())
|
||||||
|
waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
field.assertTextEquals("135,5")
|
||||||
|
runOnIdle { s.draft.apply() }
|
||||||
|
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBandCheckboxIsStagedAndApplied() = runComposeUiTest {
|
||||||
|
val s = Session(preferences())
|
||||||
|
showTab { StationTab(s.state) }
|
||||||
|
|
||||||
|
toggle("10 GHz")
|
||||||
|
checkBeside("10 GHz").assertIsOn()
|
||||||
|
assertFalse(s.prefs.isStn_bandActive10G, "the box took effect before Apply")
|
||||||
|
|
||||||
|
toggle("144 MHz")
|
||||||
|
runOnIdle { s.draft.apply() }
|
||||||
|
assertTrue(s.prefs.isStn_bandActive10G)
|
||||||
|
assertFalse(s.prefs.isStn_bandActive144)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* In a contest the session is up. Everything but the credentials stays editable then,
|
||||||
|
* and an edit still waits for Apply.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun theDraftedFieldsCanBeEditedWhileConnected() = runComposeUiTest {
|
||||||
|
val s = Session(preferences())
|
||||||
|
s.link.value = On4KstConnectionState.ONLINE
|
||||||
|
showTab { StationTab(s.state) }
|
||||||
|
|
||||||
|
fieldBeside("Callsign").assertIsNotEnabled()
|
||||||
|
fieldBeside("Server port").assertIsEnabled()
|
||||||
|
toggle("10 GHz")
|
||||||
|
type("Server port", "23005")
|
||||||
|
|
||||||
|
assertFalse(s.prefs.isStn_bandActive10G)
|
||||||
|
runOnIdle { s.draft.apply() }
|
||||||
|
assertTrue(s.prefs.isStn_bandActive10G)
|
||||||
|
assertEquals(23005, s.prefs.stn_on4kstServersPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun choosingTheMainCategoryWritesAndAppliesIt() = runComposeUiTest {
|
||||||
|
val s = Session(preferences())
|
||||||
|
s.prefs.loginChatCategoryMain = ChatCategory(2)
|
||||||
|
showTab { StationTab(s.state) }
|
||||||
|
|
||||||
|
val target = s.state.availableCategories.first { it.categoryNumber == 3 }
|
||||||
|
val current = s.state.describeCategory(s.prefs.loginChatCategoryMain)
|
||||||
|
// The main category is the upper of the two pickers; the second may show the same name.
|
||||||
|
onAllNodesWithText(current).onFirst().performScrollTo().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
awaitNode(inPopup(s.state.describeCategory(target))).performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(3, s.prefs.loginChatCategoryMain?.categoryNumber)
|
||||||
|
assertEquals(3, s.mainApplied.single()?.categoryNumber, "the running session was not told")
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The credentials are locked while a session exists or is being made: the running
|
||||||
|
* connection would not pick up a change, so an edit would look applied and be ignored
|
||||||
|
* until the next login. The lock used to be a plain field nothing ever wrote.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun theLoginFieldsAreLockedWhileConnectedOrConnecting() = runComposeUiTest {
|
||||||
|
val s = Session(preferences())
|
||||||
|
showTab { StationTab(s.state) }
|
||||||
|
val credentials = listOf("Callsign", "Name, main category", "Name, second category", "Locator", "Password")
|
||||||
|
|
||||||
|
credentials.forEach { fieldBeside(it).assertIsEnabled() }
|
||||||
|
|
||||||
|
runOnIdle { s.link.value = On4KstConnectionState.CONNECTING }
|
||||||
|
waitForIdle()
|
||||||
|
credentials.forEach { fieldBeside(it).assertIsNotEnabled() }
|
||||||
|
|
||||||
|
runOnIdle { s.link.value = On4KstConnectionState.ONLINE }
|
||||||
|
waitForIdle()
|
||||||
|
credentials.forEach { fieldBeside(it).assertIsNotEnabled() }
|
||||||
|
fieldBeside("Server host name").assertIsEnabled()
|
||||||
|
|
||||||
|
runOnIdle { s.link.value = On4KstConnectionState.DISCONNECTED }
|
||||||
|
waitForIdle()
|
||||||
|
credentials.forEach { fieldBeside(it).assertIsEnabled() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The application hands the tab `surroundings.connectionState`, which ConnectionStateFeed
|
||||||
|
* fills from the controller's callback. The application class itself cannot be built in
|
||||||
|
* a test -- it needs a controller with its database, the windows and the update service --
|
||||||
|
* so this drives the same chain from the feed on: a pushed state reaches the drawn tab
|
||||||
|
* without anything rebuilding it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun theLoginFieldsFollowWhatTheConnectionFeedPushes() = runComposeUiTest {
|
||||||
|
val surroundings = MainWindowSurroundings()
|
||||||
|
val immediately = object : UiDispatcher {
|
||||||
|
override fun runOnUi(task: Runnable) = task.run()
|
||||||
|
override fun isUiThread(): Boolean = true
|
||||||
|
}
|
||||||
|
val feed = ConnectionStateFeed(surroundings, immediately)
|
||||||
|
val state = StationTabState(preferences(), SettingsDraft(), { }, { }, { surroundings.connectionState })
|
||||||
|
showTab { StationTab(state) }
|
||||||
|
|
||||||
|
fieldBeside("Callsign").assertIsEnabled()
|
||||||
|
|
||||||
|
runOnIdle { feed.push(On4KstConnectionState.AUTHENTICATING, null) }
|
||||||
|
waitForIdle()
|
||||||
|
fieldBeside("Callsign").assertIsNotEnabled()
|
||||||
|
|
||||||
|
// An absent state reads as DISCONNECTED in the feed, and unlocks the fields.
|
||||||
|
runOnIdle { feed.push(null, null) }
|
||||||
|
waitForIdle()
|
||||||
|
fieldBeside("Callsign").assertIsEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ownHeightLabel = "Own ground height ASL, metres"
|
||||||
|
private val antennaHeightLabel = "Own antenna height, metres"
|
||||||
|
|
||||||
|
/** Far down the tab: the row lookup compares on-screen bounds, so bring it into view. */
|
||||||
|
private fun androidx.compose.ui.test.ComposeUiTest.scrollToOwnHeight() {
|
||||||
|
onNodeWithText(ownHeightLabel).performScrollTo()
|
||||||
|
waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Types [keys] one by one into the cleared own-height field, as an operator would. */
|
||||||
|
private fun androidx.compose.ui.test.ComposeUiTest.typeKeysIntoOwnHeight(keys: String) {
|
||||||
|
val field = fieldBeside(ownHeightLabel)
|
||||||
|
field.performClick()
|
||||||
|
field.performTextClearance()
|
||||||
|
keys.forEach { field.performTextInput(it.toString()) }
|
||||||
|
waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leaves the own-height field by clicking the antenna height field above it. */
|
||||||
|
private fun androidx.compose.ui.test.ComposeUiTest.leaveOwnHeight() {
|
||||||
|
fieldBeside(antennaHeightLabel).performClick()
|
||||||
|
waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Typed key by key, nothing is staged until the field is left, nothing reaches the
|
||||||
|
* preferences before Apply, and the typed text is never replaced by the value read back
|
||||||
|
* ("2" did turn into "2.0" while typing "250").
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun theOwnHeightIsTypedKeyByKeyAndCommittedOnLeavingTheField() = runComposeUiTest {
|
||||||
|
val s = Session(preferences())
|
||||||
|
val refusals = mutableListOf<String>()
|
||||||
|
showTab { StationTab(s.state) { refusals += it } }
|
||||||
|
scrollToOwnHeight()
|
||||||
|
|
||||||
|
fieldBeside(ownHeightLabel).assertTextEquals("")
|
||||||
|
|
||||||
|
typeKeysIntoOwnHeight("250")
|
||||||
|
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||||
|
assertTrue(s.state.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "staged before the field was left")
|
||||||
|
|
||||||
|
leaveOwnHeight()
|
||||||
|
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||||
|
|
||||||
|
typeKeysIntoOwnHeight("12,5")
|
||||||
|
fieldBeside(ownHeightLabel).assertTextEquals("12,5")
|
||||||
|
leaveOwnHeight()
|
||||||
|
assertEquals(12.5, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||||
|
assertTrue(s.prefs.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "the height took effect before Apply")
|
||||||
|
|
||||||
|
runOnIdle { s.draft.apply() }
|
||||||
|
waitForIdle()
|
||||||
|
assertEquals(12.5, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||||
|
fieldBeside(ownHeightLabel).assertTextEquals("12,5")
|
||||||
|
|
||||||
|
assertEquals(listOf<String>(), refusals)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* After Enter the field keeps the operator's draft while the stored value reads back
|
||||||
|
* differently ("250" against "250.0"). A recomposition of the tab must not swap the
|
||||||
|
* draft for the stored text and drop what was typed since. The tab is forced to
|
||||||
|
* recompose down to the field: the refusal callback captures a counter, so a new
|
||||||
|
* counter value hands the path-analysis section a new lambda and it re-reads the
|
||||||
|
* stored text. A connection change, the tab's own state, is driven as well.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aRecompositionAfterACommitKeepsTheKeysTypedSince() = runComposeUiTest {
|
||||||
|
val s = Session(preferences())
|
||||||
|
val tick = mutableStateOf(0)
|
||||||
|
val refusals = mutableListOf<String>()
|
||||||
|
showTab {
|
||||||
|
val seen = tick.value
|
||||||
|
StationTab(s.state) { refusals += "$it ($seen)" }
|
||||||
|
}
|
||||||
|
scrollToOwnHeight()
|
||||||
|
|
||||||
|
typeKeysIntoOwnHeight("25")
|
||||||
|
fieldBeside(ownHeightLabel).performKeyInput { pressKey(Key.Enter) }
|
||||||
|
waitForIdle()
|
||||||
|
assertEquals(25.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||||
|
|
||||||
|
fieldBeside(ownHeightLabel).performTextInput("0")
|
||||||
|
waitForIdle()
|
||||||
|
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||||
|
|
||||||
|
runOnIdle { tick.value++ }
|
||||||
|
waitForIdle()
|
||||||
|
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||||
|
|
||||||
|
runOnIdle { s.link.value = On4KstConnectionState.CONNECTING }
|
||||||
|
waitForIdle()
|
||||||
|
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||||
|
|
||||||
|
fieldBeside(ownHeightLabel).performTextInput("1")
|
||||||
|
waitForIdle()
|
||||||
|
runOnIdle { tick.value++ }
|
||||||
|
waitForIdle()
|
||||||
|
fieldBeside(ownHeightLabel).assertTextEquals("2501")
|
||||||
|
|
||||||
|
leaveOwnHeight()
|
||||||
|
assertEquals(2501.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||||
|
assertEquals(listOf<String>(), refusals)
|
||||||
|
|
||||||
|
// Applying changes the stored spelling to "2501.0"; the field must keep "2501".
|
||||||
|
runOnIdle { s.draft.apply() }
|
||||||
|
waitForIdle()
|
||||||
|
assertEquals(2501.0, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||||
|
fieldBeside(ownHeightLabel).assertTextEquals("2501")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOutOfRangeOwnHeightIsRefusedAndFallsBackToTheStoredValue() = runComposeUiTest {
|
||||||
|
val s = Session(preferences())
|
||||||
|
val refusals = mutableListOf<String>()
|
||||||
|
showTab { StationTab(s.state) { refusals += it } }
|
||||||
|
scrollToOwnHeight()
|
||||||
|
|
||||||
|
typeAndCommit(ownHeightLabel, "250")
|
||||||
|
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||||
|
|
||||||
|
typeKeysIntoOwnHeight("9500")
|
||||||
|
leaveOwnHeight()
|
||||||
|
|
||||||
|
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "an out-of-range height was written")
|
||||||
|
assertEquals(1, refusals.size, "the refusal was not reported")
|
||||||
|
assertTrue(refusals.single().contains("9500"))
|
||||||
|
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearingTheOwnHeightHandsItBackToTheElevationModel() = runComposeUiTest {
|
||||||
|
val s = Session(preferences())
|
||||||
|
s.prefs.stn_pathAnalysisOwnHeightAslMeters = 312.0
|
||||||
|
showTab { StationTab(s.state) }
|
||||||
|
scrollToOwnHeight()
|
||||||
|
|
||||||
|
fieldBeside(ownHeightLabel).assertTextEquals("312.0")
|
||||||
|
|
||||||
|
typeAndCommit(ownHeightLabel, "")
|
||||||
|
|
||||||
|
assertTrue(s.state.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "clearing did not hand back to the model")
|
||||||
|
assertEquals(312.0, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "cleared before Apply")
|
||||||
|
|
||||||
|
runOnIdle { s.draft.apply() }
|
||||||
|
assertTrue(s.prefs.stn_pathAnalysisOwnHeightAslMeters.isNaN())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theHighestPointCheckboxIsOnAndTakesEffectOnApply() = runComposeUiTest {
|
||||||
|
val s = Session(preferences())
|
||||||
|
showTab { StationTab(s.state) }
|
||||||
|
val label = "Target station on the highest point of its locator square"
|
||||||
|
|
||||||
|
checkBeside(label).assertIsOn()
|
||||||
|
|
||||||
|
toggle(label)
|
||||||
|
checkBeside(label).assertIsOff()
|
||||||
|
assertTrue(s.prefs.isStn_pathAnalysisDxHighestPointInSquare, "the switch took effect before Apply")
|
||||||
|
|
||||||
|
runOnIdle { s.draft.apply() }
|
||||||
|
assertFalse(s.prefs.isStn_pathAnalysisDxHighestPointInSquare)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,24 +3,122 @@ package kst4contest.view.compose.tabs
|
|||||||
import kst4contest.model.ChatCategory
|
import kst4contest.model.ChatCategory
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
class StationTabStateTest {
|
class StationTabStateTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
private class Session {
|
private inner class Session {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
var mainApplied: ChatCategory? = null
|
var mainApplied: ChatCategory? = null
|
||||||
var secondApplied: ChatCategory? = null
|
var secondApplied: ChatCategory? = null
|
||||||
var secondWasApplied = false
|
var secondWasApplied = false
|
||||||
|
val secondRotatorCalls = mutableListOf<Unit>()
|
||||||
|
val draft = SettingsDraft()
|
||||||
val state = StationTabState(
|
val state = StationTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
{ mainApplied = it },
|
{ mainApplied = it },
|
||||||
{ secondApplied = it; secondWasApplied = true },
|
{ secondApplied = it; secondWasApplied = true },
|
||||||
|
applySecondRotator = { secondRotatorCalls.add(Unit) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rotator 2 settings restart it once per apply however many changed`() {
|
||||||
|
val s = Session()
|
||||||
|
|
||||||
|
s.state.stn_pstRotator2Enabled = true
|
||||||
|
s.state.stn_pstRotator2Host = "10.0.0.7"
|
||||||
|
s.state.stn_pstRotator2Port = 12020
|
||||||
|
val outcome = s.draft.apply()
|
||||||
|
|
||||||
|
assertTrue(outcome.isClean, outcome.failures.toString())
|
||||||
|
assertEquals(12020, s.prefs.getStn_pstRotator2Port())
|
||||||
|
assertEquals(1, s.secondRotatorCalls.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rotator 2 on the port of rotator 1 is refused and stays in the draft`() {
|
||||||
|
val s = Session()
|
||||||
|
s.state.stn_pstRotatorPort = 12040
|
||||||
|
|
||||||
|
s.state.stn_pstRotator2Port = 12040
|
||||||
|
val outcome = s.draft.apply()
|
||||||
|
|
||||||
|
assertEquals(1, outcome.failedWrites.size)
|
||||||
|
assertTrue(outcome.failedWrites.single().contains("12040"), outcome.failedWrites.toString())
|
||||||
|
assertEquals(12010, s.prefs.getStn_pstRotator2Port())
|
||||||
|
assertEquals(12040, s.state.stn_pstRotator2Port, "the refused value is still shown")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rotator 1 onto the port of rotator 2 is refused while SO2R and rotator 2 are on`() {
|
||||||
|
val s = Session()
|
||||||
|
s.prefs.setTrxSynch_so2rEnabled(true)
|
||||||
|
s.prefs.setStn_pstRotator2Enabled(true)
|
||||||
|
|
||||||
|
s.state.stn_pstRotatorPort = 12010
|
||||||
|
val outcome = s.draft.apply()
|
||||||
|
|
||||||
|
assertEquals(1, outcome.failedWrites.size, outcome.failedWrites.toString())
|
||||||
|
assertTrue(outcome.failedWrites.single().contains("12010"), outcome.failedWrites.toString())
|
||||||
|
assertNotEquals(12010, s.prefs.getStn_pstRotatorPort())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rotator 1 onto the port of rotator 2 is accepted without SO2R`() {
|
||||||
|
val s = Session()
|
||||||
|
s.prefs.setStn_pstRotator2Enabled(true)
|
||||||
|
|
||||||
|
s.state.stn_pstRotatorPort = 12010
|
||||||
|
val outcome = s.draft.apply()
|
||||||
|
|
||||||
|
assertTrue(outcome.isClean, outcome.failures.toString())
|
||||||
|
assertEquals(12010, s.prefs.getStn_pstRotatorPort())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rotator 1 onto the port of rotator 2 is accepted while rotator 2 is off`() {
|
||||||
|
val s = Session()
|
||||||
|
s.prefs.setTrxSynch_so2rEnabled(true)
|
||||||
|
|
||||||
|
s.state.stn_pstRotatorPort = 12010
|
||||||
|
val outcome = s.draft.apply()
|
||||||
|
|
||||||
|
assertTrue(outcome.isClean, outcome.failures.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `adjacent rotator ports are refused in both directions and distant ones accepted`() {
|
||||||
|
for (p2 in intArrayOf(12041, 12039)) {
|
||||||
|
val s = Session()
|
||||||
|
s.state.stn_pstRotatorPort = 12040
|
||||||
|
s.state.stn_pstRotator2Port = p2
|
||||||
|
val outcome = s.draft.apply()
|
||||||
|
assertEquals(1, outcome.failedWrites.size, "rotor 2 on $p2: ${outcome.failedWrites}")
|
||||||
|
}
|
||||||
|
for (p1 in intArrayOf(12011, 12009)) {
|
||||||
|
val s = Session()
|
||||||
|
s.prefs.setTrxSynch_so2rEnabled(true)
|
||||||
|
s.prefs.setStn_pstRotator2Enabled(true)
|
||||||
|
s.state.stn_pstRotatorPort = p1
|
||||||
|
val outcome = s.draft.apply()
|
||||||
|
assertEquals(1, outcome.failedWrites.size, "rotor 1 on $p1: ${outcome.failedWrites}")
|
||||||
|
}
|
||||||
|
val s = Session()
|
||||||
|
s.prefs.setTrxSynch_so2rEnabled(true)
|
||||||
|
s.prefs.setStn_pstRotator2Enabled(true)
|
||||||
|
s.state.stn_pstRotatorPort = 12030
|
||||||
|
assertTrue(s.draft.apply().isClean)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `reading comes straight from the preferences`() {
|
fun `reading comes straight from the preferences`() {
|
||||||
val s = Session()
|
val s = Session()
|
||||||
@@ -32,18 +130,55 @@ class StationTabStateTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `writing reaches the preferences at once, without a confirmation step`() {
|
fun `a station setting waits in the draft until it is applied`() {
|
||||||
// The JavaFX window had no value-collecting confirmation: each control wrote
|
val s = Session()
|
||||||
// straight into ChatPreferences, and "Save settings" only persisted them.
|
|
||||||
|
s.state.stn_pstRotatorEnabled = true
|
||||||
|
s.state.stn_qtfDefault = 135.0
|
||||||
|
s.state.stn_on4kstServersPort = 23002
|
||||||
|
|
||||||
|
assertFalse(s.prefs.isStn_pstRotatorEnabled(), "an edit took effect before Apply")
|
||||||
|
assertNotEquals(23002, s.prefs.getStn_on4kstServersPort())
|
||||||
|
assertTrue(s.state.stn_pstRotatorEnabled, "the tab shows the edited value")
|
||||||
|
assertEquals(135.0, s.state.stn_qtfDefault, 0.001)
|
||||||
|
|
||||||
|
s.draft.apply()
|
||||||
|
|
||||||
|
assertTrue(s.prefs.isStn_pstRotatorEnabled())
|
||||||
|
assertEquals(135.0, s.prefs.getStn_qtfDefault(), 0.001)
|
||||||
|
assertEquals(23002, s.prefs.getStn_on4kstServersPort())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a discarded station setting leaves the preferences alone`() {
|
||||||
|
val s = Session()
|
||||||
|
s.prefs.setStn_bandActive144(true)
|
||||||
|
|
||||||
|
s.state.stn_bandActive144 = false
|
||||||
|
s.state.stn_bandActive432 = true
|
||||||
|
s.draft.discard()
|
||||||
|
|
||||||
|
assertTrue(s.prefs.isStn_bandActive144())
|
||||||
|
assertFalse(s.prefs.isStn_bandActive432())
|
||||||
|
assertTrue(s.state.stn_bandActive144, "after discarding the tab shows the stored value")
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The credentials stay immediate: they are locked while a session exists, and without
|
||||||
|
* one Connect must use what the tab shows.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the login credentials still write through at once`() {
|
||||||
val s = Session()
|
val s = Session()
|
||||||
|
|
||||||
s.state.stn_loginCallSign = "DO5AMF"
|
s.state.stn_loginCallSign = "DO5AMF"
|
||||||
s.state.stn_pstRotatorEnabled = true
|
s.state.stn_loginLocatorMainCat = "JO51IJ"
|
||||||
s.state.stn_qtfDefault = 135.0
|
s.state.stn_loginPassword = "secret"
|
||||||
|
|
||||||
assertEquals("DO5AMF", s.prefs.getStn_loginCallSign())
|
assertEquals("DO5AMF", s.prefs.getStn_loginCallSign())
|
||||||
assertTrue(s.prefs.isStn_pstRotatorEnabled())
|
assertEquals("JO51IJ", s.prefs.getStn_loginLocatorMainCat())
|
||||||
assertEquals(135.0, s.prefs.getStn_qtfDefault(), 0.001)
|
assertEquals("secret", s.prefs.getStn_loginPassword())
|
||||||
|
assertFalse(s.draft.hasPendingChanges, "a credential went into the draft")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -52,6 +187,7 @@ class StationTabStateTest {
|
|||||||
|
|
||||||
s.state.stn_bandActive144 = true
|
s.state.stn_bandActive144 = true
|
||||||
s.state.stn_bandActive432 = false
|
s.state.stn_bandActive432 = false
|
||||||
|
s.draft.apply()
|
||||||
|
|
||||||
assertTrue(s.prefs.isStn_bandActive144())
|
assertTrue(s.prefs.isStn_bandActive144())
|
||||||
assertEquals(false, s.prefs.isStn_bandActive432())
|
assertEquals(false, s.prefs.isStn_bandActive432())
|
||||||
@@ -81,18 +217,104 @@ class StationTabStateTest {
|
|||||||
assertNull(s.prefs.getLoginChatCategorySecond(),
|
assertNull(s.prefs.getLoginChatCategorySecond(),
|
||||||
"the JavaFX window wrote null for 'no second chat'")
|
"the JavaFX window wrote null for 'no second chat'")
|
||||||
assertTrue(s.secondWasApplied, "the running session must learn about it too")
|
assertTrue(s.secondWasApplied, "the running session must learn about it too")
|
||||||
|
assertFalse(s.draft.hasPendingChanges, "a chat category went into the draft")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the second chat switch lives with the login settings`() {
|
fun `the second chat switch lives with the login settings`() {
|
||||||
// It moved here from the GUI tab: the JavaFX checkbox stood on the Station tab
|
// It moved here from the GUI tab: the JavaFX checkbox stood on the Station tab
|
||||||
// next to the category pickers, and it decides what the Connect button does.
|
// next to the category pickers, and it decides what the Connect button does.
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
val state = StationTabState(prefs, { }, { })
|
val draft = SettingsDraft()
|
||||||
|
val state = StationTabState(prefs, draft, { }, { })
|
||||||
|
|
||||||
state.loginToSecondChatEnabled = true
|
state.loginToSecondChatEnabled = true
|
||||||
|
draft.apply()
|
||||||
|
|
||||||
assertTrue(prefs.isLoginToSecondChatEnabled())
|
assertTrue(prefs.isLoginToSecondChatEnabled())
|
||||||
assertEquals(true, state.loginToSecondChatEnabled)
|
assertEquals(true, state.loginToSecondChatEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the own height above sea level reads empty while the elevation model decides`() {
|
||||||
|
val s = Session()
|
||||||
|
|
||||||
|
assertEquals("", s.state.stn_pathAnalysisOwnHeightAslText)
|
||||||
|
|
||||||
|
assertNull(s.state.commitOwnHeightAsl("312,5"))
|
||||||
|
assertTrue(s.prefs.getStn_pathAnalysisOwnHeightAslMeters().isNaN(), "a commit took effect before Apply")
|
||||||
|
assertEquals(312.5, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||||
|
assertEquals("312,5", s.state.stn_pathAnalysisOwnHeightAslText, "the field keeps the committed spelling")
|
||||||
|
|
||||||
|
s.draft.apply()
|
||||||
|
assertEquals(312.5, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
|
||||||
|
assertEquals("312,5", s.state.stn_pathAnalysisOwnHeightAslText)
|
||||||
|
|
||||||
|
assertNull(s.state.commitOwnHeightAsl(" "))
|
||||||
|
s.draft.apply()
|
||||||
|
assertTrue(s.prefs.getStn_pathAnalysisOwnHeightAslMeters().isNaN(), "clearing hands it back to the model")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a discarded own height shows the stored value again`() {
|
||||||
|
val s = Session()
|
||||||
|
s.prefs.setStn_pathAnalysisOwnHeightAslMeters(400.0)
|
||||||
|
|
||||||
|
assertNull(s.state.commitOwnHeightAsl("250"))
|
||||||
|
assertEquals("250", s.state.stn_pathAnalysisOwnHeightAslText)
|
||||||
|
|
||||||
|
s.draft.discard()
|
||||||
|
|
||||||
|
assertEquals("400.0", s.state.stn_pathAnalysisOwnHeightAslText, "the discarded spelling is still shown")
|
||||||
|
assertEquals(400.0, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an own height that does not parse or is out of range is not staged`() {
|
||||||
|
val s = Session()
|
||||||
|
assertNull(s.state.commitOwnHeightAsl("250"))
|
||||||
|
|
||||||
|
listOf("25x", "9500", "-600", "NaN").forEach { entry ->
|
||||||
|
val refusal = s.state.commitOwnHeightAsl(entry)
|
||||||
|
assertTrue(refusal != null && refusal.contains(entry), "no refusal naming \"$entry\": $refusal")
|
||||||
|
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "\"$entry\" was staged")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.draft.apply()
|
||||||
|
assertEquals(250.0, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an own height draft is unchanged when it names the stored number`() {
|
||||||
|
assertTrue(StationTabState.isOwnHeightAslUnchanged("250", "250.0"))
|
||||||
|
assertTrue(StationTabState.isOwnHeightAslUnchanged("250,0", "250.0"))
|
||||||
|
assertTrue(StationTabState.isOwnHeightAslUnchanged(" ", ""))
|
||||||
|
assertFalse(StationTabState.isOwnHeightAslUnchanged("", "250.0"))
|
||||||
|
assertFalse(StationTabState.isOwnHeightAslUnchanged("250", ""))
|
||||||
|
assertFalse(StationTabState.isOwnHeightAslUnchanged("12.5", "12.0"))
|
||||||
|
assertFalse(StationTabState.isOwnHeightAslUnchanged("9500", "250.0"), "an unparsable draft must be committed and refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parsing the own height`() {
|
||||||
|
assertTrue(StationTabState.parseOwnHeightAsl("")!!.isNaN())
|
||||||
|
assertEquals(-430.0, StationTabState.parseOwnHeightAsl("-430"))
|
||||||
|
assertEquals(8848.0, StationTabState.parseOwnHeightAsl(" 8848 "))
|
||||||
|
assertNull(StationTabState.parseOwnHeightAsl("9000.5"))
|
||||||
|
assertNull(StationTabState.parseOwnHeightAsl("-"))
|
||||||
|
assertNull(StationTabState.parseOwnHeightAsl("NaN"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the highest point switch waits in the draft until it is applied`() {
|
||||||
|
val s = Session()
|
||||||
|
assertTrue(s.state.stn_pathAnalysisDxHighestPointInSquare, "on by default")
|
||||||
|
|
||||||
|
s.state.stn_pathAnalysisDxHighestPointInSquare = false
|
||||||
|
assertTrue(s.prefs.isStn_pathAnalysisDxHighestPointInSquare(), "the switch took effect before Apply")
|
||||||
|
assertFalse(s.state.stn_pathAnalysisDxHighestPointInSquare)
|
||||||
|
|
||||||
|
s.draft.apply()
|
||||||
|
assertFalse(s.prefs.isStn_pathAnalysisDxHighestPointInSquare())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
/** The corrections made once before the settings window is built, outside any draft. */
|
||||||
|
class StoredSettingsRepairTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
lateinit var directory: Path
|
||||||
|
|
||||||
|
private fun preferences() = ChatPreferences(directory.resolve("preferences.xml"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unsupported fallback band prefix is written back as 144`() {
|
||||||
|
val prefs = preferences()
|
||||||
|
prefs.setNotify_optionalFrequencyPrefix("4711")
|
||||||
|
|
||||||
|
repairStoredSettings(prefs) { null }
|
||||||
|
|
||||||
|
assertEquals("144", prefs.getNotify_optionalFrequencyPrefix().get(),
|
||||||
|
"the JavaFX window repaired the stored value the same way")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a still-default broadcast address is replaced by the detected one`() {
|
||||||
|
val prefs = preferences()
|
||||||
|
prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
|
||||||
|
|
||||||
|
repairStoredSettings(prefs) { "192.168.178.255" }
|
||||||
|
|
||||||
|
assertEquals("192.168.178.255", prefs.getLogsynch_wintestNetworkBroadcastAddress(),
|
||||||
|
"the JavaFX window auto-detected the subnet broadcast while the stored "
|
||||||
|
+ "value was still the catch-all default")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a broadcast address the operator chose is never overwritten`() {
|
||||||
|
val prefs = preferences()
|
||||||
|
prefs.setLogsynch_wintestNetworkBroadcastAddress("10.0.0.255")
|
||||||
|
|
||||||
|
repairStoredSettings(prefs) { "192.168.178.255" }
|
||||||
|
|
||||||
|
assertEquals("10.0.0.255", prefs.getLogsynch_wintestNetworkBroadcastAddress())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failed detection leaves the default in place`() {
|
||||||
|
val prefs = preferences()
|
||||||
|
prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
|
||||||
|
|
||||||
|
repairStoredSettings(prefs) { throw IllegalStateException("no interface") }
|
||||||
|
|
||||||
|
assertEquals("255.255.255.255", prefs.getLogsynch_wintestNetworkBroadcastAddress())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
|
import androidx.compose.ui.test.assertIsOn
|
||||||
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.ImmediateDraft
|
||||||
|
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 three integration tabs, drawn: Log synch, TRX synch and Airscout (#107).
|
||||||
|
*
|
||||||
|
* All three draw from a facade with no snapshot state in it and count a revision on every
|
||||||
|
* write so the control redraws. That device is what these tests watch: a write that
|
||||||
|
* reached the preferences and not the screen is the class-1 fault of `83e23f55`.
|
||||||
|
*
|
||||||
|
* The "Choose..." button of the log file is never pressed here: it opens a modal AWT file
|
||||||
|
* dialog, which has no place in a headless test.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class SynchTabsScreenTest {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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"))
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Log synch
|
||||||
|
|
||||||
|
private class LogSynch(isolated: ChatPreferences, detected: String? = "192.168.1.255") {
|
||||||
|
val prefs = isolated.apply {
|
||||||
|
// Both listeners default to on; start from off so a tick is a visible change.
|
||||||
|
isLogsynch_ucxUDPWkdCallListenerEnabled = false
|
||||||
|
isLogsynch_wintestNetworkListenerEnabled = false
|
||||||
|
}
|
||||||
|
var restarts = 0
|
||||||
|
var stops = 0
|
||||||
|
val state = run {
|
||||||
|
// What the settings window does once before it builds the tabs.
|
||||||
|
repairStoredSettings(prefs) { detected }
|
||||||
|
LogSynchTabState(prefs, ImmediateDraft(), { restarts++ }, { stops++ })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun logSynchShowsItsSectionsAndTheDetectedBroadcastAddress() = runComposeUiTest {
|
||||||
|
val s = LogSynch(preferences())
|
||||||
|
showTab { LogSynchTab(s.state) }
|
||||||
|
|
||||||
|
onNodeWithText("File polling for worked callsigns").assertExists()
|
||||||
|
onNodeWithText("Network-based QSO synchronization (N1MM/QARTEST/UCXLog/DXLog)").assertExists()
|
||||||
|
onNodeWithText("Win-Test Network-Listener").assertExists()
|
||||||
|
onNodeWithText("Choose...").assertExists()
|
||||||
|
fieldBeside("UDP broadcast address for Win-Test (default = internet interface broadcast)")
|
||||||
|
.assertTextEquals("192.168.1.255")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun logSynchStoresItsFieldsAndStartsTheWinTestListener() = runComposeUiTest {
|
||||||
|
val s = LogSynch(preferences())
|
||||||
|
showTab { LogSynchTab(s.state) }
|
||||||
|
|
||||||
|
toggle("Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net")
|
||||||
|
assertTrue(s.prefs.isLogsynch_ucxUDPWkdCallListenerEnabled)
|
||||||
|
checkBeside("Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net").assertIsOn()
|
||||||
|
|
||||||
|
type("Shared UDP port for QSO and TRX messages [default 12060]:", "12061")
|
||||||
|
assertEquals(12061, s.prefs.logsynch_ucxUDPWkdCallListenerPort)
|
||||||
|
|
||||||
|
val winTestPort = "UDP-Port for Win-Test listener (default is 9871)"
|
||||||
|
// The port field is greyed out until the listener is switched on; greyed out, it
|
||||||
|
// offers no text input, so it is found by the port it shows.
|
||||||
|
onNodeWithText(s.prefs.logsynch_wintestNetworkPort.toString()).assertIsNotEnabled()
|
||||||
|
toggle("Receive Win-Test network based UDP log messages")
|
||||||
|
assertTrue(s.prefs.isLogsynch_wintestNetworkListenerEnabled)
|
||||||
|
assertEquals(1, s.restarts, "switching the listener on did not start it")
|
||||||
|
|
||||||
|
type(winTestPort, "9872")
|
||||||
|
assertEquals(9872, s.prefs.logsynch_wintestNetworkPort)
|
||||||
|
|
||||||
|
// A refused entry is kept on screen as typed, and nothing is written.
|
||||||
|
type(winTestPort, "98x")
|
||||||
|
fieldBeside(winTestPort).assertTextEquals("98x")
|
||||||
|
assertEquals(9872, s.prefs.logsynch_wintestNetworkPort)
|
||||||
|
|
||||||
|
type("KST station name in Win-Test network (src of SKED packets)", " KST2 ")
|
||||||
|
assertEquals("KST2", s.prefs.logsynch_wintestNetworkStationNameOfKST)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ TRX synch
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trxSynchStoresItsFieldsAndSwitchesTheQrgFollower() = runComposeUiTest {
|
||||||
|
val prefs = preferences()
|
||||||
|
val follower = mutableListOf<Boolean>()
|
||||||
|
val state = TrxSynchTabState(prefs, ImmediateDraft()) { follower += it }
|
||||||
|
showTab { TrxSynchTab(state) }
|
||||||
|
|
||||||
|
onNodeWithText("Receive UCXLog TRX info").assertExists()
|
||||||
|
onNodeWithText("What an enabled source does").assertExists()
|
||||||
|
|
||||||
|
toggle("Update MYQRG from RadioInfo messages received on the shared log-sync port")
|
||||||
|
assertTrue(prefs.isTrxSynch_ucxLogUDPListenerEnabled)
|
||||||
|
assertEquals(listOf(true), follower)
|
||||||
|
|
||||||
|
toggle("Use pass frequency from Win-Test STATUS (instead of own QRG)")
|
||||||
|
assertTrue(prefs.isLogsynch_wintestUsePassQrg)
|
||||||
|
|
||||||
|
type("Win-Test station name filter (e.g. STN1, empty = accept all)", "STN1 ")
|
||||||
|
assertEquals("STN1", prefs.logsynch_wintestNetworkStationNameOfWintestClient1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Airscout
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun airscoutWritesValidEntriesAndKeepsAnInvalidOneOffThePreferences() = runComposeUiTest {
|
||||||
|
// The link defaults to on; start from off so a tick is a visible change.
|
||||||
|
val prefs = preferences().apply { isAirScout_asUDPListenerEnabled = false }
|
||||||
|
val state = AirscoutTabState(prefs, ImmediateDraft())
|
||||||
|
showTab { AirscoutTab(state) }
|
||||||
|
|
||||||
|
onNodeWithText("AirScout UDP communication").assertExists()
|
||||||
|
onNodeWithText("Queried frequency").assertExists()
|
||||||
|
|
||||||
|
toggle("Enable AirScout UDP integration (AirScout 0.9.9.5 or newer)")
|
||||||
|
assertTrue(prefs.isAirScout_asUDPListenerEnabled)
|
||||||
|
|
||||||
|
val port = "AirScout UDP port [9872] — reconnect after changing:"
|
||||||
|
type(port, "9873")
|
||||||
|
assertEquals(9873, prefs.airScout_asCommunicationPort)
|
||||||
|
|
||||||
|
type(port, "70000")
|
||||||
|
fieldBeside(port).assertTextEquals("70000")
|
||||||
|
assertEquals(9873, prefs.airScout_asCommunicationPort, "an out-of-range port was written")
|
||||||
|
|
||||||
|
type("AirScout server identifier [AS]:", "AS2")
|
||||||
|
assertEquals("AS2", prefs.airScout_asServerNameString)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,39 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
class TrxSynchTabStateTest {
|
class TrxSynchTabStateTest(@TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
private class Session {
|
private inner class Session {
|
||||||
val prefs = ChatPreferences()
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
val followerCalls = mutableListOf<Boolean>()
|
val followerCalls = mutableListOf<Boolean>()
|
||||||
val state by lazy { TrxSynchTabState(prefs) { followerCalls.add(it) } }
|
val draft = SettingsDraft()
|
||||||
|
val secondRotatorCalls = mutableListOf<Unit>()
|
||||||
|
val state by lazy {
|
||||||
|
TrxSynchTabState(prefs, draft, applySecondRotator = { secondRotatorCalls.add(Unit) }) { followerCalls.add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the SO2R switch and the second Win-Test station wait for apply and restart rotator 2 once`() {
|
||||||
|
val s = Session()
|
||||||
|
|
||||||
|
s.state.trxSynch_so2rEnabled = true
|
||||||
|
s.state.logsynch_wintestNetworkStationNameOfWintestClient2 = " STN2 "
|
||||||
|
|
||||||
|
assertFalse(s.prefs.isTrxSynch_so2rEnabled())
|
||||||
|
s.draft.apply()
|
||||||
|
|
||||||
|
assertTrue(s.prefs.isTrxSynch_so2rEnabled())
|
||||||
|
assertEquals("STN2", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2())
|
||||||
|
assertEquals(1, s.secondRotatorCalls.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -29,45 +51,66 @@ class TrxSynchTabStateTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `writing reaches the preferences at once, without a confirmation step`() {
|
fun `an edit waits for apply`() {
|
||||||
val s = Session()
|
val s = Session()
|
||||||
|
|
||||||
s.state.trxSynch_ucxLogUDPListenerEnabled = true
|
s.state.trxSynch_ucxLogUDPListenerEnabled = true
|
||||||
s.state.logsynch_wintestQrgSyncEnabled = true
|
|
||||||
s.state.logsynch_wintestUsePassQrg = true
|
s.state.logsynch_wintestUsePassQrg = true
|
||||||
|
|
||||||
|
assertFalse(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled(), "an edit took effect before Apply")
|
||||||
|
assertTrue(s.state.trxSynch_ucxLogUDPListenerEnabled, "the tab shows the edit")
|
||||||
|
assertTrue(s.followerCalls.isEmpty(), "the follower moved before Apply")
|
||||||
|
|
||||||
|
s.draft.apply()
|
||||||
|
|
||||||
assertTrue(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled())
|
assertTrue(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled())
|
||||||
assertTrue(s.prefs.isLogsynch_wintestQrgSyncEnabled())
|
|
||||||
assertTrue(s.prefs.isLogsynch_wintestUsePassQrg())
|
assertTrue(s.prefs.isLogsynch_wintestUsePassQrg())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a discarded edit changes nothing`() {
|
||||||
|
val s = Session()
|
||||||
|
|
||||||
|
s.state.trxSynch_ucxLogUDPListenerEnabled = true
|
||||||
|
s.draft.discard()
|
||||||
|
|
||||||
|
assertFalse(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled())
|
||||||
|
assertTrue(s.followerCalls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the Win-Test station name filter is stored trimmed, and an empty filter is allowed`() {
|
fun `the Win-Test station name filter is stored trimmed, and an empty filter is allowed`() {
|
||||||
// Empty means "accept all" — the JavaFX label said so explicitly.
|
// Empty means "accept all" — the JavaFX label said so explicitly.
|
||||||
val s = Session()
|
val s = Session()
|
||||||
|
|
||||||
s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " STN1 "
|
s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " STN1 "
|
||||||
|
s.draft.apply()
|
||||||
assertEquals("STN1", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
|
assertEquals("STN1", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
|
||||||
|
|
||||||
s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " "
|
s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " "
|
||||||
|
s.draft.apply()
|
||||||
assertEquals("", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
|
assertEquals("", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the own-QRG follower follows whether any source is enabled`() {
|
fun `the own-QRG follower follows whether any source is enabled, once per apply`() {
|
||||||
val s = Session()
|
val s = Session()
|
||||||
|
|
||||||
|
// Both sources in one apply: one call, with the finished state.
|
||||||
s.state.trxSynch_ucxLogUDPListenerEnabled = true
|
s.state.trxSynch_ucxLogUDPListenerEnabled = true
|
||||||
|
s.state.logsynch_wintestQrgSyncEnabled = false
|
||||||
|
s.draft.apply()
|
||||||
assertEquals(listOf(true), s.followerCalls)
|
assertEquals(listOf(true), s.followerCalls)
|
||||||
|
|
||||||
s.state.logsynch_wintestQrgSyncEnabled = true
|
|
||||||
assertEquals(listOf(true, true), s.followerCalls)
|
|
||||||
|
|
||||||
// One source off is not all sources off: the follower must stay attached.
|
// One source off is not all sources off: the follower must stay attached.
|
||||||
|
s.state.logsynch_wintestQrgSyncEnabled = true
|
||||||
|
s.draft.apply()
|
||||||
s.state.trxSynch_ucxLogUDPListenerEnabled = false
|
s.state.trxSynch_ucxLogUDPListenerEnabled = false
|
||||||
|
s.draft.apply()
|
||||||
assertEquals(listOf(true, true, true), s.followerCalls)
|
assertEquals(listOf(true, true, true), s.followerCalls)
|
||||||
|
|
||||||
s.state.logsynch_wintestQrgSyncEnabled = false
|
s.state.logsynch_wintestQrgSyncEnabled = false
|
||||||
|
s.draft.apply()
|
||||||
assertEquals(listOf(true, true, true, false), s.followerCalls)
|
assertEquals(listOf(true, true, true, false), s.followerCalls)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
package kst4contest.view.i18n
|
||||||
|
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.SettingsContent
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.SettingsNotices
|
||||||
|
import kst4contest.view.compose.SettingsTab
|
||||||
|
import kst4contest.view.compose.StationColumns
|
||||||
|
import kst4contest.view.compose.tabs.BeaconTabState
|
||||||
|
import kst4contest.view.compose.tabs.GuiOptionsTab
|
||||||
|
import kst4contest.view.compose.tabs.GuiOptionsTabState
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The texts that stayed English after stage 9 now follow the language as well.
|
||||||
|
*
|
||||||
|
* One sample from each of the three ways a text reaches the operator: a label read inside a
|
||||||
|
* composition, a column title built outside one, and a refusal produced by a tab state. All
|
||||||
|
* three have to switch, and each goes through a different door.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class RemainingTextsFollowTheLanguageTest {
|
||||||
|
|
||||||
|
/* Never the no-argument ChatPreferences: it would touch the operator's real profile. */
|
||||||
|
@TempDir
|
||||||
|
lateinit var directory: Path
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theMovedTextsAppearInGermanAfterTheSwitch() = runComposeUiTest {
|
||||||
|
var stored = "en"
|
||||||
|
val store = LanguageStore({ stored }, { stored = it }, Locale.UK)
|
||||||
|
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
|
||||||
|
val state = GuiOptionsTabState(prefs, SettingsDraft(), store)
|
||||||
|
val beacon = BeaconTabState(
|
||||||
|
prefs,
|
||||||
|
SettingsDraft(),
|
||||||
|
validateBeaconTemplate = {},
|
||||||
|
restartBeaconTimer = {},
|
||||||
|
mainCategoryName = "Microwave",
|
||||||
|
secondCategoryName = "144/432 MHz",
|
||||||
|
)
|
||||||
|
val score = StationColumns.all(
|
||||||
|
tropoOf = { "" },
|
||||||
|
priorityScoreOf = { 0.0 },
|
||||||
|
formatBand = { _, _, _ -> "" },
|
||||||
|
).single { it.id == "score" }
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
CompositionLocalProvider(LocalStrings provides store.strings) {
|
||||||
|
GuiOptionsTab(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
onNodeWithText("Start in the evening design").assertExists()
|
||||||
|
assertEquals("Score", score.title())
|
||||||
|
|
||||||
|
try {
|
||||||
|
runOnIdle { store.use("de") }
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
onNodeWithText("Im Abenddesign starten").assertExists()
|
||||||
|
onNodeWithText("Kein Filter").assertExists()
|
||||||
|
onNodeWithText("Ein gearbeitetes Großfeld markieren").assertExists()
|
||||||
|
|
||||||
|
assertEquals("Wertung", score.title(), "the column title stayed in the old language")
|
||||||
|
assertEquals(
|
||||||
|
"Einen ganzzahligen Bakenabstand von mindestens 1 Minute eingeben.",
|
||||||
|
beacon.commitBeaconInterval("0"),
|
||||||
|
"the refusal from the tab state stayed in the old language",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
// use() also sets the JVM-wide CurrentStrings; leave the next test the base language.
|
||||||
|
runOnIdle { store.use("en") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings window's notice: its title is read in the composition, its message is
|
||||||
|
* produced outside one by the save action -- the same split as above, one window further.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun theSettingsNoticeFollowsTheLanguage() = runComposeUiTest {
|
||||||
|
var stored = "en"
|
||||||
|
val store = LanguageStore({ stored }, { stored = it }, Locale.UK)
|
||||||
|
val notices = SettingsNotices()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
CompositionLocalProvider(LocalStrings provides store.strings) {
|
||||||
|
SettingsContent(
|
||||||
|
tabs = listOf(SettingsTab("Tab") {}),
|
||||||
|
notices = notices,
|
||||||
|
buttons = {},
|
||||||
|
close = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
runOnIdle {
|
||||||
|
store.use("de")
|
||||||
|
notices.problem(CurrentStrings.get().settingsWriteFailed)
|
||||||
|
}
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
onNodeWithText("Einstellungen").assertExists()
|
||||||
|
onNodeWithText(
|
||||||
|
"Die Konfigurationsdatei konnte nicht geschrieben werden. " +
|
||||||
|
"Die Einstellungen bleiben für diese Sitzung aktiv.",
|
||||||
|
).assertExists()
|
||||||
|
} finally {
|
||||||
|
runOnIdle { store.use("en") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user