Test suite cleanup, JaCoCo coverage report and tests before packaging (#62) (#112)

* 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:
Rsclub2_2andClaude Fable 5.1 authored and GitHub committed 2026-10-11 12:13:31 +02:00
1 parent 4a90ae7d30
commit 5491cbcf8b
42 files changed
+1205 -2025

No files matched your search

+101
View File
@@ -25,9 +25,104 @@ permissions:
packages: write packages: write
jobs: 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: build-windows-zip:
name: Build Windows ZIP name: Build Windows ZIP
runs-on: windows-latest runs-on: windows-latest
needs: test
steps: steps:
- name: Checkout - name: Checkout
@@ -80,6 +175,7 @@ jobs:
build-linux-appimage: build-linux-appimage:
name: Build Linux AppImage name: Build Linux AppImage
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
steps: steps:
- name: Checkout - name: Checkout
@@ -153,6 +249,7 @@ jobs:
build-linux-deb: build-linux-deb:
name: Build Debian package name: Build Debian package
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
steps: steps:
- name: Checkout - name: Checkout
@@ -198,6 +295,7 @@ jobs:
build-linux-rpm: build-linux-rpm:
name: Build Fedora package name: Build Fedora package
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
steps: steps:
- name: Checkout - name: Checkout
@@ -243,6 +341,7 @@ jobs:
build-linux-arch: build-linux-arch:
name: Build Arch Linux package name: Build Arch Linux package
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
steps: steps:
- name: Checkout - name: Checkout
@@ -354,6 +453,7 @@ jobs:
build-flatpak: build-flatpak:
name: Build Flatpak name: Build Flatpak
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
steps: steps:
- name: Checkout - name: Checkout
@@ -538,6 +638,7 @@ jobs:
build-macos-dmg: build-macos-dmg:
name: Build macOS DMG (${{ matrix.os }}) name: Build macOS DMG (${{ matrix.os }})
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
needs: test
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
+52
View File
@@ -141,6 +141,14 @@ jobs:
- name: Test - name: Test
run: ./gradlew -S 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 - name: Summarise the test results
if: always() if: always()
run: | run: |
@@ -173,6 +181,50 @@ jobs:
exit 1 exit 1
fi 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 - name: Keep the test reports on a failure
if: failure() if: failure()
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6
+101
View File
@@ -16,9 +16,104 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: 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: build-windows-zip:
name: Build Windows ZIP name: Build Windows ZIP
runs-on: windows-latest runs-on: windows-latest
needs: test
steps: steps:
- name: Checkout - name: Checkout
@@ -61,6 +156,7 @@ jobs:
build-linux-appimage: build-linux-appimage:
name: Build Linux AppImage name: Build Linux AppImage
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
steps: steps:
- name: Checkout - name: Checkout
@@ -127,6 +223,7 @@ jobs:
build-linux-deb: build-linux-deb:
name: Build Debian package name: Build Debian package
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
steps: steps:
- name: Checkout - name: Checkout
@@ -165,6 +262,7 @@ jobs:
build-linux-rpm: build-linux-rpm:
name: Build Fedora package name: Build Fedora package
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
steps: steps:
- name: Checkout - name: Checkout
@@ -203,6 +301,7 @@ jobs:
build-linux-arch: build-linux-arch:
name: Build Arch Linux package name: Build Arch Linux package
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
steps: steps:
- name: Checkout - name: Checkout
@@ -305,6 +404,7 @@ jobs:
build-flatpak: build-flatpak:
name: Build Flatpak name: Build Flatpak
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
steps: steps:
- name: Checkout - name: Checkout
@@ -430,6 +530,7 @@ jobs:
build-macos-dmg: build-macos-dmg:
name: Build macOS DMG (${{ matrix.os }}) name: Build macOS DMG (${{ matrix.os }})
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
needs: test
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
+3
View File
@@ -65,3 +65,6 @@ scratch/
# Kotlin incremental build data # Kotlin incremental build data
.kotlin/ .kotlin/
# JVM crash reports
hs_err_pid*.log
+2 -2
View File
@@ -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. 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 Gradle test summary;
- inspect the test-results XML under `*/build/test-results/` when needed; - 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. - 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. Run focused tests first, then normally the relevant broader test/build command for the scope.
+2 -4
View File
@@ -10,10 +10,8 @@ kotlin {
} }
dependencies { dependencies {
// Mirrors the compile-scope entries in pom.xml: a few test classes still live under // JUnit and Mockito are test-only. Until #62 a few test classes sat under src/main/java
// src/main/java and need JUnit and Mockito on the main compile classpath. // and dragged both -- plus byte-buddy and objenesis -- into lib/app of every app image.
implementation(libs.junit.jupiter.api)
implementation(libs.mockito.core.compile)
implementation(libs.jetbrains.annotations) implementation(libs.jetbrains.annotations)
implementation(project(":core")) implementation(project(":core"))
implementation(compose.desktop.currentOs) 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")));
}
}
+57
View File
@@ -1,5 +1,8 @@
plugins { plugins {
java 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 alias(libs.plugins.spotbugs) apply false
} }
@@ -17,11 +20,13 @@ allprojects {
val pmdVersion = libs.versions.pmd.get() val pmdVersion = libs.versions.pmd.get()
val spotbugsVersion = libs.versions.spotbugs.get() val spotbugsVersion = libs.versions.spotbugs.get()
val jacocoVersion = libs.versions.jacoco.get()
subprojects { subprojects {
apply(plugin = "java") apply(plugin = "java")
apply(plugin = "com.github.spotbugs") apply(plugin = "com.github.spotbugs")
apply(plugin = "pmd") apply(plugin = "pmd")
apply(plugin = "jacoco")
// Both tools report only; the Maven build had the failing goals commented out. // Both tools report only; the Maven build had the failing goals commented out.
extensions.configure<PmdExtension> { extensions.configure<PmdExtension> {
@@ -36,6 +41,17 @@ subprojects {
ignoreFailures.set(true) 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> { extensions.configure<JavaPluginExtension> {
toolchain { toolchain {
languageVersion.set(JavaLanguageVersion.of(21)) languageVersion.set(JavaLanguageVersion.of(21))
@@ -45,4 +61,45 @@ subprojects {
tasks.withType<Test>().configureEach { tasks.withType<Test>().configureEach {
useJUnitPlatform() 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"))
}
} }
+2 -4
View File
@@ -6,10 +6,8 @@ plugins {
// kst4contest.observe and hands UI work over via UiDispatcher. // kst4contest.observe and hands UI work over via UiDispatcher.
dependencies { dependencies {
// Test classes live under src/main/java in this project, so JUnit and Mockito // JUnit and Mockito are test-only. Until #62 a handful of test classes sat under
// are needed on the main compile classpath. This mirrored pom.xml before Gradle. // src/main/java and dragged both onto the runtime classpath of the shipped app.
implementation(libs.junit.jupiter.api)
implementation(libs.mockito.core.compile)
implementation(libs.jetbrains.annotations) implementation(libs.jetbrains.annotations)
implementation(libs.sqlite.jdbc) implementation(libs.sqlite.jdbc)
implementation(libs.jlayer) implementation(libs.jlayer)
@@ -22,7 +22,6 @@ import kst4contest.locatorUtils.DirectionUtils;
import kst4contest.logic.BandOpportunityResolver; import kst4contest.logic.BandOpportunityResolver;
import kst4contest.logic.PriorityCalculator; import kst4contest.logic.PriorityCalculator;
import kst4contest.model.*; import kst4contest.model.*;
import kst4contest.test.MockKstServer;
import kst4contest.utils.ApplicationFileUtils; import kst4contest.utils.ApplicationFileUtils;
import kst4contest.utils.PlayAudioUtils; import kst4contest.utils.PlayAudioUtils;
import kst4contest.locatorUtils.Location; import kst4contest.locatorUtils.Location;
@@ -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())); category, preferences.getStn_loginNameMainCat()));
} }
sendControl(session, On4KstProtocol.back(category)); 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) { 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_loginNameMainCat = "KST4Contest";
String stn_loginNameSecondCat = "KST4ContestSHF"; String stn_loginNameSecondCat = "KST4ContestSHF";
String stn_loginLocatorMainCat = "jn49fk"; String stn_loginLocatorMainCat = "jn49fk";
String stn_loginLocatorSecondCat = "jn49fk";
double stn_antennaBeamWidthDeg = 50; double stn_antennaBeamWidthDeg = 50;
double stn_maxQRBDefault = 900; double stn_maxQRBDefault = 900;
@@ -323,7 +322,6 @@ public class ChatPreferences {
lstNotify_QSOSniffer_sniffedCallSignList = lstNotify_QSOSniffer_sniffedCallSignList =
new SimpleRoster<>(); new SimpleRoster<>();
// SimpleRoster<String> 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<>(); SimpleRoster<String> lstNotify_QSOSniffer_sniffedPrefixLocList = new SimpleRoster<>();
// Scoring / interaction metrics // Scoring / interaction metrics
@@ -554,15 +552,6 @@ public class ChatPreferences {
return lstNotify_QSOSniffer_sniffedCallSignList; 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() { public SimpleRoster<String> getLstNotify_QSOSniffer_sniffedPrefixLocList() {
return lstNotify_QSOSniffer_sniffedPrefixLocList; return lstNotify_QSOSniffer_sniffedPrefixLocList;
} }
@@ -660,14 +649,6 @@ public class ChatPreferences {
this.logsynch_wintestUsePassQrg = logsynch_wintestUsePassQrg; 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() { public boolean isBcn_beaconsEnabledSecondCat() {
return bcn_beaconsEnabledSecondCat; return bcn_beaconsEnabledSecondCat;
} }
@@ -1962,15 +1943,6 @@ public class ChatPreferences {
/** /**
* QSO Sniffer Lists * 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 = Element snifferCallSigns =
doc.createElement("snifferCallSigns"); doc.createElement("snifferCallSigns");
rootElement.appendChild(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"); list = doc.getElementsByTagName("snifferPrefixes");
if (list != null && list.getLength() != 0) { if (list != null && list.getLength() != 0) {
lstNotify_QSOSniffer_sniffedPrefixLocList.clear(); 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;
}
}
@@ -1,7 +1,5 @@
package kst4contest.controller; 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.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -31,7 +31,7 @@ class ChatPreferencesObservableTest {
void theListSettingsStartEmpty() { void theListSettingsStartEmpty() {
ChatPreferences prefs = new ChatPreferences(); 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.getLstNotify_QSOSniffer_sniffedPrefixLocList().snapshot().isEmpty());
assertTrue(prefs.getLst_txtSnipList().snapshot().isEmpty()); assertTrue(prefs.getLst_txtSnipList().snapshot().isEmpty());
assertTrue(prefs.getLst_txtShortCutBtnList().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");
}
}
@@ -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);
}
}
}
@@ -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"));
}
}
+4 -2
View File
@@ -199,8 +199,10 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
- Use the repository Gradle wrapper (`.\gradlew.bat` on Windows). - 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. - 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. - 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.
- Build/test configuration has historically allowed some test/static-analysis failures not to fail the process exit code. Always read actual summaries/reports. - 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 ## 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.
+2
View File
@@ -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. - **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. - **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 ### Hinweise zur Aktualisierung
+2
View File
@@ -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). - **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). - **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 ### Upgrade notes
+1 -3
View File
@@ -3,20 +3,18 @@ sqliteJdbc = "3.53.4.0"
jlayer = "1.0.1" jlayer = "1.0.1"
junit = "6.1.3" junit = "6.1.3"
mockito = "5.24.0" mockito = "5.24.0"
mockitoCompile = "5.24.0"
jetbrainsAnnotations = "26.1.0" jetbrainsAnnotations = "26.1.0"
kotlin = "2.2.0" kotlin = "2.2.0"
composeMultiplatform = "1.8.2" composeMultiplatform = "1.8.2"
spotbugsPlugin = "6.5.12" spotbugsPlugin = "6.5.12"
spotbugs = "4.9.8" spotbugs = "4.9.8"
pmd = "7.17.0" pmd = "7.17.0"
jacoco = "0.8.13"
[libraries] [libraries]
sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqliteJdbc" } sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqliteJdbc" }
jlayer = { module = "javazoom:jlayer", version.ref = "jlayer" } jlayer = { module = "javazoom:jlayer", version.ref = "jlayer" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } 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" } jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrainsAnnotations" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" } mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" }
-1013
View File
File diff suppressed because it is too large. Load diff