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

@@ -22,7 +22,6 @@ import kst4contest.locatorUtils.DirectionUtils;
import kst4contest.logic.BandOpportunityResolver;
import kst4contest.logic.PriorityCalculator;
import kst4contest.model.*;
import kst4contest.test.MockKstServer;
import kst4contest.utils.ApplicationFileUtils;
import kst4contest.utils.PlayAudioUtils;
import kst4contest.locatorUtils.Location;
@@ -1,81 +0,0 @@
//package kst4contest.controller;
//
//import kst4contest.model.Band;
//import org.junit.jupiter.api.Test;
//import org.junit.jupiter.params.ParameterizedTest;
//import org.junit.jupiter.params.provider.ValueSource;
//
//import java.util.regex.Matcher;
//import java.util.regex.Pattern;
//
//import static org.junit.jupiter.api.Assertions.assertEquals;
//import static org.junit.jupiter.api.Assertions.assertFalse;
//import static org.junit.jupiter.api.Assertions.assertNotNull;
//import static org.junit.jupiter.api.Assertions.assertNull;
//import static org.junit.jupiter.api.Assertions.assertTrue;
//
//class MessageBusManagementThreadFrequencyContextTest {
//
// private static final Pattern THREE_DIGIT_VALUE =
// Pattern.compile("\\b\\d{3}\\b");
//
// @ParameterizedTest
// @ValueSource(strings = {
// "qrg 210",
// "QRG: 210",
// "freq is 210",
// "frequency = 210",
// "on 210",
// "210 MHz",
// "210 qrg"
// })
// void acceptsBareThreeDigitValueWithFrequencyContext(String messageText) {
// Matcher matcher = findThreeDigitValue(messageText);
//
// assertTrue(
// MessageBusManagementThread
// .hasExplicitBareFrequencyContext(
// messageText,
// matcher.start(),
// matcher.end()
// )
// );
// }
//
// @ParameterizedTest
// @ValueSource(strings = {
// "599",
// "144",
// "serial 210",
// "score 210",
// "worked 210 stations"
// })
// void rejectsBareThreeDigitValueWithoutFrequencyContext(String messageText) {
// Matcher matcher = findThreeDigitValue(messageText);
//
// assertFalse(
// MessageBusManagementThread
// .hasExplicitBareFrequencyContext(
// messageText,
// matcher.start(),
// matcher.end()
// )
// );
// }
//
// @Test
// void resolvesOnlySupportedFallbackPrefixes() {
// assertEquals(Band.B_144, Band.fromPrefix("144"));
// assertEquals(Band.B_432, Band.fromPrefix(" 432 "));
// assertEquals(Band.B_10G, Band.fromPrefix("10368"));
// assertNull(Band.fromPrefix("999"));
// assertNull(Band.fromPrefix(null));
// }
//
// private Matcher findThreeDigitValue(String messageText) {
// Matcher matcher = THREE_DIGIT_VALUE.matcher(messageText);
// assertTrue(matcher.find(), "Test message must contain a three-digit value");
// assertNotNull(matcher.group());
// return matcher;
// }
//}
@@ -509,16 +509,6 @@ final class On4KstConnectionManager {
category, preferences.getStn_loginNameMainCat()));
}
sendControl(session, On4KstProtocol.back(category));
String secondLocator = preferences.getStn_loginLocatorSecondCat();
String mainLocator = preferences.getStn_loginLocatorMainCat();
if (preferences.isLoginToSecondChatEnabled()
&& secondLocator != null && !secondLocator.isBlank()
&& !secondLocator.equalsIgnoreCase(mainLocator)) {
controller.onOn4KstConnectionWarning(
"ON4KST uses one locator per TCP session. The second-chat locator '"
+ secondLocator + "' is ignored; using '" + mainLocator + "'.");
}
}
private void configureSecondChat(Session session) {
@@ -1,18 +0,0 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
public class ReadUDPByWintestThreadTest {
public static void main(String[] args) {
ChatController ctrl1 = new ChatController();
ChatPreferences prefs = new ChatPreferences();
ctrl1.setChatPreferences(prefs);
// ReadUDPByWintestThread test = new ReadUDPByWintestThread(ctrl1);
// test.run();
}
}
@@ -1,86 +0,0 @@
package kst4contest.controller;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import kst4contest.model.ChatMember;
public class RegexTester {
public static void main(String[] args) {
String temp;
temp = "DL1KDA JO30EP Alex\r\n"
+ "DL3LST JO61FI Ray(Rainer)\r\n"
+ "DL4ZAQ JN49MP Peter 2m&70cm&6m\r\n"
+ "DL9LBH JN59ID Hans 2/70/23\r\n"
+ "DO5AMF JN49FK Marc\r\n"
+ "F5ICN JN03BF Alex QRV 2/70/23\r\n"
+ "F6GRA JN04DB Carol 2m\r\n"
+ "F6HTJ JN12KQ Michel\r\n"
+ "G0BIX JO01GI Terry\r\n"
+ "G0HOF IO92UL Ken 11el + 400 W\r\n"
+ "G0LBK JO03BD David\r\n"
+ "G0MBL JO01QH Andrew 2m\r\n"
+ "G1SDX IO80FL Grant\r\n"
+ "G3MXH JO02LF Terry\r\n"
+ "G3OVH IO92JP Tony\r\n"
+ "G3VCA IO93MG bob\r\n"
+ "G4AEP IO91MB bill\r\n"
+ "G4DHF IO92UU David\r\n"
+ "G4FUF JO01GN Keith\r\n"
+ "G4TRA IO81WN Steve\r\n"
+ "G6HKS IO92OB Richard\r\n"
+ "G8SEI IO92FO Jeff 6/4/2/70\r\n"
+ "GM0EWX IO67UL Calum\r\n"
+ "GW8IZR IO73TI Paul\r\n"
+ "IV3GTH JN65RU Gigi\r\n"
+ "IZ2XZM JN45KH Nick\r\n"
+ "LY1BWB KO24PR VU Club 2/70\r\n"
+ "LY2EN KO24PQ QRT\r\n"
+ "OK1FPR JO80CE Milos\r\n"
+ "OV3T JO46CM Thomas\r\n"
+ "OY4TN IP62NB Trygvi 11EL/350W\r\n"
+ "OZ2M JO65FR Bo PNGonVHF\r\n"
+ "OZ3Z JO45UN Anders @432.228\r\n"
+ "OZ7UV JO65DH Svend\r\n"
+ "PA3DOL JO22MT Sjoerd\r\n"
+ "PA3PCV JO20VV Marcel 2m\r\n"
+ "PA9R JO22JK Rob\r\n"
+ "S52FO JN76EF Janez\r\n"
+ "S53RM JN76HD Sine\r\n"
+ "SM2CEW KP15CR Peter\r\n"
+ "SP6VGJ JO81HU Jacek";
Pattern pattern = Pattern.compile("([a-zA-Z0-9]{2}/{1})?([a-zA-Z0-9]{1,3}[0-9][a-zA-Z0-9]{0,3}[a-zA-Z]{0,3})(/p)? [a-zA-Z]{2}[0-9]{2}[a-zA-Z]{2} [ -~]{1,20}");
Matcher matcher = pattern.matcher(temp);
/**
* "([a-zA-Z0-9]{1,2}\/)?[a-zA-Z0-9]{1,3}[0-9][a-zA-Z0-9]{0,3}[a-zA-Z](\/(p|m))?( )[a-zA-Z]{2}[0-9]{2}[a-zA-Z]{2}[ -~]{0,30}"gm
* Thats a line of the show users list
*/
while (matcher.find()) {
// System.out.println("Chatmember detected: "+ matcher.group() + " " + matcher.start());
ChatMember member = new ChatMember();
String matchedString = matcher.group();
String[] splittedUserString;
splittedUserString = matchedString.split(" ");
member.setCallSign(splittedUserString[0]);
member.setQra(splittedUserString[1]);
String stringAggregation = "";
for (int i = 2; i < splittedUserString.length; i++) {
stringAggregation += splittedUserString[i] + " ";
}
member.setName(stringAggregation);
System.out.println("Call: " + member.getCallSign() + ", QRA: " + member.getQra() + ", Name: " + member.getName());
}
}
}
@@ -1,43 +0,0 @@
package kst4contest.locatorUtils;
public class TestLocatorUtils {
public static void main(String[] args) {
// isInAngle(myLocation, location1, location2);
System.out.println(isInAngleAndRange("JN49FL", "jo43xm", "jo30sa", 900, 50));
System.out.println(isInAngleAndRange("JN49FL", "jo51ij", "jn39oc", 900, 50));
System.out.println(isInAngleAndRange("JN49FL", "jn39oc", "jo51ij", 1100, 50));
}
public static boolean isInAngleAndRange(String myLocator, String locatorOfSkedSender, String locatorOfSekdReceiver, double maxRangeKm, double hisAntennaBeamWidth) {
Location myLocation = new Location(myLocator);
Location skedSenderLocation = new Location(locatorOfSkedSender);
Location skedReceiverLocation = new Location(locatorOfSekdReceiver);
double distanceFromMeToLocSender = new Location(myLocator).getDistanceKm(new Location(locatorOfSkedSender));
// Check if distance exceeds my setted maximum range
if (distanceFromMeToLocSender > maxRangeKm) {
System.out.println("too far, " + distanceFromMeToLocSender + " km");
return false;
}
//check bearing of sender to receiver
double bearingOfSekdSenderToSkedReceiver = skedSenderLocation.getBearing(skedReceiverLocation);
System.out.println("skedTX -> skedRX deg: " + bearingOfSekdSenderToSkedReceiver);
double bearingOfSekdSenderToMe = skedSenderLocation.getBearing(myLocation);
System.out.println("skedTX -> me deg: " + bearingOfSekdSenderToMe);
if (DirectionUtils.isAngleInRange(bearingOfSekdSenderToSkedReceiver, bearingOfSekdSenderToMe, hisAntennaBeamWidth)) {
//may I should get "/2" because of 50% of the 3dB opening angle if txer is directed to sender exactly
return true;
} else return false;
}
}
@@ -218,7 +218,6 @@ public class ChatPreferences {
String stn_loginNameMainCat = "KST4Contest";
String stn_loginNameSecondCat = "KST4ContestSHF";
String stn_loginLocatorMainCat = "jn49fk";
String stn_loginLocatorSecondCat = "jn49fk";
double stn_antennaBeamWidthDeg = 50;
double stn_maxQRBDefault = 900;
@@ -323,7 +322,6 @@ public class ChatPreferences {
lstNotify_QSOSniffer_sniffedCallSignList =
new SimpleRoster<>();
// SimpleRoster<String> lstNotify_QSOSniffer_sniffedCallSignList = new SimpleRoster<>();
SimpleRoster<String> lstNotify_QSOSniffer_sniffedWordsList = new SimpleRoster<>();
SimpleRoster<String> lstNotify_QSOSniffer_sniffedPrefixLocList = new SimpleRoster<>();
// Scoring / interaction metrics
@@ -554,15 +552,6 @@ public class ChatPreferences {
return lstNotify_QSOSniffer_sniffedCallSignList;
}
public SimpleRoster<String> getLstNotify_QSOSniffer_sniffedWordsList() {
return lstNotify_QSOSniffer_sniffedWordsList;
}
public void setLstNotify_QSOSniffer_sniffedWordsList(java.util.Collection<String> lstNotify_QSOSniffer_sniffedWordsList) {
// Fill the existing roster: replacing the field would orphan its listeners.
this.lstNotify_QSOSniffer_sniffedWordsList.setAll(lstNotify_QSOSniffer_sniffedWordsList);
}
public SimpleRoster<String> getLstNotify_QSOSniffer_sniffedPrefixLocList() {
return lstNotify_QSOSniffer_sniffedPrefixLocList;
}
@@ -660,14 +649,6 @@ public class ChatPreferences {
this.logsynch_wintestUsePassQrg = logsynch_wintestUsePassQrg;
}
public String getStn_loginLocatorSecondCat() {
return stn_loginLocatorSecondCat;
}
public void setStn_loginLocatorSecondCat(String stn_loginLocatorSecondCat) {
this.stn_loginLocatorSecondCat = stn_loginLocatorSecondCat;
}
public boolean isBcn_beaconsEnabledSecondCat() {
return bcn_beaconsEnabledSecondCat;
}
@@ -1962,15 +1943,6 @@ public class ChatPreferences {
/**
* QSO Sniffer Lists
*/
Element snifferWords = doc.createElement("snifferWords");
rootElement.appendChild(snifferWords);
for (String word : lstNotify_QSOSniffer_sniffedWordsList.snapshot()) {
Element temp = doc.createElement("w");
temp.setTextContent(word);
snifferWords.appendChild(temp);
}
Element snifferCallSigns =
doc.createElement("snifferCallSigns");
rootElement.appendChild(snifferCallSigns);
@@ -2935,47 +2907,6 @@ public class ChatPreferences {
}
}
list = doc.getElementsByTagName("snifferWords");
if (list != null && list.getLength() != 0) {
// reset to avoid duplicates when reloading
lstNotify_QSOSniffer_sniffedWordsList.clear();
for (int temp = 0; temp < list.getLength(); temp++) {
Node node = list.item(temp);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
NodeList children = element.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node child = children.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE) {
String callSign = child.getTextContent();
if (callSign != null
&& !callSign.isBlank()) {
/*
* Older preference files may contain complete KST callsigns
* such as DN9APW-2. QSO monitoring is stored by base callsign,
* so one entry covers every suffix of the same station.
*/
String normalizedCallSign =
ChatMember.normalizeCallSignToBaseCallSign(
callSign
);
if (normalizedCallSign != null
&& !normalizedCallSign.isBlank()
&& !lstNotify_QSOSniffer_sniffedCallSignList.snapshot()
.contains(normalizedCallSign)) {
lstNotify_QSOSniffer_sniffedCallSignList
.add(normalizedCallSign);
}
}
}
}
}
}
}
list = doc.getElementsByTagName("snifferPrefixes");
if (list != null && list.getLength() != 0) {
lstNotify_QSOSniffer_sniffedPrefixLocList.clear();
@@ -1,256 +0,0 @@
package kst4contest.test;
import java.io.*;
import java.net.*;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
public class MockKstServer {
private static final int PORT = 23001;
private static final String CHAT_ID = "2"; // 2 = 144/432 MHz
// Thread-sichere Liste aller verbundenen Clients (OutputStreams)
private final List<PrintWriter> clients = new CopyOnWriteArrayList<>();
// Permanente User (Ihre Test-Callsigns)
private final Map<String, User> onlineUsers = new HashMap<>();
// Historien müssen synchronisiert werden
private final List<String> historyChat = Collections.synchronizedList(new ArrayList<>());
private final List<String> historyDx = Collections.synchronizedList(new ArrayList<>());
private boolean running = false;
private ServerSocket serverSocket;
public MockKstServer() {
// Initiale Permanente User
addUser("DK5EW", "Erwin", "JN47NX");
addUser("DL1TEST", "TestOp", "JO50XX");
addUser("ON4KST", "Alain", "JO20HI");
addUser("PA9R-2", "2", "JO20HI");
addUser("PA9R-70", "70", "JO20HI");
addUser("PA9R", "general", "JO20HI");
}
// Startet den Server im Hintergrund (Non-Blocking)
public void start() {
if (running) return;
running = true;
new Thread(() -> {
try {
serverSocket = new ServerSocket(PORT);
System.out.println("[Server] ON4KST Simulation gestartet auf Port " + PORT);
// Startet den Simulator für Zufallstraffic
new Thread(this::simulationLoop).start();
while (running) {
Socket clientSocket = serverSocket.accept();
System.out.println("[Server] Neuer Client verbunden: " + clientSocket.getInetAddress());
new Thread(new ClientHandler(clientSocket)).start();
}
} catch (IOException e) {
if (running) e.printStackTrace();
}
}).start();
}
public void stop() {
running = false;
try {
if (serverSocket != null) serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
private void addUser(String call, String name, String loc) {
onlineUsers.put(call, new User(call, name, loc));
}
private void removeUser(String call) {
onlineUsers.remove(call);
}
// Sendet Nachricht an ALLE verbundenen Clients (inkl. Sender)
private void broadcast(String message) {
if (!message.endsWith("\r\n")) message += "\r\n";
String finalMsg = message;
for (PrintWriter writer : clients) {
try {
writer.print(finalMsg);
writer.flush(); // WICHTIG: Sofort senden!
} catch (Exception e) {
// Client wohl weg, wird beim nächsten Schreibversuch oder im Handler entfernt
}
}
}
// --- Innere Logik: Client Handler ---
private class ClientHandler implements Runnable {
private Socket socket;
private PrintWriter out;
private BufferedReader in;
private String myCall = "MYCLIENT"; // Default, wird bei LOGIN überschrieben
public ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
// ISO-8859-1 ist Standard für KST/Telnet Cluster
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "ISO-8859-1"));
out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), "ISO-8859-1"), true);
clients.add(out);
String line;
boolean loginComplete = false;
while ((line = in.readLine()) != null) {
// System.out.println("[RECV] " + line); // Debugging aktivieren falls nötig
String[] parts = line.split("\\|");
String cmd = parts[0];
if (cmd.equals("LOGIN") || cmd.equals("LOGINC")) {
// Protokoll: LOGIN|callsign|password|... [cite: 21]
if (parts.length > 1) myCall = parts[1];
// 1. Login Bestätigung
// Format: LOGSTAT|100|chat id|client software version|session key|config|dx option|
send("LOGSTAT|100|" + CHAT_ID + "|JavaSim|KEY123|Config|3|");
// Bei LOGIN senden wir die Daten sofort
// Bei LOGINC warten wir eigentlich auf SDONE, senden hier aber vereinfacht direkt
if (cmd.equals("LOGIN")) {
sendInitialData();
loginComplete = true;
}
}
else if (cmd.equals("SDONE")) {
// Abschluss der Settings (bei LOGINC) [cite: 34]
sendInitialData();
loginComplete = true;
}
else if (cmd.equals("MSG")) {
// MSG|chat id|destination|command|0| [cite: 42]
if (parts.length >= 4) {
String text = parts[3];
// Nachricht sofort als CH Frame an alle verteilen (Echo)
handleChatMessage(myCall, "Me", text);
}
}
else if (cmd.equals("CK")) {
// Keepalive [cite: 20]
// Server muss nicht zwingend antworten, aber Connection bleibt offen
}
}
} catch (IOException e) {
// System.out.println("Client getrennt");
} finally {
clients.remove(out);
try { socket.close(); } catch (IOException e) {}
}
}
private void send(String msg) {
if (!msg.endsWith("\r\n")) msg += "\r\n";
out.print(msg);
out.flush();
}
private void sendInitialData() {
// 1. User Liste UA0 [cite: 14]
for (User u : onlineUsers.values()) {
send("UA0|" + CHAT_ID + "|" + u.call + "|" + u.name + "|" + u.loc + "|0|");
}
// 2. Chat History CR [cite: 7]
synchronized(historyChat) {
for (String h : historyChat) send(h);
}
// 3. DX History DL [cite: 10]
synchronized(historyDx) {
for (String d : historyDx) send(d);
}
// 4. Ende User Liste UE [cite: 15]
send("UE|" + CHAT_ID + "|" + onlineUsers.size() + "|");
}
}
// --- Hilfsmethoden für Traffic ---
private void handleChatMessage(String call, String name, String text) {
// CH|chat id|date|callsign|firstname|destination|msg|highlight|
String date = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
String frame = String.format("CH|%s|%s|%s|%s|0|%s|0|", CHAT_ID, date, call, name, text);
synchronized(historyChat) {
historyChat.add(frame);
if (historyChat.size() > 50) historyChat.remove(0);
}
broadcast(frame);
}
private void handleDxSpot(String spotter, String dx, String freq) {
// DL|Unix time|dx utc|spotter|qrg|dx|info|spotter locator|dx locator| [cite: 10]
long unixTime = System.currentTimeMillis() / 1000;
String utc = new SimpleDateFormat("HHmm").format(new Date());
// Simple Dummy Locators
String frame = String.format("DL|%d|%s|%s|%s|%s|Simulated|JO00|JO99|",
unixTime, utc, spotter, freq, dx);
synchronized(historyDx) {
historyDx.add(frame);
if (historyDx.size() > 20) historyDx.remove(0);
}
broadcast(frame);
}
private void simulationLoop() {
String[] randomCalls = {"PA0GUS", "F6APE", "OH8K", "OZ2M", "G4CBW"};
String[] msgs = {"CQ 144.300", "Tnx for QSO", "Any sked?", "QRV 432.200"};
Random rand = new Random();
while (running) {
try {
Thread.sleep(3000 + rand.nextInt(5000)); // 3-8 Sek Pause
int action = rand.nextInt(10);
String call = randomCalls[rand.nextInt(randomCalls.length)];
if (action < 4) { // 40% Chat
handleChatMessage(call, "SimOp", msgs[rand.nextInt(msgs.length)]);
} else if (action < 7) { // 30% DX Spot
handleDxSpot(call, randomCalls[rand.nextInt(randomCalls.length)], "144." + rand.nextInt(400));
} else if (action == 8) { // Login Simulation UA5
if (!onlineUsers.containsKey(call)) {
addUser(call, "SimOp", "JO11");
broadcast("UA5|" + CHAT_ID + "|" + call + "|SimOp|JO11|2|");
}
} else if (action == 9) { // Logout Simulation UR6
if (onlineUsers.containsKey(call) && !call.equals("DK5EW")) { // DK5EW nicht kicken
removeUser(call);
broadcast("UR6|" + CHAT_ID + "|" + call + "|");
}
}
// Ping ab und zu
if (rand.nextInt(5) == 0) broadcast("CK|");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
// Kleine Datenklasse
private static class User {
String call, name, loc;
User(String c, String n, String l) { this.call=c; this.name=n; this.loc=l; }
}
}
@@ -1,24 +0,0 @@
package kst4contest.view.map;
public class mapTest {
public static void main(String[] args) {
TerrainPackageService terrainPackageService = new TerrainPackageService();
TerrainPackageService.TerrainPreparationResult result =
terrainPackageService.prepareTerrainForLocators(
"JO51IJ",
"JO22JK",
"TEST",
144.300,
10.0,
10.0,
""
);
System.out.println(result.success());
System.out.println(result.message());
}
}
@@ -0,0 +1,332 @@
package kst4contest.controller;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatMember;
import kst4contest.utils.ApplicationFileUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Opens databases of older schema generations with a {@link DBController} and verifies that
* the controller migrates them to the current schema: all columns present, PRAGMA
* user_version at the current level, legacy callsign keys merged to base callsigns and the
* existing rows preserved. The fixtures are built with plain sqlite-jdbc so the test does not
* depend on the controller to create "old" files.
*/
class DBControllerSchemaMigrationTest {
private static final String USER_HOME_PROPERTY = "user.home";
/** Columns the current ChatMember CREATE TABLE statement in DBController declares. */
private static final List<String> CHAT_MEMBER_COLUMNS = List.of(
"callsign", "qra", "name", "lastActivityDateTime",
"worked", "worked144", "worked432", "worked1240", "worked2300", "worked3400",
"worked5600", "worked10G", "worked50", "worked70",
"notQRV144", "notQRV432", "notQRV1240", "notQRV2300", "notQRV3400", "notQRV5600",
"notQRV10G", "notQRV50", "notQRV70",
"lastFlagsChangeEpochMs");
/** Columns the current WorkedGrossField CREATE TABLE statement in DBController declares. */
private static final List<String> WORKED_GROSS_FIELD_COLUMNS = List.of(
"band", "grossField", "locator", "callsign", "source", "lastWorkedEpochMs");
/**
* ChatMember as created by release 1.1: no not-QRV columns, no timestamp, no 50/70 MHz.
*/
private static final String CREATE_CHAT_MEMBER_V11 =
"CREATE TABLE ChatMember ("
+ "callsign TEXT NOT NULL PRIMARY KEY, qra TEXT, name TEXT, "
+ "lastActivityDateTime TEXT, "
+ "worked BOOLEAN DEFAULT 0, worked144 BOOLEAN DEFAULT 0, "
+ "worked432 BOOLEAN DEFAULT 0, worked1240 BOOLEAN DEFAULT 0, "
+ "worked2300 BOOLEAN DEFAULT 0, worked3400 BOOLEAN DEFAULT 0, "
+ "worked5600 BOOLEAN DEFAULT 0, worked10G BOOLEAN DEFAULT 0);";
/**
* ChatMember as created by release 1.3: not-QRV columns and the expiry timestamp exist,
* the 50/70 MHz columns of release 1.4 do not.
*/
private static final String CREATE_CHAT_MEMBER_V13 =
"CREATE TABLE ChatMember ("
+ "callsign TEXT NOT NULL PRIMARY KEY, qra TEXT, name TEXT, "
+ "lastActivityDateTime TEXT, "
+ "worked BOOLEAN DEFAULT 0, worked144 BOOLEAN DEFAULT 0, "
+ "worked432 BOOLEAN DEFAULT 0, worked1240 BOOLEAN DEFAULT 0, "
+ "worked2300 BOOLEAN DEFAULT 0, worked3400 BOOLEAN DEFAULT 0, "
+ "worked5600 BOOLEAN DEFAULT 0, worked10G BOOLEAN DEFAULT 0, "
+ "notQRV144 BOOLEAN DEFAULT 0, notQRV432 BOOLEAN DEFAULT 0, "
+ "notQRV1240 BOOLEAN DEFAULT 0, notQRV2300 BOOLEAN DEFAULT 0, "
+ "notQRV3400 BOOLEAN DEFAULT 0, notQRV5600 BOOLEAN DEFAULT 0, "
+ "notQRV10G BOOLEAN DEFAULT 0, "
+ "lastFlagsChangeEpochMs INTEGER DEFAULT 0);";
@TempDir
Path temporaryHomeDirectory;
private String originalUserHome;
private DBController controller;
@BeforeEach
void redirectUserHomeToTemporaryDirectory() {
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
}
@AfterEach
void closeControllerAndRestoreUserHome() {
if (controller != null) {
controller.closeDBConnection();
controller = null;
}
if (originalUserHome == null) {
System.clearProperty(USER_HOME_PROPERTY);
} else {
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
}
}
@Test
void seededTemplateDatabaseIsMigratedToTheCurrentSchema() throws Exception {
controller = new DBController("praktiKST.db", true);
String databasePath = controller.getDatabaseFilePath();
assertEquals(currentSchemaVersion(), userVersion(databasePath));
assertColumns(databasePath, "ChatMember", CHAT_MEMBER_COLUMNS);
assertColumns(databasePath, "WorkedGrossField", WORKED_GROSS_FIELD_COLUMNS);
assertEquals(0, legacyCallsignRowCount(databasePath),
"the normalisation rebuild must leave no slash or dash keys behind");
Map<String, ChatMember> stations = controller.fetchChatMemberWkdDataFromDB();
// The bundled template carries 3452 foreign callsigns, 199 of them with a suffix or
// prefix; merging those to their base callsign shrinks the table but keeps the stations.
assertTrue(stations.size() > 3000 && stations.size() < 3452,
"unexpected station count after the rebuild: " + stations.size());
// "OE/HA5DDX" is the only row of this station in the template.
ChatMember ha5ddx = stations.get("HA5DDX");
assertNotNull(ha5ddx, "OE/HA5DDX must be stored under its base callsign");
assertEquals("JN77WM", ha5ddx.getQra());
assertEquals("Shani", ha5ddx.getName());
assertFalse(stations.containsKey("OE/HA5DDX"));
// "F6KFH" and "F6KFH-2" both exist in the template and collapse into one row.
assertTrue(stations.containsKey("F6KFH"));
assertFalse(stations.containsKey("F6KFH-2"));
}
@Test
void release11SchemaGainsAllColumnsAndMergesLegacyCallsignKeys() throws Exception {
String relativeName = "profiles/legacy11/praktiKST.db";
String databasePath = fixturePath(relativeName);
try (Connection raw = openRaw(databasePath); Statement statement = raw.createStatement()) {
statement.executeUpdate(CREATE_CHAT_MEMBER_V11);
statement.executeUpdate("INSERT INTO ChatMember (callsign, qra, name, worked144) "
+ "VALUES ('OK2M-70', 'JN69UN', 'Team', 1);");
statement.executeUpdate("INSERT INTO ChatMember (callsign, worked432) "
+ "VALUES ('OK2M', 1);");
statement.executeUpdate("INSERT INTO ChatMember (callsign, qra, name, worked) "
+ "VALUES ('EA5/G8MBI/P', 'IM98PA', 'Bob', 1);");
statement.executeUpdate("INSERT INTO ChatMember (callsign, qra, name) "
+ "VALUES ('DL1XYZ', 'JO31AA', 'Plain');");
}
assertEquals(0, userVersion(databasePath));
controller = new DBController(relativeName, false);
assertEquals(currentSchemaVersion(), userVersion(databasePath));
assertColumns(databasePath, "ChatMember", CHAT_MEMBER_COLUMNS);
assertColumns(databasePath, "WorkedGrossField", WORKED_GROSS_FIELD_COLUMNS);
assertEquals(0, legacyCallsignRowCount(databasePath));
Map<String, ChatMember> stations = controller.fetchChatMemberWkdDataFromDB();
assertEquals(Set.of("DL1XYZ", "G8MBI", "OK2M"), new TreeSet<>(stations.keySet()));
ChatMember ok2m = stations.get("OK2M");
assertTrue(ok2m.isWorked144(), "worked144 of OK2M-70 must survive the merge");
assertTrue(ok2m.isWorked432(), "worked432 of OK2M must survive the merge");
assertEquals("JN69UN", ok2m.getQra(), "the merge fills an empty locator from the other row");
assertEquals("Team", ok2m.getName());
assertTrue(ok2m.isQrv144() && ok2m.isQrv50() && ok2m.isQrv70(),
"columns added by the migration default to QRV");
ChatMember g8mbi = stations.get("G8MBI");
assertTrue(g8mbi.isWorked());
assertEquals("IM98PA", g8mbi.getQra());
ChatMember dl1xyz = stations.get("DL1XYZ");
assertFalse(dl1xyz.isWorked());
assertFalse(dl1xyz.isWorked144());
assertEquals("Plain", dl1xyz.getName());
}
@Test
void release13SchemaGainsBandColumnsAndKeepsRowsUntouched() throws Exception {
String relativeName = "profiles/legacy13/praktiKST.db";
String databasePath = fixturePath(relativeName);
long recentFlagsChange = System.currentTimeMillis() - 60_000L;
try (Connection raw = openRaw(databasePath); Statement statement = raw.createStatement()) {
statement.executeUpdate(CREATE_CHAT_MEMBER_V13);
statement.executeUpdate("INSERT INTO ChatMember (callsign, qra, name, "
+ "lastActivityDateTime, worked144, notQRV432, lastFlagsChangeEpochMs) "
+ "VALUES ('DL2ABC', 'JO62QL', 'Anna', 'Sun Aug 06 10:21:23 CEST 2023', 1, 1, "
+ recentFlagsChange + ");");
statement.executeUpdate("INSERT INTO ChatMember (callsign) VALUES ('DL3DEF');");
}
controller = new DBController(relativeName, false);
assertEquals(currentSchemaVersion(), userVersion(databasePath));
assertColumns(databasePath, "ChatMember", CHAT_MEMBER_COLUMNS);
Map<String, ChatMember> stations = controller.fetchChatMemberWkdDataFromDB();
assertEquals(Set.of("DL2ABC", "DL3DEF"), new TreeSet<>(stations.keySet()));
ChatMember dl2abc = stations.get("DL2ABC");
assertTrue(dl2abc.isWorked144());
assertFalse(dl2abc.isQrv432());
assertTrue(dl2abc.isQrv144());
assertFalse(dl2abc.isWorked50());
assertFalse(dl2abc.isWorked70());
assertTrue(dl2abc.isQrv50());
assertTrue(dl2abc.isQrv70());
assertEquals(recentFlagsChange, dl2abc.getLastFlagsChangeEpochMs(),
"a recent flags timestamp must not be reset by the expiry cleanup");
// Without legacy keys no rebuild happens, so even the column the controller never
// reads back stays as it was.
assertEquals("Sun Aug 06 10:21:23 CEST 2023",
scalar(databasePath, "SELECT lastActivityDateTime FROM ChatMember WHERE callsign = 'DL2ABC';"));
}
@Test
void reopeningAnUpToDateDatabaseChangesNothing() throws Exception {
String relativeName = "profiles/current/praktiKST.db";
controller = new DBController(relativeName, false);
String databasePath = controller.getDatabaseFilePath();
ChatMember worked = new ChatMember();
worked.setCallSign("DL0XYZ");
worked.setQra("JO51IJ");
worked.setWorked(true);
worked.setWorked144(true);
controller.storeChatMember(worked);
controller.closeDBConnection();
controller = null;
assertEquals(currentSchemaVersion(), userVersion(databasePath));
List<String> before = dump(databasePath);
controller = new DBController(relativeName, false);
assertTrue(controller.fetchChatMemberWkdDataFromDB().get("DL0XYZ").isWorked144());
controller.closeDBConnection();
controller = null;
assertEquals(before, dump(databasePath),
"schema, user_version and rows must be identical after reopening");
}
// ---------------------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------------------
private static int currentSchemaVersion() throws ReflectiveOperationException {
Field field = DBController.class.getDeclaredField("CURRENT_DATABASE_SCHEMA_VERSION");
field.setAccessible(true);
return field.getInt(null);
}
private static String fixturePath(String relativeName) throws Exception {
String path = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, relativeName);
Files.createDirectories(Path.of(path).getParent());
return path;
}
private static Connection openRaw(String databasePath) throws SQLException {
return DriverManager.getConnection("jdbc:sqlite:" + databasePath);
}
private static int userVersion(String databasePath) throws SQLException {
return Integer.parseInt(scalar(databasePath, "PRAGMA user_version;"));
}
private static String scalar(String databasePath, String sql) throws SQLException {
try (Connection raw = openRaw(databasePath);
Statement statement = raw.createStatement();
ResultSet resultSet = statement.executeQuery(sql)) {
assertTrue(resultSet.next(), "no row for " + sql);
return resultSet.getString(1);
}
}
private static int legacyCallsignRowCount(String databasePath) throws SQLException {
return Integer.parseInt(scalar(databasePath,
"SELECT count(*) FROM ChatMember WHERE callsign LIKE '%/%' OR callsign LIKE '%-%';"));
}
private static void assertColumns(String databasePath, String table, List<String> expected)
throws SQLException {
Set<String> actual = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
try (Connection raw = openRaw(databasePath);
Statement statement = raw.createStatement();
ResultSet resultSet = statement.executeQuery("PRAGMA table_info(" + table + ");")) {
while (resultSet.next()) {
actual.add(resultSet.getString("name"));
}
}
List<String> missing = new ArrayList<>();
for (String column : expected) {
if (!actual.contains(column)) {
missing.add(column);
}
}
assertEquals(List.of(), missing, "columns missing in " + table + "; present: " + actual);
}
/** Captures schema, user_version and every row, so two states can be compared. */
private static List<String> dump(String databasePath) throws SQLException {
List<String> lines = new ArrayList<>();
lines.add("user_version=" + userVersion(databasePath));
try (Connection raw = openRaw(databasePath); Statement statement = raw.createStatement()) {
try (ResultSet resultSet = statement.executeQuery(
"SELECT type, name, sql FROM sqlite_master ORDER BY type, name;")) {
while (resultSet.next()) {
lines.add(resultSet.getString(1) + " " + resultSet.getString(2) + " " + resultSet.getString(3));
}
}
for (String table : List.of("ChatMember", "WorkedGrossField")) {
try (ResultSet resultSet = statement.executeQuery("SELECT * FROM " + table + " ORDER BY 1;")) {
int columns = resultSet.getMetaData().getColumnCount();
while (resultSet.next()) {
StringBuilder row = new StringBuilder(table);
for (int i = 1; i <= columns; i++) {
row.append('|').append(resultSet.getString(i));
}
lines.add(row.toString());
}
}
}
}
return lines;
}
}
@@ -1,7 +1,5 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -31,7 +31,7 @@ class ChatPreferencesObservableTest {
void theListSettingsStartEmpty() {
ChatPreferences prefs = new ChatPreferences();
assertTrue(prefs.getLstNotify_QSOSniffer_sniffedWordsList().snapshot().isEmpty());
assertTrue(prefs.getLstNotify_QSOSniffer_sniffedCallSignList().snapshot().isEmpty());
assertTrue(prefs.getLstNotify_QSOSniffer_sniffedPrefixLocList().snapshot().isEmpty());
assertTrue(prefs.getLst_txtSnipList().snapshot().isEmpty());
assertTrue(prefs.getLst_txtShortCutBtnList().snapshot().isEmpty());
@@ -0,0 +1,335 @@
package kst4contest.model;
import kst4contest.observe.MutableValue;
import kst4contest.observe.ObservableRoster;
import kst4contest.observe.SimpleValue;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Drives every public single-argument setter of {@link ChatPreferences} to a value that
* differs from the compiled-in default, writes the preferences to XML, reads them into a
* fresh instance and compares every getter. The setters are discovered by reflection so a
* newly added preference is covered automatically; a field that is not persisted on purpose
* must be listed in {@link #NOT_PERSISTED_SETTERS} with a reason.
*/
class ChatPreferencesRoundTripTest {
/**
* Setters whose value is intentionally not written to the XML file. Each entry names the
* evidence in {@code ChatPreferences}. A field that is lost by accident must not be added
* here silently; a known persistence bug is listed with a "BUG:" prefix so the test stays
* green while the report stays loud.
*/
private static final Map<String, String> NOT_PERSISTED_SETTERS = Map.ofEntries(
Map.entry("setStoreAndRestorePreferencesFileName",
"Path of the XML file itself; set by the constructors, never a child "
+ "element of <praktiKST>"),
Map.entry("setChatState",
"Declared as 'working variable only for use by primarystage (title bar)'; "
+ "writePreferencesToXmlFile never emits it"),
Map.entry("setProgramVersion",
"Compiled-in about text; writePreferencesToXmlFile never emits it"),
Map.entry("setActualQTF",
"Runtime antenna direction ('will be updated by user at runtime!'); "
+ "writePreferencesToXmlFile never emits it"),
Map.entry("setMessageHandling_autoAnswerTextSecondCat",
"Written as <autoAnswerTextSecondCat>, but readPreferencesFromXmlFile "
+ "deliberately mirrors the main-category text over it because the UI "
+ "exposes one shared auto-answer setting (see the comment above "
+ "'messageHandling_autoAnswerTextSecondCat = "
+ "messageHandling_autoAnswerTextMainCat')"),
Map.entry("setMessageHandling_autoAnswerEnabledSecondCat",
"Same deliberate mirroring as the second-category auto-answer text; the "
+ "main-category flag wins after every read"),
Map.entry("setBcn_contestScoreSum",
"DEAD FIELD: declared with accessors only, no caller in core or app-desktop, "
+ "never written nor read")
);
/**
* Setters that are an alias for another setter and therefore have no getter of their own.
* The canonical setter is covered by the reflective loop.
*/
private static final Map<String, String> ALIAS_SETTERS = Map.ofEntries(
Map.entry("setLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup",
"Capital-S alias of setLogsynch_storeWorkedCallSignsFileNameUDPMessageBackup")
);
/**
* Setters whose argument is validated or parsed, so the generic "distinctive value per
* type" would be rejected. The value here is a valid, non-default one.
*/
private static final Map<String, Object> SPECIAL_VALUES = Map.ofEntries(
Map.entry("setGuiOptions_language", "de"),
Map.entry("setMYQRGFirstCat", "144.321.00"),
Map.entry("setMYQRGSecondCat", "432.321.00"),
Map.entry("setAirScout_asBandString", "4320000")
);
@TempDir
Path temporaryDirectory;
@Test
void everySetterSurvivesTheXmlRoundTrip() throws Exception {
Path preferencesFile = temporaryDirectory.resolve("roundtrip.xml");
ChatPreferences defaults = new ChatPreferences(preferencesFile);
ChatPreferences written = new ChatPreferences(preferencesFile);
List<Method> setters = settersUnderTest();
assertTrue(setters.size() > 100,
"Expected the reflective scan to find the ~130 setters, found " + setters.size());
List<String> unbuildable = new ArrayList<>();
List<String> withoutGetter = new ArrayList<>();
List<String> unchangedByValue = new ArrayList<>();
Map<Method, Method> getterBySetter = new LinkedHashMap<>();
for (Method setter : setters) {
Method getter = findGetter(setter);
if (getter == null) {
withoutGetter.add(setter.getName());
continue;
}
Object valueToSet = distinctiveValue(setter, getter.invoke(defaults));
if (valueToSet == null) {
unbuildable.add(setter.getName() + "(" + setter.getParameterTypes()[0].getSimpleName() + ")");
continue;
}
setter.invoke(written, valueToSet);
getterBySetter.put(setter, getter);
if (sameValue(getter.invoke(defaults), getter.invoke(written))) {
unchangedByValue.add(setter.getName() + " still reads " + describe(getter.invoke(written)));
}
}
assertEquals(List.of(), withoutGetter,
"Setters without a matching getter (add them to ALIAS_SETTERS with a reason)");
assertEquals(List.of(), unbuildable,
"Setters whose parameter type this test cannot construct");
assertEquals(List.of(), unchangedByValue,
"Setters whose distinctive value equals the default (the round trip could not "
+ "detect a loss for them)");
assertTrue(written.writePreferencesToXmlFile(), "writing " + preferencesFile + " failed");
ChatPreferences restored = new ChatPreferences(preferencesFile);
assertTrue(restored.readPreferencesFromXmlFile(), "reading " + preferencesFile + " failed");
List<String> lost = new ArrayList<>();
for (Map.Entry<Method, Method> entry : getterBySetter.entrySet()) {
Method getter = entry.getValue();
Object expected = getter.invoke(written);
Object actual = getter.invoke(restored);
if (!sameValue(expected, actual)) {
lost.add(entry.getKey().getName() + ": written=" + describe(expected)
+ " restored=" + describe(actual));
}
}
assertEquals(List.of(), lost,
"Fields that did not survive writePreferencesToXmlFile -> readPreferencesFromXmlFile");
}
@Test
void bundledTemplatePreferencesLoadWithKnownValues() throws IOException {
Path templateCopy = temporaryDirectory.resolve("template.xml");
try (InputStream template = ChatPreferences.class
.getResourceAsStream(ChatPreferences.PREFERENCE_RESOURCE)) {
assertNotNull(template, "bundled resource " + ChatPreferences.PREFERENCE_RESOURCE);
Files.copy(template, templateCopy, StandardCopyOption.REPLACE_EXISTING);
}
ChatPreferences fromTemplate = new ChatPreferences(templateCopy);
assertTrue(fromTemplate.readPreferencesFromXmlFile());
// Values the seeded template carries today; the station block uses the legacy tag names.
assertEquals("DO5AMF", fromTemplate.getStn_loginCallSign());
assertEquals("JO54ME", fromTemplate.getStn_loginLocatorMainCat());
assertEquals(2, fromTemplate.getLoginChatCategoryMain().getCategoryNumber());
assertEquals(3, fromTemplate.getLoginChatCategorySecond().getCategoryNumber());
assertTrue(fromTemplate.isLoginToSecondChatEnabled());
assertEquals(1333.0, fromTemplate.getStn_maxQRBDefault());
assertEquals(12060, fromTemplate.getLogsynch_ucxUDPWkdCallListenerPort());
assertEquals(8000, fromTemplate.getNotify_dxclusterServerPort());
assertEquals(9872, fromTemplate.getAirScout_asCommunicationPort());
assertTrue(fromTemplate.getLst_txtShortCutBtnList().snapshot().contains("sked"));
assertTrue(fromTemplate.isStn_bandActive144());
assertFalse(fromTemplate.isStn_bandActive10G());
}
@Test
void legacyFileWithoutConfigVersionMapsOldTagNamesOntoCurrentFields() throws IOException {
Path legacyFile = temporaryDirectory.resolve("legacy.xml");
Files.writeString(legacyFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<station>
<LoginCallSign>DL1ABC-2</LoginCallSign>
<LoginPassword>secret</LoginPassword>
<LoginDisplayedName>Old Name</LoginDisplayedName>
<LoginLocator>JO31AA</LoginLocator>
<ChatCategory>3</ChatCategory>
</station>
<notifications>
<notify_dxclusterServerPort>8123</notify_dxclusterServerPort>
</notifications>
</praktiKST>
""");
ChatPreferences legacy = new ChatPreferences(legacyFile);
assertTrue(legacy.readPreferencesFromXmlFile());
assertEquals("DL1ABC-2", legacy.getStn_loginCallSign());
assertEquals("secret", legacy.getStn_loginPassword());
assertEquals("Old Name", legacy.getStn_loginNameMainCat());
assertEquals("JO31AA", legacy.getStn_loginLocatorMainCat());
assertEquals(3, legacy.getLoginChatCategoryMain().getCategoryNumber());
assertEquals(8123, legacy.getNotify_dxclusterServerPort());
// A tag that is missing keeps the compiled-in default.
assertEquals(new ChatPreferences(legacyFile).getStn_on4kstServersPort(),
legacy.getStn_on4kstServersPort());
}
// ---------------------------------------------------------------------------------------
// reflection helpers
// ---------------------------------------------------------------------------------------
private static List<Method> settersUnderTest() {
List<Method> setters = new ArrayList<>();
for (Method method : ChatPreferences.class.getMethods()) {
if (method.getDeclaringClass() != ChatPreferences.class
|| Modifier.isStatic(method.getModifiers())
|| !method.getName().startsWith("set")
|| method.getParameterCount() != 1
|| NOT_PERSISTED_SETTERS.containsKey(method.getName())
|| ALIAS_SETTERS.containsKey(method.getName())) {
continue;
}
setters.add(method);
}
setters.sort(Comparator.comparing(Method::getName));
return setters;
}
private static Method findGetter(Method setter) {
String property = setter.getName().substring(3);
for (String prefix : new String[] {"get", "is"}) {
try {
return ChatPreferences.class.getMethod(prefix + property);
} catch (NoSuchMethodException ignored) {
// try the next prefix
}
}
return null;
}
/**
* Builds a value that differs from the current one. Returns null when the parameter type
* cannot be constructed here.
*/
private static Object distinctiveValue(Method setter, Object currentValue) {
Object special = SPECIAL_VALUES.get(setter.getName());
if (special != null) {
return special;
}
Class<?> type = setter.getParameterTypes()[0];
String name = setter.getName().substring(3);
Object current = unwrap(currentValue);
if (type == boolean.class) {
return !(current instanceof Boolean b && b);
}
if (type == int.class) {
return (current instanceof Integer i ? i : 0) + 7;
}
if (type == long.class) {
return (current instanceof Long l ? l : 0L) + 7L;
}
if (type == double.class) {
return (current instanceof Double d ? d : 0.0) + 7.5;
}
if (type == String.class) {
return "rt-" + name;
}
if (type == double[].class) {
double[] base = current instanceof double[] array && array.length > 0
? array : new double[] {0.0, 0.0};
double[] changed = new double[base.length];
for (int i = 0; i < base.length; i++) {
// Window positions default to NaN ("not yet placed"); NaN + 7 is still NaN.
changed[i] = Double.isNaN(base[i]) ? 100.0 + i : base[i] + 7.0 + i;
}
return changed;
}
if (Collection.class.isAssignableFrom(type)) {
return List.of("rt-" + name + "-a", "rt-" + name + "-b");
}
if (type == ChatCategory.class) {
int currentNumber = current instanceof Integer i ? i : 2;
return new ChatCategory(currentNumber == 5 ? 6 : 5);
}
if (type == MutableValue.class) {
return new SimpleValue<>(current instanceof Double d ? d + 7.5 : 7.5);
}
return null;
}
/** Reduces observable wrappers and domain objects to plain comparable values. */
private static Object unwrap(Object value) {
if (value instanceof MutableValue<?> mutable) {
return mutable.get();
}
if (value instanceof ObservableRoster<?> roster) {
return roster.snapshot();
}
if (value instanceof ChatCategory category) {
return category.getCategoryNumber();
}
return value;
}
private static boolean sameValue(Object left, Object right) {
Object a = unwrap(left);
Object b = unwrap(right);
if (a instanceof double[] x && b instanceof double[] y) {
return Arrays.equals(x, y);
}
if (a instanceof Double x && b instanceof Double y) {
return Math.abs(x - y) < 1e-9;
}
return Objects.equals(a, b);
}
private static String describe(Object value) {
Object plain = unwrap(value);
if (plain instanceof double[] array) {
return Arrays.toString(array);
}
return String.valueOf(plain);
}
}
@@ -0,0 +1,47 @@
package kst4contest.model;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Guards the QSO sniffer callsign list against the historical {@code <snifferWords>} block.
* Older preference files still carry that element; its entries must not end up in the
* monitored-callsign list (issue #103).
*/
class ChatPreferencesSnifferListTest {
@TempDir
Path temporaryDirectory;
@Test
void legacySnifferWordsDoNotLeakIntoTheCallsignList() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("sniffer.xml");
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<snifferWords>
<w>DL1ABC</w>
</snifferWords>
<snifferCallSigns>
<callSign>DN9APW-2</callSign>
</snifferCallSigns>
</praktiKST>
""");
ChatPreferences preferences = new ChatPreferences(preferencesFile);
assertTrue(preferences.readPreferencesFromXmlFile());
assertEquals(List.of("DN9APW"),
preferences.getLstNotify_QSOSniffer_sniffedCallSignList().snapshot(),
"only the <snifferCallSigns> entry, normalised to its base callsign, "
+ "may be monitored; <snifferWords> is an ignored legacy element");
}
}
@@ -0,0 +1,73 @@
package kst4contest.test;
import kst4contest.model.ChatPreferences;
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 static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatPreferencesStationMapVisibilityTest {
@TempDir
Path temporaryDirectory;
@Test
void pathAnalysisIsVisibleByDefault() {
ChatPreferences preferences = new ChatPreferences();
assertTrue(preferences.isGUIstationMapPathAnalysisVisible());
}
@Test
void hiddenPathAnalysisStateSurvivesXmlRoundTrip() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
ChatPreferences writtenPreferences = new ChatPreferences();
writtenPreferences.setStoreAndRestorePreferencesFileName(
preferencesFile.toString());
writtenPreferences.setGUIstationMapPathAnalysisVisible(false);
writtenPreferences.writePreferencesToXmlFile();
String writtenXml = Files.readString(preferencesFile);
assertTrue(writtenXml.contains(
"<GUIstationMapPathAnalysisVisible>false"
+ "</GUIstationMapPathAnalysisVisible>"));
ChatPreferences restoredPreferences = new ChatPreferences();
restoredPreferences.setStoreAndRestorePreferencesFileName(
preferencesFile.toString());
restoredPreferences.readPreferencesFromXmlFile();
assertFalse(restoredPreferences.isGUIstationMapPathAnalysisVisible());
}
@Test
void legacyXmlWithoutVisibilitySettingKeepsAnalysisDiscoverable()
throws IOException {
Path legacyPreferencesFile =
temporaryDirectory.resolve("legacy-preferences.xml");
Files.writeString(legacyPreferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>4</configVersion>
<guiOptions>
<GUIstationMapStageSceneSizeHW>1000.0;800.0</GUIstationMapStageSceneSizeHW>
</guiOptions>
</praktiKST>
""");
ChatPreferences restoredPreferences = new ChatPreferences();
restoredPreferences.setStoreAndRestorePreferencesFileName(
legacyPreferencesFile.toString());
restoredPreferences.readPreferencesFromXmlFile();
assertTrue(restoredPreferences.isGUIstationMapPathAnalysisVisible());
}
}
@@ -0,0 +1,123 @@
package kst4contest.test;
import kst4contest.logic.FrequencyTextParser;
import kst4contest.model.Band;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FrequencyTextParserTest {
@Test
void detectsExplicitStationNameFrequencies() {
List<FrequencyTextParser.DetectedFrequency> detected =
FrequencyTextParser.findExplicitFrequencies(
"Phil 432.357"
);
assertEquals(1, detected.size());
assertEquals(
Band.B_432,
detected.get(0).getBand()
);
assertEquals(
432.357,
detected.get(0).getFrequencyMHz(),
0.000_001
);
}
@Test
void detectsCommaAndMicrowaveFrequencies() {
List<FrequencyTextParser.DetectedFrequency> detected =
FrequencyTextParser.findExplicitFrequencies(
"QRV 1296,210 / 10368.100"
);
assertEquals(2, detected.size());
assertEquals(
Band.B_1296,
detected.get(0).getBand()
);
assertEquals(
Band.B_10G,
detected.get(1).getBand()
);
}
@Test
void detectsFiftyAndSeventyMhzFrequencies() {
assertEquals(
Band.B_50,
FrequencyTextParser
.findExplicitFrequencies("50.150")
.get(0)
.getBand()
);
assertEquals(
Band.B_70,
FrequencyTextParser
.findExplicitFrequencies("70.200")
.get(0)
.getBand()
);
}
@Test
void ignoresRelativeAndAmbiguousValues() {
assertTrue(
FrequencyTextParser
.findExplicitFrequencies(
"Mike .180"
)
.isEmpty()
);
assertTrue(
FrequencyTextParser
.findExplicitFrequencies(
"Mike 180"
)
.isEmpty()
);
assertTrue(
FrequencyTextParser
.findExplicitFrequencies(
"David 1.2"
)
.isEmpty()
);
assertTrue(
FrequencyTextParser
.findExplicitFrequencies(
"RST 599"
)
.isEmpty()
);
}
@Test
void normalizesSubKhzNotation() {
FrequencyTextParser.DetectedFrequency detected =
FrequencyTextParser
.findExplicitFrequencies(
"144.300.03"
)
.get(0);
assertEquals(Band.B_144, detected.getBand());
assertEquals(
144.30003,
detected.getFrequencyMHz(),
0.000_001
);
}
}
@@ -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);
}
}
}
@@ -0,0 +1,63 @@
package kst4contest.test;
import kst4contest.observe.SimpleValue;
import kst4contest.controller.Utils4KST;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class Utils4KSTFrequencyTest {
@Test
void convertsCompleteFrequenciesAcrossAllSupportedPrefixLengths() {
assertEquals("50200.0", convert("50.200", "144"));
assertEquals("70250.0", convert("70,250", "144"));
assertEquals("144205.0", convert("144.205", "432"));
assertEquals("432088.0", convert("432.088", "144"));
assertEquals("1296338.0", convert("1296.338", "144"));
assertEquals("10368100.0", convert("10368.100", "144"));
assertEquals("24048100.0", convert("24048.100", "144"));
}
@Test
void retainsSubKhzPrecision() {
assertEquals("144205.2", convert("144.205.2", "432"));
assertEquals("1296338.25", convert("1296.338.25", "144"));
}
@Test
void convertsCompactLegacyFormats() {
assertEquals("432088.0", convert("432088", "144"));
assertEquals("10368100.0", convert("10368100", "144"));
assertEquals("432088.2", convert("432088.2", "144"));
assertEquals("432088.0", convert("432 088", "144"));
}
@Test
void appliesFallbackOnlyToRelativeFrequencies() {
assertEquals("432205.0", convert(".205", "432"));
assertEquals("144205.0", convert("205", "144"));
assertEquals("10368300.0", convert(".300", "10368"));
}
@Test
void rejectsMissingOrUnsupportedValues() {
assertEquals("", convert(null, "144"));
assertEquals("", convert("", "144"));
assertEquals("", convert("not-a-frequency", "144"));
assertEquals("", convert("599", "144"));
assertEquals("", convert(".205", null));
assertEquals("", convert(".205", "invalid"));
}
private String convert(String frequency, String fallbackPrefix) {
SimpleValue<String> fallback = fallbackPrefix == null
? null
: new SimpleValue<>(fallbackPrefix);
return Utils4KST.normalizeFrequencyString(
frequency,
fallback
);
}
}
@@ -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"));
}
}