mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-19 05:42:58 +02:00
* docs: add the test-coverage design spec (#62) Records the state found for issue #62 (JUnit classes under src/main that never run, test libraries in the shipped app image, dead 2023/2024 scratch tests, no coverage figure), the decisions taken (delete, move to src/test, JaCoCo report-only without a threshold, tests before packaging), what is deliberately left to separate issues, and the definition of done. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: move the JUnit classes out of src/main and drop the dead 2023 tests (#62) Gradle's test task scans src/test only, so the six JUnit classes under src/main compiled on every build and were never executed. They move to src/test of the module that owns the class under test, keeping their package names: On4KstProtocolTest, On4KstSocketThreadTest and ChatMemberDirectionOpportunityTest within core; Utils4KSTFrequencyTest, FrequencyTextParserTest and ChatPreferencesStationMapVisibilityTest from app-desktop into core, next to the sibling tests that already live there, because they test core classes only. All 26 of them run and pass. The fully commented-out MessageBusManagementThreadFrequencyContextTest is deleted. Eight files under core/src/test/java/kst4contest/test from 2023/2024 go as well: they carry no assertion, five of them annotate a static main() with @Test, which Jupiter never runs, and some write the real preferences file, send real UDP broadcasts or open the real user database when started by hand. The five main() scratch programs under src/main (ReadUDPByWintestThreadTest, PatternMatcherTest, mapTest, RegexTester, locatorUtils/TestLocatorUtils) and MockKstServer, which only an unused import in ChatController still referenced, are removed too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * build: make JUnit and Mockito test-only dependencies (#62) With no test class left under src/main, the implementation declarations of junit-jupiter-api and mockito-core in core and app-desktop lose their only justification. They put junit-jupiter-api, junit-platform-commons, opentest4j, mockito-core, byte-buddy, byte-buddy-agent and objenesis on the runtime classpath and into lib/app of every app image, Flatpak and installer. The duplicate catalog entries junit-jupiter-api, mockito-core-compile and the mockitoCompile version go with them. Verified: `:app-desktop:dependencies --configuration runtimeClasspath` no longer lists any of the seven artifacts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: state that test failures fail the build and tests live under src/test (#62) AGENTS.md and docs/PROJECT_CONTEXT.md still said the build configuration had allowed test failures to pass. That was true of the Maven build; under Gradle a test failure fails the build, and only PMD and SpotBugs run with ignoreFailures. Both documents now say so, note that test classes live under src/test only and that JUnit and Mockito are test-only dependencies, and keep the rule to read the test-result XML rather than the exit code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * build: add a JaCoCo coverage report (#62) The jacoco plugin is applied to every module from the root build with the tool version held in the catalog. jacocoTestReport now depends on test and writes XML and HTML per module; a root task jacocoAggregatedReport merges core and app-desktop into build/reports/jacoco/aggregated/ (XML plus HTML). i18n-generator is excluded: it is a build tool and its coverage says nothing about the shipped application. Kotlin classes are counted, because the Kotlin plugin adds its output to the main source set the report reads. Report only, on purpose. There is no jacocoTestCoverageVerification and no threshold: a minimum set at today's number would trip on the next unrelated change, and one set at a target would fail every build until tests exist for the number's sake. The figure is published so it can be read when deciding what to test next; the reasoning is in docs/superpowers/specs/2026-10-11-test-coverage-design.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci: run the tests before packaging and publish coverage (#62) The nightly and tagged-release workflows built seven package formats from commits nobody had tested on the way: pr-compile-check.yml runs the suite for pull requests only, and main can be pushed to directly. Both workflows now carry a copy of that test job, including the libgl1 step and the XML-based summary, and every packaging job declares `needs: test`. Copied rather than factored into a reusable workflow on purpose: the comments in the job explain decisions the packaging workflows should carry with them, and three copies read better than one indirection. build-docs-pdf runs pandoc only and is left alone; publish-flatpak-repo and release-tag are gated through the jobs they already need. The PR test job additionally runs jacocoTestReport and jacocoAggregatedReport once the tests have run (not `if: always()`: a report from an incomplete run would mislead), prints a line/branch coverage table per module and aggregated into the job summary from the JaCoCo XML, and uploads the aggregated HTML report as the coverage-report artifact for 14 days whenever it exists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: describe the coverage report in the project context (#62) A short paragraph on JaCoCo in docs/PROJECT_CONTEXT.md: report only and why, where the per-module and aggregated reports land, which Gradle tasks write them, that i18n-generator is excluded, how the PR job summary and the coverage-report artifact show the figure, and that the nightly and tagged-release workflows run the tests before any packaging job. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: round-trip every ChatPreferences field through the XML file (#62) Drive all public single-argument setters by reflection to non-default values, write preferences.xml, read it back and compare every getter. Fields that are not persisted on purpose are listed with their evidence; two real reader/writer gaps (second-category locator, sniffer words) are documented with a BUG marker instead of being fixed here. Two further tests load the bundled template and a legacy file without configVersion. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: cover the DBController schema migration from older user_version values (#62) Open the seeded template database and hand-built release 1.1 and 1.3 schema fixtures with a DBController and verify that PRAGMA user_version reaches the current level, every column of the current CREATE TABLE statements exists, legacy callsign keys are merged to base callsigns and existing rows and flags survive. A further test proves that reopening an up-to-date database changes neither schema nor rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore: drop the committed JVM crash report and ignore future ones (#62) hs_err_pid26344.log in the repository root was a HotSpot crash dump that had been committed by accident. It carries no information about the project and must not travel with the sources. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: drop the dead QSO sniffer word list that corrupted the callsign list on load (#103) ChatPreferences carried a second sniffer list, lstNotify_QSOSniffer_sniffedWordsList, that no UI, no manual and no consumer outside ChatPreferences ever touched. It was still written to preferences.xml as <snifferWords><w>, and the matching reader block was a copy of the <snifferCallSigns> block: it cleared the word list and then added every word, normalised as a callsign, to lstNotify_QSOSniffer_sniffedCallSignList. Any entry that ever reached the word list therefore leaked into QSO monitoring on every load. Remove the field, its accessors, the write block and the read block. A <snifferWords> element in an older preferences file is now ignored like any unknown element; the callsign list and its persistence are unchanged. ChatPreferencesSnifferListTest loads a file that carries both lists and asserts that only the <snifferCallSigns> entry is monitored. The round-trip test loses its exclusion entry for the removed setter. Fixes #103 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: remove the second-category locator that could never be set (#104) ChatPreferences declared stn_loginLocatorSecondCat with the compiled-in default "jn49fk". Nothing wrote it to or read it from preferences.xml, no settings tab exposed it and no caller ever invoked the setter, so the field always held its default. On4KstConnectionManager.configureMainChat nevertheless compared that default with the station's locator and, with the second chat enabled, raised the warning "ON4KST uses one locator per TCP session. The second-chat locator ... is ignored" whenever the station's locator was not jn49fk, which is practically every installation. ON4KST uses one locator per TCP session by design, so a second-category locator can never become meaningful. Remove the field, its accessors, the comparison and the warning; the rest of configureMainChat is unchanged. The round-trip test loses its exclusion entry for the removed setter. No automated test covers configureMainChat: it is private, works on the manager's private Session and needs a mocked ChatController plus a live socket handshake, so a harness would far exceed the scope of this fix. The compile of core and app-desktop and the shorter exclusion list are the evidence that nothing references the member. Fixes #104 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a90ae7d30
commit
5491cbcf8b
42 files changed
+1205
-2025
No files matched your search
@@ -25,9 +25,104 @@ permissions:
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
# Same job as in pr-compile-check.yml, copied rather than shared: a reusable workflow
|
||||
# would be one more indirection to read, and the comments belong next to the steps they
|
||||
# explain. Every packaging job below needs this one, so a commit whose tests fail builds
|
||||
# no artifact (#62). Edit all three copies together.
|
||||
test:
|
||||
name: Test (Java 21, headless)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Java 21
|
||||
uses: actions/setup-java@v6.0.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Ensure gradlew is executable
|
||||
run: chmod +x gradlew
|
||||
|
||||
# Skiko's native library links against libGL, so it has to be loadable even though
|
||||
# nothing here renders through it: the drawing tests use Skia's software backend and
|
||||
# runComposeUiTest never opens a window. Without this the whole Compose half of the
|
||||
# suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object
|
||||
# file" -- which reads like a missing display and is not one. Found by running this
|
||||
# job under act, where the container has no libGL; GitHub's own runner image happens
|
||||
# to ship it, so the step is what makes the job reproducible off GitHub.
|
||||
- name: Install the OpenGL client library skiko links against
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libgl1
|
||||
|
||||
# No Xvfb, deliberately. Everything that pins behaviour runs without a display:
|
||||
# runComposeUiTest reaches the semantics tree headless (measured before this job was
|
||||
# written), and the ImageComposeScene drawing tests render in software. The handful
|
||||
# of tests that open a real window guard themselves with assumeFalse(isHeadless) and
|
||||
# skip here; the summary below prints the skip count, so that is visible rather than
|
||||
# assumed.
|
||||
#
|
||||
# Software GL under Xvfb was considered and rejected: a missing Skia GL context is
|
||||
# the hardest failure in this codebase to read, and importing that class of flake
|
||||
# into CI would cost more than the few window tests are worth here.
|
||||
|
||||
# All three modules. The translation generator is a Gradle module and not buildSrc
|
||||
# precisely so that its 42 tests run here: buildSrc tests do not run on `./gradlew
|
||||
# build` -- measured, not assumed. The summary below globs */build/test-results, so
|
||||
# the module was counted the moment it existed.
|
||||
- name: Test
|
||||
run: ./gradlew -S test
|
||||
|
||||
- name: Summarise the test results
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
files=(*/build/test-results/test/TEST-*.xml)
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No test result XML was produced -- the test task did not run." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Counted from the XML and not from Gradle's console output: this project's own
|
||||
# rule is never to infer "all tests passed" from an exit code.
|
||||
summary=$(grep -h "<testsuite " "${files[@]}" \
|
||||
| sed 's/.*tests="\([0-9]*\)".*skipped="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3 \4/' \
|
||||
| awk '{t+=$1; s+=$2; f+=$3; e+=$4} END {print t" "s" "f" "e}')
|
||||
read -r total skipped failures errors <<< "$summary"
|
||||
echo "tests=$total skipped=$skipped failures=$failures errors=$errors"
|
||||
{
|
||||
echo "### Tests"
|
||||
echo ""
|
||||
echo "| total | skipped | failures | errors |"
|
||||
echo "|---|---|---|---|"
|
||||
echo "| $total | $skipped | $failures | $errors |"
|
||||
echo ""
|
||||
echo "Skipped are the tests that need a display, plus the deliberately"
|
||||
echo "disabled reference-data generator. This job runs headless on purpose."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then
|
||||
echo "The result XML reports failures or errors." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Keep the test reports on a failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
*/build/test-results/test/
|
||||
*/build/reports/tests/test/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
build-windows-zip:
|
||||
name: Build Windows ZIP
|
||||
runs-on: windows-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -80,6 +175,7 @@ jobs:
|
||||
build-linux-appimage:
|
||||
name: Build Linux AppImage
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -153,6 +249,7 @@ jobs:
|
||||
build-linux-deb:
|
||||
name: Build Debian package
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -198,6 +295,7 @@ jobs:
|
||||
build-linux-rpm:
|
||||
name: Build Fedora package
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -243,6 +341,7 @@ jobs:
|
||||
build-linux-arch:
|
||||
name: Build Arch Linux package
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -354,6 +453,7 @@ jobs:
|
||||
build-flatpak:
|
||||
name: Build Flatpak
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -538,6 +638,7 @@ jobs:
|
||||
build-macos-dmg:
|
||||
name: Build macOS DMG (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -141,6 +141,14 @@ jobs:
|
||||
- name: Test
|
||||
run: ./gradlew -S test
|
||||
|
||||
# Coverage is a report, not a gate: no threshold, nothing in it can fail on a
|
||||
# percentage. Deliberately not `if: always()` -- without a completed test run the
|
||||
# report would be partial, and after a failed test the result XML below is what matters.
|
||||
# jacocoTestReport writes the per-module XML the summary reads; the aggregate merges
|
||||
# core and app-desktop (i18n-generator is build tooling and stays out).
|
||||
- name: Coverage report
|
||||
run: ./gradlew -S jacocoTestReport jacocoAggregatedReport
|
||||
|
||||
- name: Summarise the test results
|
||||
if: always()
|
||||
run: |
|
||||
@@ -173,6 +181,50 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Summarise the coverage
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The last LINE/BRANCH counter in a JaCoCo XML is the report-level total; the
|
||||
# per-package and per-class counters come before it.
|
||||
percent() {
|
||||
local file="$1" type="$2"
|
||||
{ grep -o "<counter type=\"$type\"[^>]*/>" "$file" || true; } | tail -1 \
|
||||
| sed 's/.*missed="\([0-9]*\)".*covered="\([0-9]*\)".*/\1 \2/' \
|
||||
| awk 'NF == 2 { t=$1+$2; if (t==0) print "n/a"; else printf "%.1f%% (%d/%d)", 100*$2/t, $2, t }'
|
||||
}
|
||||
row() {
|
||||
local label="$1" file="$2"
|
||||
if [ -f "$file" ]; then
|
||||
echo "| $label | $(percent "$file" LINE) | $(percent "$file" BRANCH) |"
|
||||
else
|
||||
echo "| $label | no report | no report |"
|
||||
fi
|
||||
}
|
||||
{
|
||||
echo ""
|
||||
echo "### Coverage"
|
||||
echo ""
|
||||
echo "| module | lines | branches |"
|
||||
echo "|---|---|---|"
|
||||
row "core" core/build/reports/jacoco/test/jacocoTestReport.xml
|
||||
row "app-desktop" app-desktop/build/reports/jacoco/test/jacocoTestReport.xml
|
||||
row "core + app-desktop" build/reports/jacoco/aggregated/jacoco.xml
|
||||
echo ""
|
||||
echo "Report only: nothing fails on a percentage. The aggregated HTML report is the"
|
||||
echo "coverage-report artifact of this run. i18n-generator is build tooling and is"
|
||||
echo "not counted."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Keep the coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: coverage-report
|
||||
path: build/reports/jacoco/aggregated/html/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Keep the test reports on a failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
@@ -16,9 +16,104 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# Same job as in pr-compile-check.yml, copied rather than shared: a reusable workflow
|
||||
# would be one more indirection to read, and the comments belong next to the steps they
|
||||
# explain. Every packaging job below needs this one, so a commit whose tests fail builds
|
||||
# no artifact (#62). Edit all three copies together.
|
||||
test:
|
||||
name: Test (Java 21, headless)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Java 21
|
||||
uses: actions/setup-java@v6.0.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Ensure gradlew is executable
|
||||
run: chmod +x gradlew
|
||||
|
||||
# Skiko's native library links against libGL, so it has to be loadable even though
|
||||
# nothing here renders through it: the drawing tests use Skia's software backend and
|
||||
# runComposeUiTest never opens a window. Without this the whole Compose half of the
|
||||
# suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object
|
||||
# file" -- which reads like a missing display and is not one. Found by running this
|
||||
# job under act, where the container has no libGL; GitHub's own runner image happens
|
||||
# to ship it, so the step is what makes the job reproducible off GitHub.
|
||||
- name: Install the OpenGL client library skiko links against
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libgl1
|
||||
|
||||
# No Xvfb, deliberately. Everything that pins behaviour runs without a display:
|
||||
# runComposeUiTest reaches the semantics tree headless (measured before this job was
|
||||
# written), and the ImageComposeScene drawing tests render in software. The handful
|
||||
# of tests that open a real window guard themselves with assumeFalse(isHeadless) and
|
||||
# skip here; the summary below prints the skip count, so that is visible rather than
|
||||
# assumed.
|
||||
#
|
||||
# Software GL under Xvfb was considered and rejected: a missing Skia GL context is
|
||||
# the hardest failure in this codebase to read, and importing that class of flake
|
||||
# into CI would cost more than the few window tests are worth here.
|
||||
|
||||
# All three modules. The translation generator is a Gradle module and not buildSrc
|
||||
# precisely so that its 42 tests run here: buildSrc tests do not run on `./gradlew
|
||||
# build` -- measured, not assumed. The summary below globs */build/test-results, so
|
||||
# the module was counted the moment it existed.
|
||||
- name: Test
|
||||
run: ./gradlew -S test
|
||||
|
||||
- name: Summarise the test results
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
files=(*/build/test-results/test/TEST-*.xml)
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No test result XML was produced -- the test task did not run." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Counted from the XML and not from Gradle's console output: this project's own
|
||||
# rule is never to infer "all tests passed" from an exit code.
|
||||
summary=$(grep -h "<testsuite " "${files[@]}" \
|
||||
| sed 's/.*tests="\([0-9]*\)".*skipped="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3 \4/' \
|
||||
| awk '{t+=$1; s+=$2; f+=$3; e+=$4} END {print t" "s" "f" "e}')
|
||||
read -r total skipped failures errors <<< "$summary"
|
||||
echo "tests=$total skipped=$skipped failures=$failures errors=$errors"
|
||||
{
|
||||
echo "### Tests"
|
||||
echo ""
|
||||
echo "| total | skipped | failures | errors |"
|
||||
echo "|---|---|---|---|"
|
||||
echo "| $total | $skipped | $failures | $errors |"
|
||||
echo ""
|
||||
echo "Skipped are the tests that need a display, plus the deliberately"
|
||||
echo "disabled reference-data generator. This job runs headless on purpose."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then
|
||||
echo "The result XML reports failures or errors." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Keep the test reports on a failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
*/build/test-results/test/
|
||||
*/build/reports/tests/test/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
build-windows-zip:
|
||||
name: Build Windows ZIP
|
||||
runs-on: windows-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -61,6 +156,7 @@ jobs:
|
||||
build-linux-appimage:
|
||||
name: Build Linux AppImage
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -127,6 +223,7 @@ jobs:
|
||||
build-linux-deb:
|
||||
name: Build Debian package
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -165,6 +262,7 @@ jobs:
|
||||
build-linux-rpm:
|
||||
name: Build Fedora package
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -203,6 +301,7 @@ jobs:
|
||||
build-linux-arch:
|
||||
name: Build Arch Linux package
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -305,6 +404,7 @@ jobs:
|
||||
build-flatpak:
|
||||
name: Build Flatpak
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -430,6 +530,7 @@ jobs:
|
||||
build-macos-dmg:
|
||||
name: Build macOS DMG (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -65,3 +65,6 @@ scratch/
|
||||
|
||||
# Kotlin incremental build data
|
||||
.kotlin/
|
||||
|
||||
# JVM crash reports
|
||||
hs_err_pid*.log
|
||||
@@ -124,11 +124,11 @@ Read the current `gradle.properties` and `gradle/libs.versions.toml` before rely
|
||||
|
||||
At this context snapshot the project uses Java 21, Kotlin 2.2 and Compose Multiplatform 1.8.x, with JUnit 5/Mockito, PMD and SpotBugs integrated. JavaFX is gone; there is no `org.openjfx` dependency left.
|
||||
|
||||
Important: the build configuration has historically allowed test failures to be ignored, and static-analysis findings do not fail the build. Therefore:
|
||||
Important: a test failure fails the build, but PMD and SpotBugs run with `ignoreFailures`, so static-analysis findings do not. Test classes live under `src/test` only (since #62; before that a few sat under `src/main` and were never run), and JUnit/Mockito are test-only dependencies. Therefore:
|
||||
|
||||
- inspect the Gradle test summary;
|
||||
- inspect the test-results XML under `*/build/test-results/` when needed;
|
||||
- do not infer "all tests passed" from exit code 0;
|
||||
- do not infer "all tests passed" from exit code 0 -- read the XML;
|
||||
- report PMD/SpotBugs findings that are visible in the relevant build.
|
||||
|
||||
Run focused tests first, then normally the relevant broader test/build command for the scope.
|
||||
|
||||
@@ -10,10 +10,8 @@ kotlin {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Mirrors the compile-scope entries in pom.xml: a few test classes still live under
|
||||
// src/main/java and need JUnit and Mockito on the main compile classpath.
|
||||
implementation(libs.junit.jupiter.api)
|
||||
implementation(libs.mockito.core.compile)
|
||||
// JUnit and Mockito are test-only. Until #62 a few test classes sat under src/main/java
|
||||
// and dragged both -- plus byte-buddy and objenesis -- into lib/app of every app image.
|
||||
implementation(libs.jetbrains.annotations)
|
||||
implementation(project(":core"))
|
||||
implementation(compose.desktop.currentOs)
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.observe.SimpleValue;
|
||||
import kst4contest.controller.Utils4KST;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class PatternMatcherTest {
|
||||
|
||||
/**
|
||||
* Tests if pattern matches with the given String.
|
||||
*
|
||||
* @param testString
|
||||
* @param regExPattern
|
||||
* @return true if match, else false
|
||||
*/
|
||||
|
||||
private static boolean testPattern(String testString, String regExPattern) {
|
||||
|
||||
Pattern pattern = Pattern.compile(regExPattern);
|
||||
Matcher matcher = pattern.matcher(testString);
|
||||
|
||||
return matcher.find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a chatmembers frequency-string for cluster usage<br/>
|
||||
* <b>returns a frequency String in KHz like = "144300" or "144300.0" to match DXC protocol needs</b>
|
||||
*
|
||||
* @param optionalPrefix: if there is a value like ".300", it have to be decided, wich ".300": 144.300, 432.300, 1296.300 .... prefix means for example "144."
|
||||
*/
|
||||
private static String normalizeFrequencyString(String qrgString, String optionalPrefix) {
|
||||
|
||||
// final String PTRN_QRG_CAT2 = "(([0-9]{3,4}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)|(([a-zA-Z][0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)|((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)";
|
||||
|
||||
final String PTRN_QRG_CAT2_wholeQRGMHz4Digits = "(([0-9]{4}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)"; //1296.300.3 etc
|
||||
final String PTRN_QRG_CAT2_wholeQRGMHz3Digits = "(([0-9]{3}[\\.|,| ]?[0-9]{3})([\\.][\\d]{1,2})?)"; //144.300.3 etc
|
||||
final String PTRN_QRG_CAT2_QRGwithoutPrefix = "((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)"; //144.300.3 etc
|
||||
String predefinedPrefixInMHz = optionalPrefix;
|
||||
|
||||
String stringAggregation = "";
|
||||
|
||||
if (testPattern(qrgString, PTRN_QRG_CAT2_wholeQRGMHz4Digits)) {
|
||||
System.out.print("yep: ");
|
||||
stringAggregation = qrgString;
|
||||
|
||||
stringAggregation = stringAggregation.replace(".","");
|
||||
stringAggregation = stringAggregation.replace(",","");
|
||||
if (stringAggregation.length() == 8) {
|
||||
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-1) + "." + stringAggregation.substring(stringAggregation.length()-1, stringAggregation.length());
|
||||
stringAggregation = stringAggregationNew;
|
||||
} else if (stringAggregation.length() == 9) {
|
||||
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-2) + "." + stringAggregation.substring(stringAggregation.length()-2, stringAggregation.length());
|
||||
stringAggregation = stringAggregationNew;
|
||||
}
|
||||
|
||||
} else
|
||||
|
||||
if (testPattern(qrgString, PTRN_QRG_CAT2_wholeQRGMHz3Digits)) {
|
||||
System.out.print("yep: ");
|
||||
stringAggregation = qrgString;
|
||||
|
||||
stringAggregation = stringAggregation.replace(".","");
|
||||
stringAggregation = stringAggregation.replace(",","");
|
||||
if (stringAggregation.length() == 7) {
|
||||
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-1) + "." + stringAggregation.substring(stringAggregation.length()-1, stringAggregation.length());
|
||||
stringAggregation = stringAggregationNew;
|
||||
} else if (stringAggregation.length() == 8) {
|
||||
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length()-2) + "." + stringAggregation.substring(stringAggregation.length()-2, stringAggregation.length());
|
||||
stringAggregation = stringAggregationNew;
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
if (testPattern(qrgString, PTRN_QRG_CAT2_QRGwithoutPrefix)) { //case ".050 or .300 or something like that"
|
||||
System.out.print("yep: ");
|
||||
stringAggregation = qrgString;
|
||||
|
||||
stringAggregation = stringAggregation.replace(".", "");
|
||||
stringAggregation = stringAggregation.replace(",", "");
|
||||
if (stringAggregation.length() == 3) { // like 050 or 300
|
||||
String stringAggregationNew = optionalPrefix + stringAggregation;
|
||||
stringAggregation = stringAggregationNew;
|
||||
return stringAggregation;
|
||||
|
||||
} else if (stringAggregation.length() == 4) { //like 050.2 --> 0502
|
||||
|
||||
stringAggregation = optionalPrefix + stringAggregation;
|
||||
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length() - 1) + "." + stringAggregation.substring(stringAggregation.length() - 1, stringAggregation.length());
|
||||
stringAggregation = stringAggregationNew;
|
||||
return stringAggregation;
|
||||
|
||||
} else if (stringAggregation.length() == 5) { //like 050.20 --> 05020
|
||||
|
||||
stringAggregation = optionalPrefix + stringAggregation;
|
||||
String stringAggregationNew = stringAggregation.substring(0, stringAggregation.length() - 2) + "." + stringAggregation.substring(stringAggregation.length() - 2, stringAggregation.length());
|
||||
stringAggregation = stringAggregationNew;
|
||||
return stringAggregation;
|
||||
}
|
||||
}
|
||||
|
||||
return qrgString;
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
int i = 0;
|
||||
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.775", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.2", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144,300.2", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300.2", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300,2", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.20", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("300", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.2", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.20", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.2", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.20", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,2", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,20", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300,2", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300.2", new SimpleValue<>("144")));
|
||||
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("q305", new SimpleValue<>("144")));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
plugins {
|
||||
java
|
||||
// Applied here as well as in the subprojects: the root needs the JacocoReport task
|
||||
// type and the tool classpath for the aggregated report below.
|
||||
jacoco
|
||||
alias(libs.plugins.spotbugs) apply false
|
||||
}
|
||||
|
||||
@@ -17,11 +20,13 @@ allprojects {
|
||||
|
||||
val pmdVersion = libs.versions.pmd.get()
|
||||
val spotbugsVersion = libs.versions.spotbugs.get()
|
||||
val jacocoVersion = libs.versions.jacoco.get()
|
||||
|
||||
subprojects {
|
||||
apply(plugin = "java")
|
||||
apply(plugin = "com.github.spotbugs")
|
||||
apply(plugin = "pmd")
|
||||
apply(plugin = "jacoco")
|
||||
|
||||
// Both tools report only; the Maven build had the failing goals commented out.
|
||||
extensions.configure<PmdExtension> {
|
||||
@@ -36,6 +41,17 @@ subprojects {
|
||||
ignoreFailures.set(true)
|
||||
}
|
||||
|
||||
/*
|
||||
* Coverage is a report, not a gate: there is no jacocoTestCoverageVerification and no
|
||||
* minimum percentage. A threshold set at today's number would trip on the next unrelated
|
||||
* change; one set at a target would fail every build until tests are written for the
|
||||
* number's sake. The figure is published so it can be read when deciding what to test
|
||||
* next (docs/superpowers/specs/2026-10-11-test-coverage-design.md, #62).
|
||||
*/
|
||||
extensions.configure<JacocoPluginExtension> {
|
||||
toolVersion = jacocoVersion
|
||||
}
|
||||
|
||||
extensions.configure<JavaPluginExtension> {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
@@ -45,4 +61,45 @@ subprojects {
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
// XML for CI to read, HTML for people. Gradle does not wire the report after the test
|
||||
// task on its own; without this dependsOn the report task would read stale or missing
|
||||
// execution data. Kotlin classes count too: the Kotlin plugin adds its output to the
|
||||
// main source set, which is what the default report reads.
|
||||
tasks.withType<JacocoReport>().configureEach {
|
||||
dependsOn(tasks.withType<Test>())
|
||||
reports {
|
||||
xml.required.set(true)
|
||||
html.required.set(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* One report over the two shipped modules. i18n-generator stays out: it is a build tool,
|
||||
* and its coverage says nothing about the application. Sources and classes are resolved
|
||||
* lazily because the root script is evaluated before the subprojects have their source sets.
|
||||
*/
|
||||
val coveredProjects = listOf(project(":core"), project(":app-desktop"))
|
||||
|
||||
val jacocoAggregatedReport by tasks.registering(JacocoReport::class) {
|
||||
description = "Merges the JaCoCo coverage of core and app-desktop into one report"
|
||||
group = "verification"
|
||||
|
||||
dependsOn(coveredProjects.map { it.tasks.named("test") })
|
||||
|
||||
executionData.setFrom(coveredProjects.map { it.layout.buildDirectory.file("jacoco/test.exec") })
|
||||
sourceDirectories.setFrom(provider {
|
||||
coveredProjects.flatMap { it.extensions.getByType<SourceSetContainer>()["main"].allSource.srcDirs }
|
||||
})
|
||||
classDirectories.setFrom(provider {
|
||||
coveredProjects.map { it.extensions.getByType<SourceSetContainer>()["main"].output }
|
||||
})
|
||||
|
||||
reports {
|
||||
xml.required.set(true)
|
||||
xml.outputLocation.set(layout.buildDirectory.file("reports/jacoco/aggregated/jacoco.xml"))
|
||||
html.required.set(true)
|
||||
html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco/aggregated/html"))
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,8 @@ plugins {
|
||||
// kst4contest.observe and hands UI work over via UiDispatcher.
|
||||
|
||||
dependencies {
|
||||
// Test classes live under src/main/java in this project, so JUnit and Mockito
|
||||
// are needed on the main compile classpath. This mirrored pom.xml before Gradle.
|
||||
implementation(libs.junit.jupiter.api)
|
||||
implementation(libs.mockito.core.compile)
|
||||
// JUnit and Mockito are test-only. Until #62 a handful of test classes sat under
|
||||
// src/main/java and dragged both onto the runtime classpath of the shipped app.
|
||||
implementation(libs.jetbrains.annotations)
|
||||
implementation(libs.sqlite.jdbc)
|
||||
implementation(libs.jlayer)
|
||||
|
||||
@@ -22,7 +22,6 @@ import kst4contest.locatorUtils.DirectionUtils;
|
||||
import kst4contest.logic.BandOpportunityResolver;
|
||||
import kst4contest.logic.PriorityCalculator;
|
||||
import kst4contest.model.*;
|
||||
import kst4contest.test.MockKstServer;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
import kst4contest.utils.PlayAudioUtils;
|
||||
import kst4contest.locatorUtils.Location;
|
||||
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
//package kst4contest.controller;
|
||||
//
|
||||
//import kst4contest.model.Band;
|
||||
//import org.junit.jupiter.api.Test;
|
||||
//import org.junit.jupiter.params.ParameterizedTest;
|
||||
//import org.junit.jupiter.params.provider.ValueSource;
|
||||
//
|
||||
//import java.util.regex.Matcher;
|
||||
//import java.util.regex.Pattern;
|
||||
//
|
||||
//import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
//import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
//import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
//import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
//import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
//
|
||||
//class MessageBusManagementThreadFrequencyContextTest {
|
||||
//
|
||||
// private static final Pattern THREE_DIGIT_VALUE =
|
||||
// Pattern.compile("\\b\\d{3}\\b");
|
||||
//
|
||||
// @ParameterizedTest
|
||||
// @ValueSource(strings = {
|
||||
// "qrg 210",
|
||||
// "QRG: 210",
|
||||
// "freq is 210",
|
||||
// "frequency = 210",
|
||||
// "on 210",
|
||||
// "210 MHz",
|
||||
// "210 qrg"
|
||||
// })
|
||||
// void acceptsBareThreeDigitValueWithFrequencyContext(String messageText) {
|
||||
// Matcher matcher = findThreeDigitValue(messageText);
|
||||
//
|
||||
// assertTrue(
|
||||
// MessageBusManagementThread
|
||||
// .hasExplicitBareFrequencyContext(
|
||||
// messageText,
|
||||
// matcher.start(),
|
||||
// matcher.end()
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// @ParameterizedTest
|
||||
// @ValueSource(strings = {
|
||||
// "599",
|
||||
// "144",
|
||||
// "serial 210",
|
||||
// "score 210",
|
||||
// "worked 210 stations"
|
||||
// })
|
||||
// void rejectsBareThreeDigitValueWithoutFrequencyContext(String messageText) {
|
||||
// Matcher matcher = findThreeDigitValue(messageText);
|
||||
//
|
||||
// assertFalse(
|
||||
// MessageBusManagementThread
|
||||
// .hasExplicitBareFrequencyContext(
|
||||
// messageText,
|
||||
// matcher.start(),
|
||||
// matcher.end()
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void resolvesOnlySupportedFallbackPrefixes() {
|
||||
// assertEquals(Band.B_144, Band.fromPrefix("144"));
|
||||
// assertEquals(Band.B_432, Band.fromPrefix(" 432 "));
|
||||
// assertEquals(Band.B_10G, Band.fromPrefix("10368"));
|
||||
// assertNull(Band.fromPrefix("999"));
|
||||
// assertNull(Band.fromPrefix(null));
|
||||
// }
|
||||
//
|
||||
// private Matcher findThreeDigitValue(String messageText) {
|
||||
// Matcher matcher = THREE_DIGIT_VALUE.matcher(messageText);
|
||||
// assertTrue(matcher.find(), "Test message must contain a three-digit value");
|
||||
// assertNotNull(matcher.group());
|
||||
// return matcher;
|
||||
// }
|
||||
//}
|
||||
@@ -509,16 +509,6 @@ final class On4KstConnectionManager {
|
||||
category, preferences.getStn_loginNameMainCat()));
|
||||
}
|
||||
sendControl(session, On4KstProtocol.back(category));
|
||||
|
||||
String secondLocator = preferences.getStn_loginLocatorSecondCat();
|
||||
String mainLocator = preferences.getStn_loginLocatorMainCat();
|
||||
if (preferences.isLoginToSecondChatEnabled()
|
||||
&& secondLocator != null && !secondLocator.isBlank()
|
||||
&& !secondLocator.equalsIgnoreCase(mainLocator)) {
|
||||
controller.onOn4KstConnectionWarning(
|
||||
"ON4KST uses one locator per TCP session. The second-chat locator '"
|
||||
+ secondLocator + "' is ignored; using '" + mainLocator + "'.");
|
||||
}
|
||||
}
|
||||
|
||||
private void configureSecondChat(Session session) {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.ChatPreferences;
|
||||
|
||||
public class ReadUDPByWintestThreadTest {
|
||||
|
||||
public static void main(String[] args) {
|
||||
ChatController ctrl1 = new ChatController();
|
||||
|
||||
ChatPreferences prefs = new ChatPreferences();
|
||||
ctrl1.setChatPreferences(prefs);
|
||||
|
||||
// ReadUDPByWintestThread test = new ReadUDPByWintestThread(ctrl1);
|
||||
|
||||
|
||||
// test.run();
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import kst4contest.model.ChatMember;
|
||||
|
||||
public class RegexTester {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
|
||||
String temp;
|
||||
|
||||
temp = "DL1KDA JO30EP Alex\r\n"
|
||||
+ "DL3LST JO61FI Ray(Rainer)\r\n"
|
||||
+ "DL4ZAQ JN49MP Peter 2m&70cm&6m\r\n"
|
||||
+ "DL9LBH JN59ID Hans 2/70/23\r\n"
|
||||
+ "DO5AMF JN49FK Marc\r\n"
|
||||
+ "F5ICN JN03BF Alex QRV 2/70/23\r\n"
|
||||
+ "F6GRA JN04DB Carol 2m\r\n"
|
||||
+ "F6HTJ JN12KQ Michel\r\n"
|
||||
+ "G0BIX JO01GI Terry\r\n"
|
||||
+ "G0HOF IO92UL Ken 11el + 400 W\r\n"
|
||||
+ "G0LBK JO03BD David\r\n"
|
||||
+ "G0MBL JO01QH Andrew 2m\r\n"
|
||||
+ "G1SDX IO80FL Grant\r\n"
|
||||
+ "G3MXH JO02LF Terry\r\n"
|
||||
+ "G3OVH IO92JP Tony\r\n"
|
||||
+ "G3VCA IO93MG bob\r\n"
|
||||
+ "G4AEP IO91MB bill\r\n"
|
||||
+ "G4DHF IO92UU David\r\n"
|
||||
+ "G4FUF JO01GN Keith\r\n"
|
||||
+ "G4TRA IO81WN Steve\r\n"
|
||||
+ "G6HKS IO92OB Richard\r\n"
|
||||
+ "G8SEI IO92FO Jeff 6/4/2/70\r\n"
|
||||
+ "GM0EWX IO67UL Calum\r\n"
|
||||
+ "GW8IZR IO73TI Paul\r\n"
|
||||
+ "IV3GTH JN65RU Gigi\r\n"
|
||||
+ "IZ2XZM JN45KH Nick\r\n"
|
||||
+ "LY1BWB KO24PR VU Club 2/70\r\n"
|
||||
+ "LY2EN KO24PQ QRT\r\n"
|
||||
+ "OK1FPR JO80CE Milos\r\n"
|
||||
+ "OV3T JO46CM Thomas\r\n"
|
||||
+ "OY4TN IP62NB Trygvi 11EL/350W\r\n"
|
||||
+ "OZ2M JO65FR Bo PNGonVHF\r\n"
|
||||
+ "OZ3Z JO45UN Anders @432.228\r\n"
|
||||
+ "OZ7UV JO65DH Svend\r\n"
|
||||
+ "PA3DOL JO22MT Sjoerd\r\n"
|
||||
+ "PA3PCV JO20VV Marcel 2m\r\n"
|
||||
+ "PA9R JO22JK Rob\r\n"
|
||||
+ "S52FO JN76EF Janez\r\n"
|
||||
+ "S53RM JN76HD Sine\r\n"
|
||||
+ "SM2CEW KP15CR Peter\r\n"
|
||||
+ "SP6VGJ JO81HU Jacek";
|
||||
|
||||
|
||||
Pattern pattern = Pattern.compile("([a-zA-Z0-9]{2}/{1})?([a-zA-Z0-9]{1,3}[0-9][a-zA-Z0-9]{0,3}[a-zA-Z]{0,3})(/p)? [a-zA-Z]{2}[0-9]{2}[a-zA-Z]{2} [ -~]{1,20}");
|
||||
Matcher matcher = pattern.matcher(temp);
|
||||
/**
|
||||
* "([a-zA-Z0-9]{1,2}\/)?[a-zA-Z0-9]{1,3}[0-9][a-zA-Z0-9]{0,3}[a-zA-Z](\/(p|m))?( )[a-zA-Z]{2}[0-9]{2}[a-zA-Z]{2}[ -~]{0,30}"gm
|
||||
* Thats a line of the show users list
|
||||
*/
|
||||
while (matcher.find()) {
|
||||
// System.out.println("Chatmember detected: "+ matcher.group() + " " + matcher.start());
|
||||
|
||||
ChatMember member = new ChatMember();
|
||||
String matchedString = matcher.group();
|
||||
|
||||
String[] splittedUserString;
|
||||
splittedUserString = matchedString.split(" ");
|
||||
|
||||
member.setCallSign(splittedUserString[0]);
|
||||
member.setQra(splittedUserString[1]);
|
||||
|
||||
String stringAggregation = "";
|
||||
for (int i = 2; i < splittedUserString.length; i++) {
|
||||
stringAggregation += splittedUserString[i] + " ";
|
||||
}
|
||||
member.setName(stringAggregation);
|
||||
|
||||
System.out.println("Call: " + member.getCallSign() + ", QRA: " + member.getQra() + ", Name: " + member.getName());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package kst4contest.locatorUtils;
|
||||
|
||||
public class TestLocatorUtils {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
|
||||
// isInAngle(myLocation, location1, location2);
|
||||
System.out.println(isInAngleAndRange("JN49FL", "jo43xm", "jo30sa", 900, 50));
|
||||
System.out.println(isInAngleAndRange("JN49FL", "jo51ij", "jn39oc", 900, 50));
|
||||
System.out.println(isInAngleAndRange("JN49FL", "jn39oc", "jo51ij", 1100, 50));
|
||||
}
|
||||
|
||||
public static boolean isInAngleAndRange(String myLocator, String locatorOfSkedSender, String locatorOfSekdReceiver, double maxRangeKm, double hisAntennaBeamWidth) {
|
||||
|
||||
Location myLocation = new Location(myLocator);
|
||||
Location skedSenderLocation = new Location(locatorOfSkedSender);
|
||||
Location skedReceiverLocation = new Location(locatorOfSekdReceiver);
|
||||
|
||||
double distanceFromMeToLocSender = new Location(myLocator).getDistanceKm(new Location(locatorOfSkedSender));
|
||||
|
||||
// Check if distance exceeds my setted maximum range
|
||||
if (distanceFromMeToLocSender > maxRangeKm) {
|
||||
System.out.println("too far, " + distanceFromMeToLocSender + " km");
|
||||
return false;
|
||||
}
|
||||
|
||||
//check bearing of sender to receiver
|
||||
|
||||
double bearingOfSekdSenderToSkedReceiver = skedSenderLocation.getBearing(skedReceiverLocation);
|
||||
System.out.println("skedTX -> skedRX deg: " + bearingOfSekdSenderToSkedReceiver);
|
||||
|
||||
double bearingOfSekdSenderToMe = skedSenderLocation.getBearing(myLocation);
|
||||
System.out.println("skedTX -> me deg: " + bearingOfSekdSenderToMe);
|
||||
|
||||
if (DirectionUtils.isAngleInRange(bearingOfSekdSenderToSkedReceiver, bearingOfSekdSenderToMe, hisAntennaBeamWidth)) {
|
||||
//may I should get "/2" because of 50% of the 3dB opening angle if txer is directed to sender exactly
|
||||
return true;
|
||||
} else return false;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -218,7 +218,6 @@ public class ChatPreferences {
|
||||
String stn_loginNameMainCat = "KST4Contest";
|
||||
String stn_loginNameSecondCat = "KST4ContestSHF";
|
||||
String stn_loginLocatorMainCat = "jn49fk";
|
||||
String stn_loginLocatorSecondCat = "jn49fk";
|
||||
|
||||
double stn_antennaBeamWidthDeg = 50;
|
||||
double stn_maxQRBDefault = 900;
|
||||
@@ -323,7 +322,6 @@ public class ChatPreferences {
|
||||
lstNotify_QSOSniffer_sniffedCallSignList =
|
||||
new SimpleRoster<>();
|
||||
// SimpleRoster<String> lstNotify_QSOSniffer_sniffedCallSignList = new SimpleRoster<>();
|
||||
SimpleRoster<String> lstNotify_QSOSniffer_sniffedWordsList = new SimpleRoster<>();
|
||||
SimpleRoster<String> lstNotify_QSOSniffer_sniffedPrefixLocList = new SimpleRoster<>();
|
||||
|
||||
// Scoring / interaction metrics
|
||||
@@ -554,15 +552,6 @@ public class ChatPreferences {
|
||||
return lstNotify_QSOSniffer_sniffedCallSignList;
|
||||
}
|
||||
|
||||
public SimpleRoster<String> getLstNotify_QSOSniffer_sniffedWordsList() {
|
||||
return lstNotify_QSOSniffer_sniffedWordsList;
|
||||
}
|
||||
|
||||
public void setLstNotify_QSOSniffer_sniffedWordsList(java.util.Collection<String> lstNotify_QSOSniffer_sniffedWordsList) {
|
||||
// Fill the existing roster: replacing the field would orphan its listeners.
|
||||
this.lstNotify_QSOSniffer_sniffedWordsList.setAll(lstNotify_QSOSniffer_sniffedWordsList);
|
||||
}
|
||||
|
||||
public SimpleRoster<String> getLstNotify_QSOSniffer_sniffedPrefixLocList() {
|
||||
return lstNotify_QSOSniffer_sniffedPrefixLocList;
|
||||
}
|
||||
@@ -660,14 +649,6 @@ public class ChatPreferences {
|
||||
this.logsynch_wintestUsePassQrg = logsynch_wintestUsePassQrg;
|
||||
}
|
||||
|
||||
public String getStn_loginLocatorSecondCat() {
|
||||
return stn_loginLocatorSecondCat;
|
||||
}
|
||||
|
||||
public void setStn_loginLocatorSecondCat(String stn_loginLocatorSecondCat) {
|
||||
this.stn_loginLocatorSecondCat = stn_loginLocatorSecondCat;
|
||||
}
|
||||
|
||||
public boolean isBcn_beaconsEnabledSecondCat() {
|
||||
return bcn_beaconsEnabledSecondCat;
|
||||
}
|
||||
@@ -1962,15 +1943,6 @@ public class ChatPreferences {
|
||||
/**
|
||||
* QSO Sniffer Lists
|
||||
*/
|
||||
Element snifferWords = doc.createElement("snifferWords");
|
||||
rootElement.appendChild(snifferWords);
|
||||
|
||||
for (String word : lstNotify_QSOSniffer_sniffedWordsList.snapshot()) {
|
||||
Element temp = doc.createElement("w");
|
||||
temp.setTextContent(word);
|
||||
snifferWords.appendChild(temp);
|
||||
}
|
||||
|
||||
Element snifferCallSigns =
|
||||
doc.createElement("snifferCallSigns");
|
||||
rootElement.appendChild(snifferCallSigns);
|
||||
@@ -2935,47 +2907,6 @@ public class ChatPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
list = doc.getElementsByTagName("snifferWords");
|
||||
if (list != null && list.getLength() != 0) {
|
||||
// reset to avoid duplicates when reloading
|
||||
lstNotify_QSOSniffer_sniffedWordsList.clear();
|
||||
for (int temp = 0; temp < list.getLength(); temp++) {
|
||||
Node node = list.item(temp);
|
||||
if (node.getNodeType() == Node.ELEMENT_NODE) {
|
||||
Element element = (Element) node;
|
||||
NodeList children = element.getChildNodes();
|
||||
for (int i = 0; i < children.getLength(); i++) {
|
||||
Node child = children.item(i);
|
||||
if (child.getNodeType() == Node.ELEMENT_NODE) {
|
||||
String callSign = child.getTextContent();
|
||||
|
||||
if (callSign != null
|
||||
&& !callSign.isBlank()) {
|
||||
|
||||
/*
|
||||
* Older preference files may contain complete KST callsigns
|
||||
* such as DN9APW-2. QSO monitoring is stored by base callsign,
|
||||
* so one entry covers every suffix of the same station.
|
||||
*/
|
||||
String normalizedCallSign =
|
||||
ChatMember.normalizeCallSignToBaseCallSign(
|
||||
callSign
|
||||
);
|
||||
|
||||
if (normalizedCallSign != null
|
||||
&& !normalizedCallSign.isBlank()
|
||||
&& !lstNotify_QSOSniffer_sniffedCallSignList.snapshot()
|
||||
.contains(normalizedCallSign)) {
|
||||
lstNotify_QSOSniffer_sniffedCallSignList
|
||||
.add(normalizedCallSign);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list = doc.getElementsByTagName("snifferPrefixes");
|
||||
if (list != null && list.getLength() != 0) {
|
||||
lstNotify_QSOSniffer_sniffedPrefixLocList.clear();
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
public class MockKstServer {
|
||||
|
||||
private static final int PORT = 23001;
|
||||
private static final String CHAT_ID = "2"; // 2 = 144/432 MHz
|
||||
|
||||
// Thread-sichere Liste aller verbundenen Clients (OutputStreams)
|
||||
private final List<PrintWriter> clients = new CopyOnWriteArrayList<>();
|
||||
|
||||
// Permanente User (Ihre Test-Callsigns)
|
||||
private final Map<String, User> onlineUsers = new HashMap<>();
|
||||
// Historien müssen synchronisiert werden
|
||||
private final List<String> historyChat = Collections.synchronizedList(new ArrayList<>());
|
||||
private final List<String> historyDx = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
private boolean running = false;
|
||||
private ServerSocket serverSocket;
|
||||
|
||||
public MockKstServer() {
|
||||
// Initiale Permanente User
|
||||
addUser("DK5EW", "Erwin", "JN47NX");
|
||||
addUser("DL1TEST", "TestOp", "JO50XX");
|
||||
addUser("ON4KST", "Alain", "JO20HI");
|
||||
addUser("PA9R-2", "2", "JO20HI");
|
||||
addUser("PA9R-70", "70", "JO20HI");
|
||||
addUser("PA9R", "general", "JO20HI");
|
||||
}
|
||||
|
||||
// Startet den Server im Hintergrund (Non-Blocking)
|
||||
public void start() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
serverSocket = new ServerSocket(PORT);
|
||||
System.out.println("[Server] ON4KST Simulation gestartet auf Port " + PORT);
|
||||
|
||||
// Startet den Simulator für Zufallstraffic
|
||||
new Thread(this::simulationLoop).start();
|
||||
|
||||
while (running) {
|
||||
Socket clientSocket = serverSocket.accept();
|
||||
System.out.println("[Server] Neuer Client verbunden: " + clientSocket.getInetAddress());
|
||||
new Thread(new ClientHandler(clientSocket)).start();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if (running) e.printStackTrace();
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
running = false;
|
||||
try {
|
||||
if (serverSocket != null) serverSocket.close();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private void addUser(String call, String name, String loc) {
|
||||
onlineUsers.put(call, new User(call, name, loc));
|
||||
}
|
||||
|
||||
private void removeUser(String call) {
|
||||
onlineUsers.remove(call);
|
||||
}
|
||||
|
||||
// Sendet Nachricht an ALLE verbundenen Clients (inkl. Sender)
|
||||
private void broadcast(String message) {
|
||||
if (!message.endsWith("\r\n")) message += "\r\n";
|
||||
String finalMsg = message;
|
||||
|
||||
for (PrintWriter writer : clients) {
|
||||
try {
|
||||
writer.print(finalMsg);
|
||||
writer.flush(); // WICHTIG: Sofort senden!
|
||||
} catch (Exception e) {
|
||||
// Client wohl weg, wird beim nächsten Schreibversuch oder im Handler entfernt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Innere Logik: Client Handler ---
|
||||
private class ClientHandler implements Runnable {
|
||||
private Socket socket;
|
||||
private PrintWriter out;
|
||||
private BufferedReader in;
|
||||
private String myCall = "MYCLIENT"; // Default, wird bei LOGIN überschrieben
|
||||
|
||||
public ClientHandler(Socket socket) {
|
||||
this.socket = socket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
// ISO-8859-1 ist Standard für KST/Telnet Cluster
|
||||
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "ISO-8859-1"));
|
||||
out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), "ISO-8859-1"), true);
|
||||
|
||||
clients.add(out);
|
||||
|
||||
String line;
|
||||
boolean loginComplete = false;
|
||||
|
||||
while ((line = in.readLine()) != null) {
|
||||
// System.out.println("[RECV] " + line); // Debugging aktivieren falls nötig
|
||||
String[] parts = line.split("\\|");
|
||||
String cmd = parts[0];
|
||||
|
||||
if (cmd.equals("LOGIN") || cmd.equals("LOGINC")) {
|
||||
// Protokoll: LOGIN|callsign|password|... [cite: 21]
|
||||
if (parts.length > 1) myCall = parts[1];
|
||||
|
||||
// 1. Login Bestätigung
|
||||
// Format: LOGSTAT|100|chat id|client software version|session key|config|dx option|
|
||||
send("LOGSTAT|100|" + CHAT_ID + "|JavaSim|KEY123|Config|3|");
|
||||
|
||||
// Bei LOGIN senden wir die Daten sofort
|
||||
// Bei LOGINC warten wir eigentlich auf SDONE, senden hier aber vereinfacht direkt
|
||||
if (cmd.equals("LOGIN")) {
|
||||
sendInitialData();
|
||||
loginComplete = true;
|
||||
}
|
||||
}
|
||||
else if (cmd.equals("SDONE")) {
|
||||
// Abschluss der Settings (bei LOGINC) [cite: 34]
|
||||
sendInitialData();
|
||||
loginComplete = true;
|
||||
}
|
||||
else if (cmd.equals("MSG")) {
|
||||
// MSG|chat id|destination|command|0| [cite: 42]
|
||||
if (parts.length >= 4) {
|
||||
String text = parts[3];
|
||||
// Nachricht sofort als CH Frame an alle verteilen (Echo)
|
||||
handleChatMessage(myCall, "Me", text);
|
||||
}
|
||||
}
|
||||
else if (cmd.equals("CK")) {
|
||||
// Keepalive [cite: 20]
|
||||
// Server muss nicht zwingend antworten, aber Connection bleibt offen
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// System.out.println("Client getrennt");
|
||||
} finally {
|
||||
clients.remove(out);
|
||||
try { socket.close(); } catch (IOException e) {}
|
||||
}
|
||||
}
|
||||
|
||||
private void send(String msg) {
|
||||
if (!msg.endsWith("\r\n")) msg += "\r\n";
|
||||
out.print(msg);
|
||||
out.flush();
|
||||
}
|
||||
|
||||
private void sendInitialData() {
|
||||
// 1. User Liste UA0 [cite: 14]
|
||||
for (User u : onlineUsers.values()) {
|
||||
send("UA0|" + CHAT_ID + "|" + u.call + "|" + u.name + "|" + u.loc + "|0|");
|
||||
}
|
||||
// 2. Chat History CR [cite: 7]
|
||||
synchronized(historyChat) {
|
||||
for (String h : historyChat) send(h);
|
||||
}
|
||||
// 3. DX History DL [cite: 10]
|
||||
synchronized(historyDx) {
|
||||
for (String d : historyDx) send(d);
|
||||
}
|
||||
// 4. Ende User Liste UE [cite: 15]
|
||||
send("UE|" + CHAT_ID + "|" + onlineUsers.size() + "|");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hilfsmethoden für Traffic ---
|
||||
|
||||
private void handleChatMessage(String call, String name, String text) {
|
||||
// CH|chat id|date|callsign|firstname|destination|msg|highlight|
|
||||
String date = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
|
||||
String frame = String.format("CH|%s|%s|%s|%s|0|%s|0|", CHAT_ID, date, call, name, text);
|
||||
|
||||
synchronized(historyChat) {
|
||||
historyChat.add(frame);
|
||||
if (historyChat.size() > 50) historyChat.remove(0);
|
||||
}
|
||||
broadcast(frame);
|
||||
}
|
||||
|
||||
private void handleDxSpot(String spotter, String dx, String freq) {
|
||||
// DL|Unix time|dx utc|spotter|qrg|dx|info|spotter locator|dx locator| [cite: 10]
|
||||
long unixTime = System.currentTimeMillis() / 1000;
|
||||
String utc = new SimpleDateFormat("HHmm").format(new Date());
|
||||
// Simple Dummy Locators
|
||||
String frame = String.format("DL|%d|%s|%s|%s|%s|Simulated|JO00|JO99|",
|
||||
unixTime, utc, spotter, freq, dx);
|
||||
|
||||
synchronized(historyDx) {
|
||||
historyDx.add(frame);
|
||||
if (historyDx.size() > 20) historyDx.remove(0);
|
||||
}
|
||||
broadcast(frame);
|
||||
}
|
||||
|
||||
private void simulationLoop() {
|
||||
String[] randomCalls = {"PA0GUS", "F6APE", "OH8K", "OZ2M", "G4CBW"};
|
||||
String[] msgs = {"CQ 144.300", "Tnx for QSO", "Any sked?", "QRV 432.200"};
|
||||
Random rand = new Random();
|
||||
|
||||
while (running) {
|
||||
try {
|
||||
Thread.sleep(3000 + rand.nextInt(5000)); // 3-8 Sek Pause
|
||||
|
||||
int action = rand.nextInt(10);
|
||||
String call = randomCalls[rand.nextInt(randomCalls.length)];
|
||||
|
||||
if (action < 4) { // 40% Chat
|
||||
handleChatMessage(call, "SimOp", msgs[rand.nextInt(msgs.length)]);
|
||||
} else if (action < 7) { // 30% DX Spot
|
||||
handleDxSpot(call, randomCalls[rand.nextInt(randomCalls.length)], "144." + rand.nextInt(400));
|
||||
} else if (action == 8) { // Login Simulation UA5
|
||||
if (!onlineUsers.containsKey(call)) {
|
||||
addUser(call, "SimOp", "JO11");
|
||||
broadcast("UA5|" + CHAT_ID + "|" + call + "|SimOp|JO11|2|");
|
||||
}
|
||||
} else if (action == 9) { // Logout Simulation UR6
|
||||
if (onlineUsers.containsKey(call) && !call.equals("DK5EW")) { // DK5EW nicht kicken
|
||||
removeUser(call);
|
||||
broadcast("UR6|" + CHAT_ID + "|" + call + "|");
|
||||
}
|
||||
}
|
||||
|
||||
// Ping ab und zu
|
||||
if (rand.nextInt(5) == 0) broadcast("CK|");
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kleine Datenklasse
|
||||
private static class User {
|
||||
String call, name, loc;
|
||||
User(String c, String n, String l) { this.call=c; this.name=n; this.loc=l; }
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
public class mapTest {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
TerrainPackageService terrainPackageService = new TerrainPackageService();
|
||||
|
||||
TerrainPackageService.TerrainPreparationResult result =
|
||||
terrainPackageService.prepareTerrainForLocators(
|
||||
"JO51IJ",
|
||||
"JO22JK",
|
||||
"TEST",
|
||||
144.300,
|
||||
10.0,
|
||||
10.0,
|
||||
""
|
||||
);
|
||||
|
||||
System.out.println(result.success());
|
||||
System.out.println(result.message());
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
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.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Opens databases of older schema generations with a {@link DBController} and verifies that
|
||||
* the controller migrates them to the current schema: all columns present, PRAGMA
|
||||
* user_version at the current level, legacy callsign keys merged to base callsigns and the
|
||||
* existing rows preserved. The fixtures are built with plain sqlite-jdbc so the test does not
|
||||
* depend on the controller to create "old" files.
|
||||
*/
|
||||
class DBControllerSchemaMigrationTest {
|
||||
|
||||
private static final String USER_HOME_PROPERTY = "user.home";
|
||||
|
||||
/** Columns the current ChatMember CREATE TABLE statement in DBController declares. */
|
||||
private static final List<String> CHAT_MEMBER_COLUMNS = List.of(
|
||||
"callsign", "qra", "name", "lastActivityDateTime",
|
||||
"worked", "worked144", "worked432", "worked1240", "worked2300", "worked3400",
|
||||
"worked5600", "worked10G", "worked50", "worked70",
|
||||
"notQRV144", "notQRV432", "notQRV1240", "notQRV2300", "notQRV3400", "notQRV5600",
|
||||
"notQRV10G", "notQRV50", "notQRV70",
|
||||
"lastFlagsChangeEpochMs");
|
||||
|
||||
/** Columns the current WorkedGrossField CREATE TABLE statement in DBController declares. */
|
||||
private static final List<String> WORKED_GROSS_FIELD_COLUMNS = List.of(
|
||||
"band", "grossField", "locator", "callsign", "source", "lastWorkedEpochMs");
|
||||
|
||||
/**
|
||||
* ChatMember as created by release 1.1: no not-QRV columns, no timestamp, no 50/70 MHz.
|
||||
*/
|
||||
private static final String CREATE_CHAT_MEMBER_V11 =
|
||||
"CREATE TABLE ChatMember ("
|
||||
+ "callsign TEXT NOT NULL PRIMARY KEY, qra TEXT, name TEXT, "
|
||||
+ "lastActivityDateTime TEXT, "
|
||||
+ "worked BOOLEAN DEFAULT 0, worked144 BOOLEAN DEFAULT 0, "
|
||||
+ "worked432 BOOLEAN DEFAULT 0, worked1240 BOOLEAN DEFAULT 0, "
|
||||
+ "worked2300 BOOLEAN DEFAULT 0, worked3400 BOOLEAN DEFAULT 0, "
|
||||
+ "worked5600 BOOLEAN DEFAULT 0, worked10G BOOLEAN DEFAULT 0);";
|
||||
|
||||
/**
|
||||
* ChatMember as created by release 1.3: not-QRV columns and the expiry timestamp exist,
|
||||
* the 50/70 MHz columns of release 1.4 do not.
|
||||
*/
|
||||
private static final String CREATE_CHAT_MEMBER_V13 =
|
||||
"CREATE TABLE ChatMember ("
|
||||
+ "callsign TEXT NOT NULL PRIMARY KEY, qra TEXT, name TEXT, "
|
||||
+ "lastActivityDateTime TEXT, "
|
||||
+ "worked BOOLEAN DEFAULT 0, worked144 BOOLEAN DEFAULT 0, "
|
||||
+ "worked432 BOOLEAN DEFAULT 0, worked1240 BOOLEAN DEFAULT 0, "
|
||||
+ "worked2300 BOOLEAN DEFAULT 0, worked3400 BOOLEAN DEFAULT 0, "
|
||||
+ "worked5600 BOOLEAN DEFAULT 0, worked10G BOOLEAN DEFAULT 0, "
|
||||
+ "notQRV144 BOOLEAN DEFAULT 0, notQRV432 BOOLEAN DEFAULT 0, "
|
||||
+ "notQRV1240 BOOLEAN DEFAULT 0, notQRV2300 BOOLEAN DEFAULT 0, "
|
||||
+ "notQRV3400 BOOLEAN DEFAULT 0, notQRV5600 BOOLEAN DEFAULT 0, "
|
||||
+ "notQRV10G BOOLEAN DEFAULT 0, "
|
||||
+ "lastFlagsChangeEpochMs INTEGER DEFAULT 0);";
|
||||
|
||||
@TempDir
|
||||
Path temporaryHomeDirectory;
|
||||
|
||||
private String originalUserHome;
|
||||
private DBController controller;
|
||||
|
||||
@BeforeEach
|
||||
void redirectUserHomeToTemporaryDirectory() {
|
||||
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
|
||||
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void closeControllerAndRestoreUserHome() {
|
||||
if (controller != null) {
|
||||
controller.closeDBConnection();
|
||||
controller = null;
|
||||
}
|
||||
if (originalUserHome == null) {
|
||||
System.clearProperty(USER_HOME_PROPERTY);
|
||||
} else {
|
||||
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void seededTemplateDatabaseIsMigratedToTheCurrentSchema() throws Exception {
|
||||
controller = new DBController("praktiKST.db", true);
|
||||
String databasePath = controller.getDatabaseFilePath();
|
||||
|
||||
assertEquals(currentSchemaVersion(), userVersion(databasePath));
|
||||
assertColumns(databasePath, "ChatMember", CHAT_MEMBER_COLUMNS);
|
||||
assertColumns(databasePath, "WorkedGrossField", WORKED_GROSS_FIELD_COLUMNS);
|
||||
assertEquals(0, legacyCallsignRowCount(databasePath),
|
||||
"the normalisation rebuild must leave no slash or dash keys behind");
|
||||
|
||||
Map<String, ChatMember> stations = controller.fetchChatMemberWkdDataFromDB();
|
||||
// The bundled template carries 3452 foreign callsigns, 199 of them with a suffix or
|
||||
// prefix; merging those to their base callsign shrinks the table but keeps the stations.
|
||||
assertTrue(stations.size() > 3000 && stations.size() < 3452,
|
||||
"unexpected station count after the rebuild: " + stations.size());
|
||||
|
||||
// "OE/HA5DDX" is the only row of this station in the template.
|
||||
ChatMember ha5ddx = stations.get("HA5DDX");
|
||||
assertNotNull(ha5ddx, "OE/HA5DDX must be stored under its base callsign");
|
||||
assertEquals("JN77WM", ha5ddx.getQra());
|
||||
assertEquals("Shani", ha5ddx.getName());
|
||||
assertFalse(stations.containsKey("OE/HA5DDX"));
|
||||
|
||||
// "F6KFH" and "F6KFH-2" both exist in the template and collapse into one row.
|
||||
assertTrue(stations.containsKey("F6KFH"));
|
||||
assertFalse(stations.containsKey("F6KFH-2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void release11SchemaGainsAllColumnsAndMergesLegacyCallsignKeys() throws Exception {
|
||||
String relativeName = "profiles/legacy11/praktiKST.db";
|
||||
String databasePath = fixturePath(relativeName);
|
||||
try (Connection raw = openRaw(databasePath); Statement statement = raw.createStatement()) {
|
||||
statement.executeUpdate(CREATE_CHAT_MEMBER_V11);
|
||||
statement.executeUpdate("INSERT INTO ChatMember (callsign, qra, name, worked144) "
|
||||
+ "VALUES ('OK2M-70', 'JN69UN', 'Team', 1);");
|
||||
statement.executeUpdate("INSERT INTO ChatMember (callsign, worked432) "
|
||||
+ "VALUES ('OK2M', 1);");
|
||||
statement.executeUpdate("INSERT INTO ChatMember (callsign, qra, name, worked) "
|
||||
+ "VALUES ('EA5/G8MBI/P', 'IM98PA', 'Bob', 1);");
|
||||
statement.executeUpdate("INSERT INTO ChatMember (callsign, qra, name) "
|
||||
+ "VALUES ('DL1XYZ', 'JO31AA', 'Plain');");
|
||||
}
|
||||
assertEquals(0, userVersion(databasePath));
|
||||
|
||||
controller = new DBController(relativeName, false);
|
||||
|
||||
assertEquals(currentSchemaVersion(), userVersion(databasePath));
|
||||
assertColumns(databasePath, "ChatMember", CHAT_MEMBER_COLUMNS);
|
||||
assertColumns(databasePath, "WorkedGrossField", WORKED_GROSS_FIELD_COLUMNS);
|
||||
assertEquals(0, legacyCallsignRowCount(databasePath));
|
||||
|
||||
Map<String, ChatMember> stations = controller.fetchChatMemberWkdDataFromDB();
|
||||
assertEquals(Set.of("DL1XYZ", "G8MBI", "OK2M"), new TreeSet<>(stations.keySet()));
|
||||
|
||||
ChatMember ok2m = stations.get("OK2M");
|
||||
assertTrue(ok2m.isWorked144(), "worked144 of OK2M-70 must survive the merge");
|
||||
assertTrue(ok2m.isWorked432(), "worked432 of OK2M must survive the merge");
|
||||
assertEquals("JN69UN", ok2m.getQra(), "the merge fills an empty locator from the other row");
|
||||
assertEquals("Team", ok2m.getName());
|
||||
assertTrue(ok2m.isQrv144() && ok2m.isQrv50() && ok2m.isQrv70(),
|
||||
"columns added by the migration default to QRV");
|
||||
|
||||
ChatMember g8mbi = stations.get("G8MBI");
|
||||
assertTrue(g8mbi.isWorked());
|
||||
assertEquals("IM98PA", g8mbi.getQra());
|
||||
|
||||
ChatMember dl1xyz = stations.get("DL1XYZ");
|
||||
assertFalse(dl1xyz.isWorked());
|
||||
assertFalse(dl1xyz.isWorked144());
|
||||
assertEquals("Plain", dl1xyz.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void release13SchemaGainsBandColumnsAndKeepsRowsUntouched() throws Exception {
|
||||
String relativeName = "profiles/legacy13/praktiKST.db";
|
||||
String databasePath = fixturePath(relativeName);
|
||||
long recentFlagsChange = System.currentTimeMillis() - 60_000L;
|
||||
try (Connection raw = openRaw(databasePath); Statement statement = raw.createStatement()) {
|
||||
statement.executeUpdate(CREATE_CHAT_MEMBER_V13);
|
||||
statement.executeUpdate("INSERT INTO ChatMember (callsign, qra, name, "
|
||||
+ "lastActivityDateTime, worked144, notQRV432, lastFlagsChangeEpochMs) "
|
||||
+ "VALUES ('DL2ABC', 'JO62QL', 'Anna', 'Sun Aug 06 10:21:23 CEST 2023', 1, 1, "
|
||||
+ recentFlagsChange + ");");
|
||||
statement.executeUpdate("INSERT INTO ChatMember (callsign) VALUES ('DL3DEF');");
|
||||
}
|
||||
|
||||
controller = new DBController(relativeName, false);
|
||||
|
||||
assertEquals(currentSchemaVersion(), userVersion(databasePath));
|
||||
assertColumns(databasePath, "ChatMember", CHAT_MEMBER_COLUMNS);
|
||||
|
||||
Map<String, ChatMember> stations = controller.fetchChatMemberWkdDataFromDB();
|
||||
assertEquals(Set.of("DL2ABC", "DL3DEF"), new TreeSet<>(stations.keySet()));
|
||||
|
||||
ChatMember dl2abc = stations.get("DL2ABC");
|
||||
assertTrue(dl2abc.isWorked144());
|
||||
assertFalse(dl2abc.isQrv432());
|
||||
assertTrue(dl2abc.isQrv144());
|
||||
assertFalse(dl2abc.isWorked50());
|
||||
assertFalse(dl2abc.isWorked70());
|
||||
assertTrue(dl2abc.isQrv50());
|
||||
assertTrue(dl2abc.isQrv70());
|
||||
assertEquals(recentFlagsChange, dl2abc.getLastFlagsChangeEpochMs(),
|
||||
"a recent flags timestamp must not be reset by the expiry cleanup");
|
||||
|
||||
// Without legacy keys no rebuild happens, so even the column the controller never
|
||||
// reads back stays as it was.
|
||||
assertEquals("Sun Aug 06 10:21:23 CEST 2023",
|
||||
scalar(databasePath, "SELECT lastActivityDateTime FROM ChatMember WHERE callsign = 'DL2ABC';"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reopeningAnUpToDateDatabaseChangesNothing() throws Exception {
|
||||
String relativeName = "profiles/current/praktiKST.db";
|
||||
controller = new DBController(relativeName, false);
|
||||
String databasePath = controller.getDatabaseFilePath();
|
||||
|
||||
ChatMember worked = new ChatMember();
|
||||
worked.setCallSign("DL0XYZ");
|
||||
worked.setQra("JO51IJ");
|
||||
worked.setWorked(true);
|
||||
worked.setWorked144(true);
|
||||
controller.storeChatMember(worked);
|
||||
controller.closeDBConnection();
|
||||
controller = null;
|
||||
|
||||
assertEquals(currentSchemaVersion(), userVersion(databasePath));
|
||||
List<String> before = dump(databasePath);
|
||||
|
||||
controller = new DBController(relativeName, false);
|
||||
assertTrue(controller.fetchChatMemberWkdDataFromDB().get("DL0XYZ").isWorked144());
|
||||
controller.closeDBConnection();
|
||||
controller = null;
|
||||
|
||||
assertEquals(before, dump(databasePath),
|
||||
"schema, user_version and rows must be identical after reopening");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private static int currentSchemaVersion() throws ReflectiveOperationException {
|
||||
Field field = DBController.class.getDeclaredField("CURRENT_DATABASE_SCHEMA_VERSION");
|
||||
field.setAccessible(true);
|
||||
return field.getInt(null);
|
||||
}
|
||||
|
||||
private static String fixturePath(String relativeName) throws Exception {
|
||||
String path = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, relativeName);
|
||||
Files.createDirectories(Path.of(path).getParent());
|
||||
return path;
|
||||
}
|
||||
|
||||
private static Connection openRaw(String databasePath) throws SQLException {
|
||||
return DriverManager.getConnection("jdbc:sqlite:" + databasePath);
|
||||
}
|
||||
|
||||
private static int userVersion(String databasePath) throws SQLException {
|
||||
return Integer.parseInt(scalar(databasePath, "PRAGMA user_version;"));
|
||||
}
|
||||
|
||||
private static String scalar(String databasePath, String sql) throws SQLException {
|
||||
try (Connection raw = openRaw(databasePath);
|
||||
Statement statement = raw.createStatement();
|
||||
ResultSet resultSet = statement.executeQuery(sql)) {
|
||||
assertTrue(resultSet.next(), "no row for " + sql);
|
||||
return resultSet.getString(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static int legacyCallsignRowCount(String databasePath) throws SQLException {
|
||||
return Integer.parseInt(scalar(databasePath,
|
||||
"SELECT count(*) FROM ChatMember WHERE callsign LIKE '%/%' OR callsign LIKE '%-%';"));
|
||||
}
|
||||
|
||||
private static void assertColumns(String databasePath, String table, List<String> expected)
|
||||
throws SQLException {
|
||||
Set<String> actual = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
|
||||
try (Connection raw = openRaw(databasePath);
|
||||
Statement statement = raw.createStatement();
|
||||
ResultSet resultSet = statement.executeQuery("PRAGMA table_info(" + table + ");")) {
|
||||
while (resultSet.next()) {
|
||||
actual.add(resultSet.getString("name"));
|
||||
}
|
||||
}
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String column : expected) {
|
||||
if (!actual.contains(column)) {
|
||||
missing.add(column);
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), missing, "columns missing in " + table + "; present: " + actual);
|
||||
}
|
||||
|
||||
/** Captures schema, user_version and every row, so two states can be compared. */
|
||||
private static List<String> dump(String databasePath) throws SQLException {
|
||||
List<String> lines = new ArrayList<>();
|
||||
lines.add("user_version=" + userVersion(databasePath));
|
||||
try (Connection raw = openRaw(databasePath); Statement statement = raw.createStatement()) {
|
||||
try (ResultSet resultSet = statement.executeQuery(
|
||||
"SELECT type, name, sql FROM sqlite_master ORDER BY type, name;")) {
|
||||
while (resultSet.next()) {
|
||||
lines.add(resultSet.getString(1) + " " + resultSet.getString(2) + " " + resultSet.getString(3));
|
||||
}
|
||||
}
|
||||
for (String table : List.of("ChatMember", "WorkedGrossField")) {
|
||||
try (ResultSet resultSet = statement.executeQuery("SELECT * FROM " + table + " ORDER BY 1;")) {
|
||||
int columns = resultSet.getMetaData().getColumnCount();
|
||||
while (resultSet.next()) {
|
||||
StringBuilder row = new StringBuilder(table);
|
||||
for (int i = 1; i <= columns; i++) {
|
||||
row.append('|').append(resultSet.getString(i));
|
||||
}
|
||||
lines.add(row.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
-2
@@ -1,7 +1,5 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
File renamed without changes.
File renamed without changes.
@@ -31,7 +31,7 @@ class ChatPreferencesObservableTest {
|
||||
void theListSettingsStartEmpty() {
|
||||
ChatPreferences prefs = new ChatPreferences();
|
||||
|
||||
assertTrue(prefs.getLstNotify_QSOSniffer_sniffedWordsList().snapshot().isEmpty());
|
||||
assertTrue(prefs.getLstNotify_QSOSniffer_sniffedCallSignList().snapshot().isEmpty());
|
||||
assertTrue(prefs.getLstNotify_QSOSniffer_sniffedPrefixLocList().snapshot().isEmpty());
|
||||
assertTrue(prefs.getLst_txtSnipList().snapshot().isEmpty());
|
||||
assertTrue(prefs.getLst_txtShortCutBtnList().snapshot().isEmpty());
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
package kst4contest.model;
|
||||
|
||||
import kst4contest.observe.MutableValue;
|
||||
import kst4contest.observe.ObservableRoster;
|
||||
import kst4contest.observe.SimpleValue;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Drives every public single-argument setter of {@link ChatPreferences} to a value that
|
||||
* differs from the compiled-in default, writes the preferences to XML, reads them into a
|
||||
* fresh instance and compares every getter. The setters are discovered by reflection so a
|
||||
* newly added preference is covered automatically; a field that is not persisted on purpose
|
||||
* must be listed in {@link #NOT_PERSISTED_SETTERS} with a reason.
|
||||
*/
|
||||
class ChatPreferencesRoundTripTest {
|
||||
|
||||
/**
|
||||
* Setters whose value is intentionally not written to the XML file. Each entry names the
|
||||
* evidence in {@code ChatPreferences}. A field that is lost by accident must not be added
|
||||
* here silently; a known persistence bug is listed with a "BUG:" prefix so the test stays
|
||||
* green while the report stays loud.
|
||||
*/
|
||||
private static final Map<String, String> NOT_PERSISTED_SETTERS = Map.ofEntries(
|
||||
Map.entry("setStoreAndRestorePreferencesFileName",
|
||||
"Path of the XML file itself; set by the constructors, never a child "
|
||||
+ "element of <praktiKST>"),
|
||||
Map.entry("setChatState",
|
||||
"Declared as 'working variable only for use by primarystage (title bar)'; "
|
||||
+ "writePreferencesToXmlFile never emits it"),
|
||||
Map.entry("setProgramVersion",
|
||||
"Compiled-in about text; writePreferencesToXmlFile never emits it"),
|
||||
Map.entry("setActualQTF",
|
||||
"Runtime antenna direction ('will be updated by user at runtime!'); "
|
||||
+ "writePreferencesToXmlFile never emits it"),
|
||||
Map.entry("setMessageHandling_autoAnswerTextSecondCat",
|
||||
"Written as <autoAnswerTextSecondCat>, but readPreferencesFromXmlFile "
|
||||
+ "deliberately mirrors the main-category text over it because the UI "
|
||||
+ "exposes one shared auto-answer setting (see the comment above "
|
||||
+ "'messageHandling_autoAnswerTextSecondCat = "
|
||||
+ "messageHandling_autoAnswerTextMainCat')"),
|
||||
Map.entry("setMessageHandling_autoAnswerEnabledSecondCat",
|
||||
"Same deliberate mirroring as the second-category auto-answer text; the "
|
||||
+ "main-category flag wins after every read"),
|
||||
Map.entry("setBcn_contestScoreSum",
|
||||
"DEAD FIELD: declared with accessors only, no caller in core or app-desktop, "
|
||||
+ "never written nor read")
|
||||
);
|
||||
|
||||
/**
|
||||
* Setters that are an alias for another setter and therefore have no getter of their own.
|
||||
* The canonical setter is covered by the reflective loop.
|
||||
*/
|
||||
private static final Map<String, String> ALIAS_SETTERS = Map.ofEntries(
|
||||
Map.entry("setLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup",
|
||||
"Capital-S alias of setLogsynch_storeWorkedCallSignsFileNameUDPMessageBackup")
|
||||
);
|
||||
|
||||
/**
|
||||
* Setters whose argument is validated or parsed, so the generic "distinctive value per
|
||||
* type" would be rejected. The value here is a valid, non-default one.
|
||||
*/
|
||||
private static final Map<String, Object> SPECIAL_VALUES = Map.ofEntries(
|
||||
Map.entry("setGuiOptions_language", "de"),
|
||||
Map.entry("setMYQRGFirstCat", "144.321.00"),
|
||||
Map.entry("setMYQRGSecondCat", "432.321.00"),
|
||||
Map.entry("setAirScout_asBandString", "4320000")
|
||||
);
|
||||
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void everySetterSurvivesTheXmlRoundTrip() throws Exception {
|
||||
Path preferencesFile = temporaryDirectory.resolve("roundtrip.xml");
|
||||
ChatPreferences defaults = new ChatPreferences(preferencesFile);
|
||||
ChatPreferences written = new ChatPreferences(preferencesFile);
|
||||
|
||||
List<Method> setters = settersUnderTest();
|
||||
assertTrue(setters.size() > 100,
|
||||
"Expected the reflective scan to find the ~130 setters, found " + setters.size());
|
||||
|
||||
List<String> unbuildable = new ArrayList<>();
|
||||
List<String> withoutGetter = new ArrayList<>();
|
||||
List<String> unchangedByValue = new ArrayList<>();
|
||||
Map<Method, Method> getterBySetter = new LinkedHashMap<>();
|
||||
|
||||
for (Method setter : setters) {
|
||||
Method getter = findGetter(setter);
|
||||
if (getter == null) {
|
||||
withoutGetter.add(setter.getName());
|
||||
continue;
|
||||
}
|
||||
Object valueToSet = distinctiveValue(setter, getter.invoke(defaults));
|
||||
if (valueToSet == null) {
|
||||
unbuildable.add(setter.getName() + "(" + setter.getParameterTypes()[0].getSimpleName() + ")");
|
||||
continue;
|
||||
}
|
||||
setter.invoke(written, valueToSet);
|
||||
getterBySetter.put(setter, getter);
|
||||
|
||||
if (sameValue(getter.invoke(defaults), getter.invoke(written))) {
|
||||
unchangedByValue.add(setter.getName() + " still reads " + describe(getter.invoke(written)));
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(List.of(), withoutGetter,
|
||||
"Setters without a matching getter (add them to ALIAS_SETTERS with a reason)");
|
||||
assertEquals(List.of(), unbuildable,
|
||||
"Setters whose parameter type this test cannot construct");
|
||||
assertEquals(List.of(), unchangedByValue,
|
||||
"Setters whose distinctive value equals the default (the round trip could not "
|
||||
+ "detect a loss for them)");
|
||||
|
||||
assertTrue(written.writePreferencesToXmlFile(), "writing " + preferencesFile + " failed");
|
||||
|
||||
ChatPreferences restored = new ChatPreferences(preferencesFile);
|
||||
assertTrue(restored.readPreferencesFromXmlFile(), "reading " + preferencesFile + " failed");
|
||||
|
||||
List<String> lost = new ArrayList<>();
|
||||
for (Map.Entry<Method, Method> entry : getterBySetter.entrySet()) {
|
||||
Method getter = entry.getValue();
|
||||
Object expected = getter.invoke(written);
|
||||
Object actual = getter.invoke(restored);
|
||||
if (!sameValue(expected, actual)) {
|
||||
lost.add(entry.getKey().getName() + ": written=" + describe(expected)
|
||||
+ " restored=" + describe(actual));
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(List.of(), lost,
|
||||
"Fields that did not survive writePreferencesToXmlFile -> readPreferencesFromXmlFile");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bundledTemplatePreferencesLoadWithKnownValues() throws IOException {
|
||||
Path templateCopy = temporaryDirectory.resolve("template.xml");
|
||||
try (InputStream template = ChatPreferences.class
|
||||
.getResourceAsStream(ChatPreferences.PREFERENCE_RESOURCE)) {
|
||||
assertNotNull(template, "bundled resource " + ChatPreferences.PREFERENCE_RESOURCE);
|
||||
Files.copy(template, templateCopy, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
ChatPreferences fromTemplate = new ChatPreferences(templateCopy);
|
||||
assertTrue(fromTemplate.readPreferencesFromXmlFile());
|
||||
|
||||
// Values the seeded template carries today; the station block uses the legacy tag names.
|
||||
assertEquals("DO5AMF", fromTemplate.getStn_loginCallSign());
|
||||
assertEquals("JO54ME", fromTemplate.getStn_loginLocatorMainCat());
|
||||
assertEquals(2, fromTemplate.getLoginChatCategoryMain().getCategoryNumber());
|
||||
assertEquals(3, fromTemplate.getLoginChatCategorySecond().getCategoryNumber());
|
||||
assertTrue(fromTemplate.isLoginToSecondChatEnabled());
|
||||
assertEquals(1333.0, fromTemplate.getStn_maxQRBDefault());
|
||||
assertEquals(12060, fromTemplate.getLogsynch_ucxUDPWkdCallListenerPort());
|
||||
assertEquals(8000, fromTemplate.getNotify_dxclusterServerPort());
|
||||
assertEquals(9872, fromTemplate.getAirScout_asCommunicationPort());
|
||||
assertTrue(fromTemplate.getLst_txtShortCutBtnList().snapshot().contains("sked"));
|
||||
assertTrue(fromTemplate.isStn_bandActive144());
|
||||
assertFalse(fromTemplate.isStn_bandActive10G());
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyFileWithoutConfigVersionMapsOldTagNamesOntoCurrentFields() throws IOException {
|
||||
Path legacyFile = temporaryDirectory.resolve("legacy.xml");
|
||||
Files.writeString(legacyFile, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<praktiKST>
|
||||
<station>
|
||||
<LoginCallSign>DL1ABC-2</LoginCallSign>
|
||||
<LoginPassword>secret</LoginPassword>
|
||||
<LoginDisplayedName>Old Name</LoginDisplayedName>
|
||||
<LoginLocator>JO31AA</LoginLocator>
|
||||
<ChatCategory>3</ChatCategory>
|
||||
</station>
|
||||
<notifications>
|
||||
<notify_dxclusterServerPort>8123</notify_dxclusterServerPort>
|
||||
</notifications>
|
||||
</praktiKST>
|
||||
""");
|
||||
|
||||
ChatPreferences legacy = new ChatPreferences(legacyFile);
|
||||
assertTrue(legacy.readPreferencesFromXmlFile());
|
||||
|
||||
assertEquals("DL1ABC-2", legacy.getStn_loginCallSign());
|
||||
assertEquals("secret", legacy.getStn_loginPassword());
|
||||
assertEquals("Old Name", legacy.getStn_loginNameMainCat());
|
||||
assertEquals("JO31AA", legacy.getStn_loginLocatorMainCat());
|
||||
assertEquals(3, legacy.getLoginChatCategoryMain().getCategoryNumber());
|
||||
assertEquals(8123, legacy.getNotify_dxclusterServerPort());
|
||||
// A tag that is missing keeps the compiled-in default.
|
||||
assertEquals(new ChatPreferences(legacyFile).getStn_on4kstServersPort(),
|
||||
legacy.getStn_on4kstServersPort());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// reflection helpers
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private static List<Method> settersUnderTest() {
|
||||
List<Method> setters = new ArrayList<>();
|
||||
for (Method method : ChatPreferences.class.getMethods()) {
|
||||
if (method.getDeclaringClass() != ChatPreferences.class
|
||||
|| Modifier.isStatic(method.getModifiers())
|
||||
|| !method.getName().startsWith("set")
|
||||
|| method.getParameterCount() != 1
|
||||
|| NOT_PERSISTED_SETTERS.containsKey(method.getName())
|
||||
|| ALIAS_SETTERS.containsKey(method.getName())) {
|
||||
continue;
|
||||
}
|
||||
setters.add(method);
|
||||
}
|
||||
setters.sort(Comparator.comparing(Method::getName));
|
||||
return setters;
|
||||
}
|
||||
|
||||
private static Method findGetter(Method setter) {
|
||||
String property = setter.getName().substring(3);
|
||||
for (String prefix : new String[] {"get", "is"}) {
|
||||
try {
|
||||
return ChatPreferences.class.getMethod(prefix + property);
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
// try the next prefix
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a value that differs from the current one. Returns null when the parameter type
|
||||
* cannot be constructed here.
|
||||
*/
|
||||
private static Object distinctiveValue(Method setter, Object currentValue) {
|
||||
Object special = SPECIAL_VALUES.get(setter.getName());
|
||||
if (special != null) {
|
||||
return special;
|
||||
}
|
||||
|
||||
Class<?> type = setter.getParameterTypes()[0];
|
||||
String name = setter.getName().substring(3);
|
||||
Object current = unwrap(currentValue);
|
||||
|
||||
if (type == boolean.class) {
|
||||
return !(current instanceof Boolean b && b);
|
||||
}
|
||||
if (type == int.class) {
|
||||
return (current instanceof Integer i ? i : 0) + 7;
|
||||
}
|
||||
if (type == long.class) {
|
||||
return (current instanceof Long l ? l : 0L) + 7L;
|
||||
}
|
||||
if (type == double.class) {
|
||||
return (current instanceof Double d ? d : 0.0) + 7.5;
|
||||
}
|
||||
if (type == String.class) {
|
||||
return "rt-" + name;
|
||||
}
|
||||
if (type == double[].class) {
|
||||
double[] base = current instanceof double[] array && array.length > 0
|
||||
? array : new double[] {0.0, 0.0};
|
||||
double[] changed = new double[base.length];
|
||||
for (int i = 0; i < base.length; i++) {
|
||||
// Window positions default to NaN ("not yet placed"); NaN + 7 is still NaN.
|
||||
changed[i] = Double.isNaN(base[i]) ? 100.0 + i : base[i] + 7.0 + i;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
if (Collection.class.isAssignableFrom(type)) {
|
||||
return List.of("rt-" + name + "-a", "rt-" + name + "-b");
|
||||
}
|
||||
if (type == ChatCategory.class) {
|
||||
int currentNumber = current instanceof Integer i ? i : 2;
|
||||
return new ChatCategory(currentNumber == 5 ? 6 : 5);
|
||||
}
|
||||
if (type == MutableValue.class) {
|
||||
return new SimpleValue<>(current instanceof Double d ? d + 7.5 : 7.5);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reduces observable wrappers and domain objects to plain comparable values. */
|
||||
private static Object unwrap(Object value) {
|
||||
if (value instanceof MutableValue<?> mutable) {
|
||||
return mutable.get();
|
||||
}
|
||||
if (value instanceof ObservableRoster<?> roster) {
|
||||
return roster.snapshot();
|
||||
}
|
||||
if (value instanceof ChatCategory category) {
|
||||
return category.getCategoryNumber();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static boolean sameValue(Object left, Object right) {
|
||||
Object a = unwrap(left);
|
||||
Object b = unwrap(right);
|
||||
if (a instanceof double[] x && b instanceof double[] y) {
|
||||
return Arrays.equals(x, y);
|
||||
}
|
||||
if (a instanceof Double x && b instanceof Double y) {
|
||||
return Math.abs(x - y) < 1e-9;
|
||||
}
|
||||
return Objects.equals(a, b);
|
||||
}
|
||||
|
||||
private static String describe(Object value) {
|
||||
Object plain = unwrap(value);
|
||||
if (plain instanceof double[] array) {
|
||||
return Arrays.toString(array);
|
||||
}
|
||||
return String.valueOf(plain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package kst4contest.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Guards the QSO sniffer callsign list against the historical {@code <snifferWords>} block.
|
||||
* Older preference files still carry that element; its entries must not end up in the
|
||||
* monitored-callsign list (issue #103).
|
||||
*/
|
||||
class ChatPreferencesSnifferListTest {
|
||||
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void legacySnifferWordsDoNotLeakIntoTheCallsignList() throws IOException {
|
||||
Path preferencesFile = temporaryDirectory.resolve("sniffer.xml");
|
||||
Files.writeString(preferencesFile, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<praktiKST>
|
||||
<snifferWords>
|
||||
<w>DL1ABC</w>
|
||||
</snifferWords>
|
||||
<snifferCallSigns>
|
||||
<callSign>DN9APW-2</callSign>
|
||||
</snifferCallSigns>
|
||||
</praktiKST>
|
||||
""");
|
||||
|
||||
ChatPreferences preferences = new ChatPreferences(preferencesFile);
|
||||
assertTrue(preferences.readPreferencesFromXmlFile());
|
||||
|
||||
assertEquals(List.of("DN9APW"),
|
||||
preferences.getLstNotify_QSOSniffer_sniffedCallSignList().snapshot(),
|
||||
"only the <snifferCallSigns> entry, normalised to its base callsign, "
|
||||
+ "may be monitored; <snifferWords> is an ignored legacy element");
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,15 +0,0 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
|
||||
public class TestApplicationFileUtils {
|
||||
public static final String DATABASE_FILE = "praktiKST.db";
|
||||
private static final String DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE);
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
System.out.println("DB Path = " + DB_PATH);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class TestChatPreferences {
|
||||
|
||||
@Test
|
||||
public static void main(String[] args) {
|
||||
|
||||
ChatPreferences prefs = new ChatPreferences();
|
||||
prefs.readPreferencesFromXmlFile(); //works
|
||||
|
||||
prefs.writePreferencesToXmlFile(); //works
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.controller.DBController;
|
||||
import kst4contest.controller.Utils4KST;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
|
||||
public class TestDBController {
|
||||
|
||||
public static final String DATABASE_FILE = "praktiKST.db";
|
||||
private static final String DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE);
|
||||
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
|
||||
DBController dbc = DBController.getInstance();
|
||||
// dbc.initDBConnection();
|
||||
|
||||
ChatMember dummy = new ChatMember();
|
||||
dummy.setCallSign("DM5M");
|
||||
dummy.setQra("jo51ij");
|
||||
dummy.setName("Team Test");
|
||||
dummy.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
|
||||
dummy.setWorked5600(true);
|
||||
// dummy.setWorked432(true);
|
||||
|
||||
dbc.fetchChatMemberWkdDataForOnlyOneCallsignFromDB(dummy);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.locatorUtils.Angle;
|
||||
import kst4contest.locatorUtils.Latitude;
|
||||
import kst4contest.locatorUtils.Location;
|
||||
import kst4contest.locatorUtils.Longitude;
|
||||
|
||||
import java.text.DecimalFormat;
|
||||
|
||||
public class TestLocatorUtils {
|
||||
|
||||
public static void main(String[] args) {
|
||||
Location location = new Location("JN49FL");
|
||||
Location location2 = new Location("JO51IJ");
|
||||
|
||||
// System.out.println(location.getDistanceKm(location2));
|
||||
// System.out.println(Location.getBearing(location, location2));
|
||||
|
||||
|
||||
System.out.println((new Location().getDistanceKmByTwoLocatorStrings("JN49FL", "kn02fx") + ""));
|
||||
// System.out.println((new Location().getBearing());
|
||||
// int test = 888.08;
|
||||
// System.out.println(test);
|
||||
|
||||
// String test = new Location().getDistanceKmByTwoLocatorStrings("JN49FL", "Jo51ij") + "";
|
||||
//
|
||||
// DecimalFormat df = new DecimalFormat("#.##");
|
||||
//
|
||||
// System.out.println(df.format(Double.parseDouble(test)));
|
||||
|
||||
// Angle angle = new Angle();
|
||||
// Latitude lat = new Latitude();
|
||||
// Longitude lon = new Longitude();
|
||||
//
|
||||
// location.setLatitude(lat);
|
||||
// location.setLongitude(lon);
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.controller.ReadUDPbyAirScoutMessageThread;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class TestReadUDPASListenerThread {
|
||||
|
||||
@Test
|
||||
public static void main(String[] args) {
|
||||
|
||||
// ReadUDPbyAirScoutMessageThread asUDPReader = new ReadUDPbyAirScoutMessageThread(9872, null, "AS", "KST");
|
||||
// asUDPReader.start();
|
||||
|
||||
String testThis;
|
||||
|
||||
// testThis = "ASNEAREST: \"AS\" \"KST\" \"2023-04-01 21:33:42Z,DO5AMF,JN49GL,G4CLA,IO92JL,9,VLG2PT,M,190,75,14,BAW809,M,250,50,18,BEL6CM,M,143,50,12,WZZ6719,M,148,50,11,KLM1678,M,313,75,22,TRA1B,M,271,75,20,SAS4728,M,125,75,9,RYR6TL,M,90,75,6,UAE10,S,96,50,6\"";
|
||||
// asUDPReader.processASUDPMessage(testThis);
|
||||
|
||||
|
||||
// ucxUDPReader.processUCXUDPMessage(testThis);
|
||||
|
||||
|
||||
// ucxUDPReader =new ReadUDPbyUCXMessageThread(12060);
|
||||
// ucxUDPReader.processUCXUDPMessage(testThis);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.controller.ReadUDPbyUCXMessageThread;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class TestReadUDPUCXListenerThread {
|
||||
|
||||
@Test
|
||||
public static void main(String[] args) {
|
||||
|
||||
// ReadUDPbyUCXMessageThread ucxUDPReader = new ReadUDPbyUCXMessageThread(12060);
|
||||
// ucxUDPReader.start();
|
||||
|
||||
|
||||
String testThis;
|
||||
|
||||
testThis = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n"
|
||||
+ "<contactinfo>\r\n"
|
||||
+ " <app>UcxLog</app>\r\n"
|
||||
+ " <contestname></contestname>\r\n"
|
||||
+ " <contestnr></contestnr>\r\n"
|
||||
+ " <timestamp>2022-10-30 11:51:00</timestamp>\r\n"
|
||||
+ " <mycall>DO5AMF</mycall>\r\n"
|
||||
+ " <band>144</band>\r\n"
|
||||
+ " <rxfreq>14400000</rxfreq>\r\n"
|
||||
+ " <txfreq>14400000</txfreq>\r\n"
|
||||
+ " <operator></operator>\r\n"
|
||||
+ " <mode>USB</mode>\r\n"
|
||||
+ " <call>DM1AO</call>\r\n"
|
||||
+ " <countryprefix></countryprefix>\r\n"
|
||||
+ " <wpxprefix></wpxprefix>\r\n"
|
||||
+ " <stationprefix></stationprefix>\r\n"
|
||||
+ " <continent>EU</continent>\r\n"
|
||||
+ " <snt>59</snt>\r\n"
|
||||
+ " <sntnr>021 JN49FK</sntnr>\r\n"
|
||||
+ " <rcv>59</rcv>\r\n"
|
||||
+ " <rcvnr>001 JO50LT</rcvnr>\r\n"
|
||||
+ " <gridsquare></gridsquare>\r\n"
|
||||
+ " <exchange1></exchange1>\r\n"
|
||||
+ " <section></section>\r\n"
|
||||
+ " <comment></comment>\r\n"
|
||||
+ " <qth></qth>\r\n"
|
||||
+ " <name></name>\r\n"
|
||||
+ " <power></power>\r\n"
|
||||
+ " <misctext></misctext>\r\n"
|
||||
+ " <zone>0</zone>\r\n"
|
||||
+ " <prec></prec>\r\n"
|
||||
+ " <ck>0</ck>\r\n"
|
||||
+ " <ismultiplierl></ismultiplierl>\r\n"
|
||||
+ " <ismultiplier2></ismultiplier2>\r\n"
|
||||
+ " <ismultiplier3></ismultiplier3>\r\n"
|
||||
+ " <points></points>\r\n"
|
||||
+ " <radionr>1</radionr>\r\n"
|
||||
+ " <run1run2>1</run1run2>\r\n"
|
||||
+ " <RoverLocation></RoverLocation>\r\n"
|
||||
+ " <RadioInterfaced>0</RadioInterfaced>\r\n"
|
||||
+ " <NetworkedCompNr>0</NetworkedCompNr>\r\n"
|
||||
+ " <IsOriginal>True</IsOriginal>\r\n"
|
||||
+ " <NetBiosName></NetBiosName>\r\n"
|
||||
+ " <IsRunQSO>0</IsRunQSO>\r\n"
|
||||
+ " <StationName>LAPTOP-05GRNFHI</StationName>\r\n"
|
||||
+ " <ID>0C73B5B3B82C4836</ID>\r\n"
|
||||
+ " <IsClaimedQso>True</IsClaimedQso>\r\n"
|
||||
+ "</contactinfo> ";
|
||||
// ucxUDPReader.processUCXUDPMessage(testThis);
|
||||
|
||||
|
||||
testThis ="<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n"
|
||||
+ "<RadioInfo>\r\n"
|
||||
+ " <app>UcxLog</app>\r\n"
|
||||
+ " <RadioNr>1</RadioNr>\r\n"
|
||||
+ " <Freq>2101100</Freq>\r\n"
|
||||
+ " <TXFreq>2101100</TXFreq>\r\n"
|
||||
+ " <Mode>CW</Mode>\r\n"
|
||||
+ " <IsSplit>False</IsSplit>\r\n"
|
||||
+ "</RadioInfo>";
|
||||
|
||||
// ucxUDPReader =new ReadUDPbyUCXMessageThread(12060);
|
||||
// ucxUDPReader.processUCXUDPMessage(testThis);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
|
||||
public class UDPAirScoutSendMessages {
|
||||
|
||||
@Test
|
||||
public static void main(String args[]) {
|
||||
try {
|
||||
// String host = "www.java2s.com";
|
||||
// byte[] message = "ASWATCHLIST: \"KST\" \"AS\" 1440000,DO5AMF,JN49GL,OK1MZM,JN89IW ".getBytes();
|
||||
|
||||
|
||||
|
||||
// Get the internet address of the specified host
|
||||
// InetAddress address = InetAddress.getByName(host);^
|
||||
// byte[] byteAddress = "127.0.0.1".getBytes();
|
||||
// System.out.println(byteAddress.length);
|
||||
String host = "255.255.255.255";
|
||||
int port = 9872;
|
||||
|
||||
byte[] message = "ASSETPATH: \"KST\" \"AS\" 1440000,DO5AMF,JN49GL,OK1MZM,JN89IW ".getBytes();
|
||||
InetAddress address = InetAddress.getByName("255.255.255.255");
|
||||
|
||||
// Initialize a datagram packet with data and address
|
||||
DatagramPacket packet = new DatagramPacket(message, message.length,
|
||||
address, port);
|
||||
|
||||
|
||||
// Create a datagram socket, send the packet through it, close it.
|
||||
DatagramSocket dsocket = new DatagramSocket();
|
||||
dsocket.setBroadcast(true);
|
||||
dsocket.send(packet);
|
||||
dsocket.close();
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -1,15 +0,0 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.controller.Utils4KST;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class testTimeGeneration {
|
||||
|
||||
@Test
|
||||
public static void main(String[] args) {
|
||||
|
||||
System.out.println(new Utils4KST().time_convertEpochToReadable("1664669836"));
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -199,8 +199,10 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
|
||||
|
||||
- Use the repository Gradle wrapper (`.\gradlew.bat` on Windows).
|
||||
- The project uses Java 21, Kotlin 2.2 and Compose Multiplatform 1.8.x at this context snapshot. JavaFX has been removed; no `org.openjfx` dependency remains.
|
||||
- JUnit 5/Mockito, PMD and SpotBugs are part of the verification environment.
|
||||
- Build/test configuration has historically allowed some test/static-analysis failures not to fail the process exit code. Always read actual summaries/reports.
|
||||
- JUnit 5/Mockito, PMD and SpotBugs are part of the verification environment. JUnit and Mockito are test-only dependencies; nothing of them reaches the shipped app image.
|
||||
- Test classes live under `src/test` only. Until #62 a few JUnit classes sat under `src/main`, compiled on every build and were never executed; that is the historical reason the two modules once declared JUnit and Mockito as `implementation` dependencies.
|
||||
- A test failure fails the build. Only PMD and SpotBugs run with `ignoreFailures`, so their findings do not. Still read the test-result XML under `*/build/test-results/test/` rather than inferring a pass from the exit code.
|
||||
- Coverage is measured with JaCoCo as a report only: no `jacocoTestCoverageVerification`, no threshold (rationale in `docs/superpowers/specs/2026-10-11-test-coverage-design.md`). `./gradlew jacocoTestReport` writes a per-module report under `*/build/reports/jacoco/test/` (XML and HTML); `./gradlew jacocoAggregatedReport` merges `core` and `app-desktop` into `build/reports/jacoco/aggregated/jacoco.xml` and `.../aggregated/html/`. Both run the tests first when needed. `i18n-generator` still gets its per-module report but is left out of the aggregated one: it is build tooling and says nothing about the shipped application. The PR workflow prints line and branch coverage per module and aggregated into the job summary and uploads the aggregated HTML as the `coverage-report` artifact for 14 days. The nightly and tagged-release workflows run the test job first; no packaging job starts before it has passed.
|
||||
|
||||
## Documentation Surfaces
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# Konzept: Testabdeckung (#62)
|
||||
|
||||
> Status: Konzept, freigegeben am 2026-10-11, umgesetzt im Zweig `feature/62-test-coverage`.
|
||||
> Alle Zeilenanker beziehen sich auf `main` bei `4a90ae7d`.
|
||||
|
||||
Issue #62 verlangt einen Blick auf die Testsuite als Ganzes: Was läuft, was sieht nur so aus,
|
||||
als liefe es, und wie viel des Codes erreicht die Suite tatsächlich. Dieses Dokument hält den
|
||||
vorgefundenen Stand fest, die getroffenen Entscheidungen und das, was bewusst eigenen Issues
|
||||
überlassen wird.
|
||||
|
||||
## Das Problem
|
||||
|
||||
Vier Befunde, gemessen am Stand `4a90ae7d`.
|
||||
|
||||
**Sieben JUnit-Klassen liegen unter `src/main` und werden nie ausgeführt.** Gradles
|
||||
`test`-Task durchsucht nur `src/test`. Deshalb werden
|
||||
`core/src/main/java/kst4contest/controller/On4KstProtocolTest.java`,
|
||||
`On4KstSocketThreadTest.java`, `MessageBusManagementThreadFrequencyContextTest.java`
|
||||
(vollständig auskommentiert), `core/src/main/java/kst4contest/model/ChatMemberDirectionOpportunityTest.java`
|
||||
und die drei Klassen unter `app-desktop/src/main/java/kst4contest/test/`
|
||||
(`Utils4KSTFrequencyTest`, `FrequencyTextParserTest`, `ChatPreferencesStationMapVisibilityTest`)
|
||||
bei jedem Build kompiliert und zählen für nichts. Der Protokolltest darunter nagelt die
|
||||
exakten `LOGINC|`-, `SDONE|`- und `ACHAT|`-Frames fest, der Socket-Test genau ein CR/LF je
|
||||
Frame. Beides sind Invarianten, die `AGENTS.md` ausdrücklich nennt, und keine davon hat seit
|
||||
der Maven-Zeit ein Build geprüft.
|
||||
|
||||
**Wegen dieser Klassen wird JUnit und Mockito mit der Anwendung ausgeliefert.** `core` und
|
||||
`app-desktop` deklarieren `implementation(libs.junit.jupiter.api)` und
|
||||
`implementation(libs.mockito.core.compile)`. Damit liegen `junit-jupiter-api`,
|
||||
`junit-platform-commons`, `opentest4j`, `mockito-core`, `byte-buddy`, `byte-buddy-agent` und
|
||||
`objenesis` auf dem Laufzeit-Klassenpfad und in `lib/app` jedes App-Image, Flatpak und
|
||||
Installers. `docs/CONCEPT_IN_APP_UPDATE.md` nennt das bereits als Vorbedingung für einen
|
||||
signierten Update-Pfad: Ein Bytecode-Manipulations-Agent in einem signierten Produktionsbinary
|
||||
ist das Falsche, was man erklären müsste.
|
||||
|
||||
**Acht Dateien unter `core/src/test/java/kst4contest/test/` sind toter Scratch-Code von
|
||||
2023/2024.** `TestChatPreferences`, `TestReadUDPASListenerThread`,
|
||||
`TestReadUDPUCXListenerThread`, `UDPAirScoutSendMessages`, `testTimeGeneration`,
|
||||
`TestApplicationFileUtils`, `TestDBController` und `TestLocatorUtils` enthalten keine einzige
|
||||
Assertion. Fünf davon tragen `@Test` an einer `public static void main`, die Jupiter nie
|
||||
ausführt; die anderen drei haben gar keine Annotation. Manche schreiben beim manuellen Start die
|
||||
echte Preferences-Datei, senden echten UDP-Broadcast oder öffnen die echte Nutzerdatenbank.
|
||||
Fünf weitere `main()`-Programme derselben Art liegen unter `src/main`
|
||||
(`ReadUDPByWintestThreadTest`, `PatternMatcherTest`, `mapTest`, `RegexTester`,
|
||||
`locatorUtils/TestLocatorUtils`), dazu `MockKstServer`, auf den nur noch ein ungenutzter Import
|
||||
in `ChatController` verweist.
|
||||
|
||||
**Es gibt keine Abdeckungszahl.** Die Suite ist durch die Compose-Migration auf mehrere hundert
|
||||
Tests gewachsen, aber niemand kann sagen, welchen Teil von `core` sie erreicht. Drei Bereiche
|
||||
sind bekannt dünn: der XML-Roundtrip von `ChatPreferences` jenseits der Handvoll Felder, die
|
||||
Einzeltests festnageln, die Schema-Migration in `DBController`, und die Netzwerk-Threads
|
||||
(`ReadThread`/`WriteThread`, die UDP-Listener), die bestenfalls je einen Loopback-Test haben.
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
**Tote Tests löschen, nicht retten.** Ein Test ohne Assertion dokumentiert nichts, und die mit
|
||||
Seiteneffekten sind eine Gefahr, sobald jemand „alle Tests“ aus der IDE startet. Ihre
|
||||
Gegenstände sind entweder anderswo abgedeckt (`ChatPreferences*Test`,
|
||||
`DBControllerProfileDatabaseTest`, `DirectionUtilsConsoleOutputTest`) oder werden zu den zwei
|
||||
neuen Testbereichen unten. Der auskommentierte `MessageBusManagementThreadFrequencyContextTest`
|
||||
wird ebenfalls gelöscht; was er abgedeckt hätte
|
||||
(`MessageBusManagementThread.hasExplicitBareFrequencyContext` und `Band.fromPrefix`), ist im
|
||||
Umsetzungsbericht vermerkt, damit es mit der Frequenz-Parsing-Arbeit aufgegriffen und nicht
|
||||
stillschweigend verloren wird.
|
||||
|
||||
**Tests liegen ausschließlich unter `src/test`.** Die sechs lebenden JUnit-Klassen wandern nach
|
||||
`src/test` des Moduls, dem die getestete Klasse gehört, mit unveränderten Paketnamen, neben die
|
||||
Geschwistertests, die dort schon liegen (`FrequencyTextParserRegressionTest`,
|
||||
`ChatPreferencesStationMapClusteringTest`). Die drei Klassen aus `app-desktop` testen
|
||||
ausschließlich `core`-Klassen und ziehen deshalb nach `core`. Ohne Testklasse unter `src/main`
|
||||
entfallen die beiden `implementation`-Deklarationen und die doppelten Katalogeinträge
|
||||
(`junit-jupiter-api`, `mockito-core-compile`, Version `mockitoCompile`). JUnit und Mockito sind
|
||||
nur noch `testImplementation`, was die vorhandenen `testImplementation`-Zeilen ohnehin schon
|
||||
sagten. Nachgewiesen über `:app-desktop:dependencies --configuration runtimeClasspath` ohne die
|
||||
sieben Artefakte und über die Jar-Liste von `createDistributable`.
|
||||
|
||||
**JaCoCo nur als Bericht, ohne Schwellwert.** Das `jacoco`-Plugin wird aus dem Root-Build auf
|
||||
jedes Modul angewandt, `jacocoTestReport` läuft nach `test` und schreibt XML und HTML je Modul,
|
||||
und ein Root-Task `jacocoAggregatedReport` führt `core` und `app-desktop` zu einem Bericht unter
|
||||
`build/reports/jacoco/aggregated/` zusammen. `i18n-generator` bleibt außen vor: Es ist ein
|
||||
Bauwerkzeug, seine Abdeckung sagt nichts über die ausgelieferte Anwendung. Es gibt bewusst keine
|
||||
`jacocoTestCoverageVerification` und keinen Mindestprozentsatz. Ein heute gesetzter Schwellwert
|
||||
stünde bei der Zahl, die zufällig gerade herauskommt, und wäre damit ein Stolperdraht für die
|
||||
nächste unabhängige Änderung statt einer Aussage über Qualität; ein Schwellwert auf Zielniveau
|
||||
ließe jeden Build scheitern, bis jemand Tests um der Zahl willen schreibt. Der Bericht existiert,
|
||||
damit die Zahl in jedem Pull Request sichtbar ist und bei der Entscheidung hilft, was als
|
||||
Nächstes zu testen ist. Sie zu heben ist Sache der unten gelisteten Issues, nicht der
|
||||
Build-Konfiguration.
|
||||
|
||||
**Tests laufen vor dem Packaging.** Der Nightly- und der Release-Workflow bauen sieben
|
||||
Paketformate aus einem Commit, den unterwegs niemand getestet hat: `pr-compile-check.yml` führt
|
||||
die Suite nur für Pull Requests aus, und `main` kann direkte Pushes erhalten. Beide Workflows
|
||||
bekommen eine Kopie des `test`-Jobs aus `pr-compile-check.yml`, einschließlich des
|
||||
`libgl1`-Schritts und der XML-basierten Zusammenfassung, und jeder Packaging-Job deklariert
|
||||
`needs: test`. Der Job wird dupliziert statt in einen wiederverwendbaren Workflow ausgelagert:
|
||||
Drei Kopien von vierzig Zeilen lesen sich leichter als eine Indirektion, und die Kommentare im
|
||||
Job erklären Entscheidungen, die die Packaging-Workflows bei sich tragen sollen. Der
|
||||
PR-Workflow führt zusätzlich `jacocoAggregatedReport` aus, schreibt aus dem JaCoCo-XML eine
|
||||
Abdeckungstabelle in die Job-Zusammenfassung und lädt den aggregierten HTML-Bericht für
|
||||
vierzehn Tage als Artefakt `coverage-report` hoch.
|
||||
|
||||
**Zwei neue Testbereiche entstehen im selben Zweig, aber in eigenen Commits.** Issue #62 nennt
|
||||
den `ChatPreferences`-Roundtrip und die `DBController`-Schema-Migration als die zwei
|
||||
ungetesteten Risikobereiche, die sich jetzt schließen lassen. Es sind gewöhnliche
|
||||
Testergänzungen, die vom Aufräumen nicht abhängen; sie landen nach dem Aufräumen, damit ihre
|
||||
Wirkung im aggregierten Bericht sichtbar wird.
|
||||
|
||||
## Bewusst außerhalb des Umfangs
|
||||
|
||||
Jeder dieser Punkte ist eine echte Lücke, die unterwegs gefunden wurde, und jeder wird ein
|
||||
eigenes Issue, weil jeder eine Entscheidung braucht, die dieses Aufräumen nicht nebenbei
|
||||
treffen sollte:
|
||||
|
||||
- **Refactoring von `MessageBusManagementThread` für Testbarkeit.** Frequenzextraktion,
|
||||
Worked-Prüfung und der DX-Cluster-Spot-Pfad sind heute nur über einen konstruierten Thread
|
||||
mit gemockten Mitspielern erreichbar. Sie in testbare Einheiten zu teilen berührt die
|
||||
Nachrichten-Pipeline und braucht ein eigenes Konzept.
|
||||
- **Loopback-Tests für die Netzwerk-Threads.** `On4KstSocketThreadTest` deckt `ReadThread`
|
||||
und `WriteThread` ab; die UDP-Listener für Win-Test, UCXLog und AirScout und der
|
||||
PSTRotator-Client haben kein Gegenstück. Ein Loopback-Harness mit Port-0-Socket je Protokoll
|
||||
ist die richtige Form, aber das Framing jedes Protokolls verdient eine Prüfung, bevor ein Test
|
||||
es festnagelt.
|
||||
- **Compose-Screen-Tests.** Die State-Tests und die zwei Settings-Abdeckungsnetze prüfen, dass
|
||||
Setter aufgerufen werden; nur `runComposeUiTest` kann prüfen, dass ein Wert den Bildschirm
|
||||
erreicht hat. Welche Screens diesen Aufwand wert sind, ist eine Oberflächenentscheidung.
|
||||
- **Pfadanalyse-Mathematik.** `PathPropagationAssessment` und die Terrainprofil-Berechnung
|
||||
haben Layout-Tests, aber keine numerischen Referenzfälle. Referenzwerte brauchen eine Quelle,
|
||||
die sich zitieren lässt.
|
||||
- **Entfernen von `Thread.sleep`.** Mehrere Tests und einige Produktionspfade warten noch auf
|
||||
die Wanduhr. Sie durch injizierbare Uhren oder Latches zu ersetzen ist mechanisch, aber breit.
|
||||
|
||||
## Definition von „fertig“ für #62
|
||||
|
||||
- Unter keinem `src/main` bleibt eine mit `@Test` annotierte Klasse oder ein Import von
|
||||
`org.junit` oder `org.mockito`.
|
||||
- `./gradlew clean build` führt die verschobenen Tests aus; die Ergebnis-XML unter
|
||||
`*/build/test-results/test/` meldet null Fehlschläge und null Fehler.
|
||||
- `./gradlew :app-desktop:dependencies --configuration runtimeClasspath` listet keines von
|
||||
`junit`, `mockito`, `byte-buddy`, `objenesis`, `opentest4j`, und
|
||||
`app-desktop/build/compose/binaries/main/app/KST4Contest/lib/app` enthält kein Jar dieser
|
||||
Namen.
|
||||
- `./gradlew jacocoAggregatedReport` schreibt `build/reports/jacoco/aggregated/jacoco.xml` und
|
||||
einen HTML-Bericht; die PR-Job-Zusammenfassung zeigt eine Abdeckungstabelle und bietet das
|
||||
Artefakt `coverage-report` an.
|
||||
- `nightly-artifacts.yml` und `tagged-release.yml` führen den Test-Job aus, und kein
|
||||
Packaging-Job startet, bevor er bestanden ist.
|
||||
- `AGENTS.md` und `docs/PROJECT_CONTEXT.md` behaupten nicht mehr, Testfehler könnten den Build
|
||||
passieren; sie sagen, dass nur PMD und SpotBugs mit `ignoreFailures` laufen.
|
||||
- Die zwei neuen Testbereiche (`ChatPreferences`-Roundtrip, `DBController`-Schema-Migration)
|
||||
liegen im Zweig, und die Punkte außerhalb des Umfangs existieren als Issues.
|
||||
|
||||
## Was unverändert bleibt
|
||||
|
||||
- Testverhalten: Keine Assertion wird abgeschwächt und kein Test deaktiviert, damit der Umzug
|
||||
kompiliert.
|
||||
- Der Produktionscode, bis auf den entfernten `MockKstServer`-Import in `ChatController`.
|
||||
- PMD und SpotBugs behalten `ignoreFailures`; ihre Befunde werden wie bisher aus den Berichten
|
||||
gelesen.
|
||||
- Die Packaging-Jobs selbst: Es kommt nur `needs: test` hinzu.
|
||||
- Keine neue Laufzeitabhängigkeit. JaCoCo ist ein Gradle-Plugin, das die Test-JVM
|
||||
instrumentiert; nichts davon erreicht das Jar.
|
||||
@@ -42,6 +42,8 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un
|
||||
|
||||
- **AP-Spalte sortiert numerisch:** Die Spalte **AP [minutes / pot%]** in der Benutzerliste und bei den gerichteten Nachrichten wurde nach ihrem Text sortiert, sodass `13 (75%)` vor `3 (50%)` stand. Jetzt sortiert sie nach den Minuten des ersten Flugzeugs und bei gleichen Minuten nach dem besseren Potenzial; leere und `nil`-Zellen stehen aufsteigend am Ende. Damit ist [Issue #87](https://github.com/praktimarc/kst4contest/issues/87) behoben.
|
||||
- **Keine automatischen Cluster-Spots für gearbeitete Stationen:** Der lokale DX-Cluster-Server erzeugte Spots aus Richtungsgelegenheiten, ohne den Worked-Status zu prüfen. Jetzt wird kein Spot mehr erzeugt, wenn die Station auf dem Band der erkannten Frequenz bereits gearbeitet ist; ohne bandbezogene Information zählt der allgemeine Worked-Status. Manuelle Spots aus der Karte und der Test-Spot in den Einstellungen bleiben unverändert. Damit ist [Issue #91](https://github.com/praktimarc/kst4contest/issues/91) behoben.
|
||||
- **QSO-Monitoring übernimmt keine Fremdeinträge mehr:** Die `preferences.xml` enthielt neben der Liste der überwachten Rufzeichen eine zweite Liste `snifferWords`, die nirgends in der Oberfläche erschien. Beim Laden wurden ihre Einträge trotzdem als Rufzeichen in das QSO-Monitoring übernommen. Die tote Liste ist entfernt; ein vorhandenes `snifferWords`-Element in alten Dateien wird ignoriert. Damit ist [Issue #103](https://github.com/praktimarc/kst4contest/issues/103) behoben.
|
||||
- **Kein Locator für die zweite Kategorie mehr:** Intern gab es eine Einstellung für einen eigenen Locator der zweiten Chat-Kategorie, die weder in der Oberfläche noch in der `preferences.xml` erschien und deshalb immer auf ihrem eingebauten Standardwert stand. Beim Verbindungsaufbau wurde dieser Standardwert mit dem eigenen Locator verglichen und eine Warnung ausgegeben, dass der zweite Locator ignoriert werde. ON4KST verwendet ohnehin einen Locator pro TCP-Sitzung; die Einstellung und die Warnung sind entfernt. Damit ist [Issue #104](https://github.com/praktimarc/kst4contest/issues/104) behoben.
|
||||
|
||||
### Hinweise zur Aktualisierung
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ Several operators sharing one computer can now use their own callsigns, locators
|
||||
|
||||
- **AP column sorts numerically:** the **AP [minutes / pot%]** column in the user list and in the directed messages was sorted by its text, which put `13 (75%)` above `3 (50%)`. It now sorts by the minutes of the first aeroplane and, for equal minutes, by the better potential first; empty and `nil` cells go to the bottom in ascending order. This fixes [Issue #87](https://github.com/praktimarc/kst4contest/issues/87).
|
||||
- **No automatic cluster spots for worked stations:** the local DX Cluster server generated spots from directional opportunities without checking the worked state. A spot is no longer created when the station has already been worked on the band of the detected frequency; without per-band information the global worked state counts. Manual spots from the map and the test spot in the settings are unchanged. This fixes [Issue #91](https://github.com/praktimarc/kst4contest/issues/91).
|
||||
- **QSO monitoring no longer picks up foreign entries:** besides the list of monitored callsigns, `preferences.xml` carried a second list `snifferWords` that never appeared in the user interface. On loading, its entries were nevertheless added to QSO monitoring as callsigns. The dead list is removed; a `snifferWords` element in older files is ignored. This fixes [Issue #103](https://github.com/praktimarc/kst4contest/issues/103).
|
||||
- **No second-category locator any more:** internally there was a setting for a separate locator of the second chat category that appeared neither in the user interface nor in `preferences.xml`, so it always kept its compiled-in default. During login that default was compared with the station's locator and a warning was emitted that the second locator is ignored. ON4KST uses one locator per TCP session anyway; the setting and the warning are removed. This fixes [Issue #104](https://github.com/praktimarc/kst4contest/issues/104).
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
|
||||
@@ -3,20 +3,18 @@ sqliteJdbc = "3.53.4.0"
|
||||
jlayer = "1.0.1"
|
||||
junit = "6.1.3"
|
||||
mockito = "5.24.0"
|
||||
mockitoCompile = "5.24.0"
|
||||
jetbrainsAnnotations = "26.1.0"
|
||||
kotlin = "2.2.0"
|
||||
composeMultiplatform = "1.8.2"
|
||||
spotbugsPlugin = "6.5.12"
|
||||
spotbugs = "4.9.8"
|
||||
pmd = "7.17.0"
|
||||
jacoco = "0.8.13"
|
||||
|
||||
[libraries]
|
||||
sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqliteJdbc" }
|
||||
jlayer = { module = "javazoom:jlayer", version.ref = "jlayer" }
|
||||
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
|
||||
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }
|
||||
mockito-core-compile = { module = "org.mockito:mockito-core", version.ref = "mockitoCompile" }
|
||||
jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrainsAnnotations" }
|
||||
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
|
||||
mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" }
|
||||
|
||||
-1013
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user