From 2db71ee86489df411097831265e2fa51d8922883 Mon Sep 17 00:00:00 2001 From: Philipp Wagner Date: Fri, 16 Oct 2026 10:19:22 +0200 Subject: [PATCH] Loopback tests for the network threads (#106) (#124) * test: pin the Win-Test sked frames over a loopback socket (#106) WinTestSkedSender is pointed at the loopback address instead of a broadcast address, and a socket on an ephemeral port receives the LOCKSKED/ADDSKED/UNLOCKSKED sequence. The frames and their checksum byte are written out literally, including the \260 degree escape and the SSB fallback for an unexpected mode ID. Co-Authored-By: Claude Opus 5.5 * test: cover the PSTRotator UDP client over loopback (#106) A socket on an ephemeral loopback port plays PSTRotator. The tests pin the poll queries, the command frames, the AZ:/EL:/MODE: replies reaching the listener, an unparsable reply leaving the loop alive, and the explicit-port constructor reporting its status after start() (#115). The reply format is derived from the parser and the PSTRotator UDP documentation, not from a capture. Co-Authored-By: Claude Opus 5.5 * test: cover the AirScout UDP listener over loopback (#106) ReadUDPbyAirScoutMessageThread gains a package-private constructor that takes an already bound socket, as ReadUDPbyUCXMessageThread has; the public constructor still binds the configured port in run(). The tests pin applying an ASNEAREST response to every active variant of the receiver, the case-sensitive server/client match with a UTF-8 client name, the disabled listener, the poison pill, recovery from a malformed response, and the date losing its space because the parser strips all spaces. Co-Authored-By: Claude Opus 5.5 * test: cover UCXLog/DXLog QSO packets over loopback (#106) The UCX loopback test now sends contactinfo and contactreplace packets: transport bytes before the XML are dropped, the QSO reaches the controller with band, locator and source, the gross field is registered, an unknown station is stored in the database and the UDP backup file, a known one is only updated, and nothing happens while log sync is disabled. A RadioInfo packet sent afterwards serves as an ordering marker instead of a sleep. Co-Authored-By: Claude Opus 5.5 * test: cover the local DX Cluster server over loopback (#106) DXClusterThreadPooledServer gains a package-private constructor that takes an already bound server socket; the public constructor still binds the configured port in run(). Plain sockets on the loopback address act as logging programs. The tests pin the listening status, the "login: " prompt, one DXSpider-style line with BEL BEL CR LF per spot to every client, the rejection of spots that cannot be formatted, and stop() closing the clients and ending the server thread. Co-Authored-By: Claude Opus 5.5 * fix: keep the UCXLog/DXLog listener alive on bad input (#106) An unwritable or empty backup path left the FileWriter null, and the following BufferedWriter threw a NullPointerException; a RadioInfo without or threw one as well. Neither was caught, so the listener thread ended for good. The backup line is now written with try-with-resources and skipped with a log line when the file cannot be opened, an incomplete RadioInfo element is skipped, and run() drops a packet whose processing throws any RuntimeException, reports a non-critical status and keeps receiving. The loopback tests cover all three paths. The interrupt test now waits for a processed marker packet instead of sleeping and no longer claims more than it can observe, and the marker helper states its in-order assumption for UDP on loopback. Co-Authored-By: Claude Opus 5.5 * fix: let a second DX Cluster server run() bind again (#106) The pre-bound-socket constructor made run() skip binding whenever a server socket existed. A run() after the first one ended would then accept on a closed socket. Bind whenever the socket is missing or closed, which restores the behaviour of the public constructor. Co-Authored-By: Claude Opus 5.5 * test: make the ignored AirScout responses observable (#106) The tests for responses to another client or server and for the disabled listener only checked that nothing happened before the poison pill, which a listener that never read the datagrams would also pass. Each now sends a valid sentinel afterwards and waits for it, and the ignored responses name a different receiver, so a wrongly processed copy is detectable; the disabled case also shows that the switch was asked once per response. The date test is renamed and documented as a pinned known defect, so a parser fix knows to flip it. Co-Authored-By: Claude Opus 5.5 * test: wait for the PSTRotator listener thread after stop() (#106) The client keeps its listener thread private, so the test finds it by the name start() gives it and joins it after stop(), asserting that exactly one such thread existed and that it ended. Co-Authored-By: Claude Opus 5.5 * fix: keep the UCX listener alive when the status callback throws (#106) Review follow-up. The safety net reported a dropped packet through the status callback without guarding it, so a throwing callback still ended the listener. The test for an unwritable backup file is renamed to what it proves, and the changelog mentions that any unexpected error now drops only the affected packet. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../DXClusterThreadPooledServer.java | 28 +- .../ReadUDPbyAirScoutMessageThread.java | 28 +- .../controller/ReadUDPbyUCXMessageThread.java | 77 ++++-- ...ClusterThreadPooledServerLoopbackTest.java | 191 +++++++++++++ .../PstRotatorClientLoopbackTest.java | 207 ++++++++++++++ ...DPbyAirScoutMessageThreadLoopbackTest.java | 219 +++++++++++++++ ...ReadUDPbyUCXMessageThreadLoopbackTest.java | 254 +++++++++++++++++- .../WinTestSkedSenderLoopbackTest.java | 113 ++++++++ github_docs/de-Changelog.md | 2 + github_docs/en-Changelog.md | 2 + 10 files changed, 1085 insertions(+), 36 deletions(-) create mode 100644 core/src/test/java/kst4contest/controller/DXClusterThreadPooledServerLoopbackTest.java create mode 100644 core/src/test/java/kst4contest/controller/PstRotatorClientLoopbackTest.java create mode 100644 core/src/test/java/kst4contest/controller/ReadUDPbyAirScoutMessageThreadLoopbackTest.java create mode 100644 core/src/test/java/kst4contest/controller/WinTestSkedSenderLoopbackTest.java diff --git a/core/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java b/core/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java index 2f27cfe4..e776a1ed 100644 --- a/core/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java +++ b/core/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java @@ -52,12 +52,38 @@ public class DXClusterThreadPooledServer implements Runnable { this.callBackToController = callback; } + /** + * Creates a server on a socket that is already bound. Tests use this to + * listen on an ephemeral loopback port; the application binds the + * configured port itself through the public constructor. + * + * @param boundServerSocket bound server socket this server takes ownership of + * @param chatController controller that provides the spot preferences + * @param callback thread-status sink + */ + DXClusterThreadPooledServer( + ServerSocket boundServerSocket, + ChatController chatController, + ThreadStatusCallback callback + ) { + this.serverSocket = boundServerSocket; + this.serverPort = boundServerSocket.getLocalPort(); + this.chatController = chatController; + this.callBackToController = callback; + } + @Override public void run() { Thread.currentThread().setName("DXCluster-thread-pooled-server"); try { - serverSocket = new ServerSocket(serverPort); + /* + * A pre-bound socket is used once; any later run() binds the + * configured port again, as it always did. + */ + if (serverSocket == null || serverSocket.isClosed()) { + serverSocket = new ServerSocket(serverPort); + } if (stopped) { return; diff --git a/core/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java b/core/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java index 8eadf1ce..b03c9407 100644 --- a/core/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java @@ -44,6 +44,26 @@ public class ReadUDPbyAirScoutMessageThread extends Thread { this.callBackToController = callback; } + /** + * Creates a listener on a socket that is already bound. Tests use this to + * listen on an ephemeral loopback port; the application binds the configured + * port itself through the public constructor. + * + * @param boundSocket bound datagram socket this thread takes ownership of + * @param client controller that receives the parsed responses + * @param callback thread-status sink + */ + ReadUDPbyAirScoutMessageThread( + DatagramSocket boundSocket, + ChatController client, + ThreadStatusCallback callback + ) { + this.socket = boundSocket; + this.localPort = boundSocket.getLocalPort(); + this.client = client; + this.callBackToController = callback; + } + @Override public void interrupt() { super.interrupt(); @@ -108,9 +128,11 @@ public class ReadUDPbyAirScoutMessageThread extends Thread { ); try { - socket = new DatagramSocket(null); - socket.setReuseAddress(true); - socket.bind(new InetSocketAddress(localPort)); + if (socket == null) { + socket = new DatagramSocket(null); + socket.setReuseAddress(true); + socket.bind(new InetSocketAddress(localPort)); + } socket.setSoTimeout(3000); while (!Thread.currentThread().isInterrupted()) { diff --git a/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java b/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java index 61e246ca..55e95446 100644 --- a/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java @@ -263,7 +263,26 @@ public class ReadUDPbyUCXMessageThread extends Thread { } if (!timeOutIndicator) { - processUCXUDPMessage(received); + /* + * Safety net: one malformed or unexpected packet must not end the + * listener. The packet is dropped and the loop keeps receiving. + */ + try { + processUCXUDPMessage(received); + } catch (RuntimeException exception) { + System.out.println("[ReadUDPFromUCX, warning]: packet dropped after unexpected error: " + + exception); + exception.printStackTrace(); + ThreadStateMessage droppedPacketMessage = new ThreadStateMessage(this.ThreadNickName, true, + "packet dropped after error: " + exception, false); + try { + callBackToController.onThreadStatus(ThreadNickName, droppedPacketMessage); + } catch (RuntimeException callbackException) { + // A failing status callback must not end the listener either. + System.out.println("[ReadUDPFromUCX, warning]: status callback failed: " + + callbackException); + } + } } else { //dont process the empty message } @@ -282,8 +301,6 @@ public class ReadUDPbyUCXMessageThread extends Thread { public String processUCXUDPMessage(String udpPacketToProcess) { - File logUDPMessageToThisFile; - String udpMsg = helper_extractXmlPayload(udpPacketToProcess); ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "received Message\n" + udpMsg, false); @@ -463,24 +480,9 @@ public class ReadUDPbyUCXMessageThread extends Thread { } - logUDPMessageToThisFile = new File(this.client.getChatPreferences() - .getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup()); - - FileWriter fileWriterPersistUDPToFile = null; - BufferedWriter bufwrtrRawMSGOut; - - try { - fileWriterPersistUDPToFile = new FileWriter(logUDPMessageToThisFile, true); - - } catch (IOException e1) { - e1.printStackTrace(); - } - - bufwrtrRawMSGOut = new BufferedWriter(fileWriterPersistUDPToFile); - - bufwrtrRawMSGOut.write("\n" + workedCall.toString()); - bufwrtrRawMSGOut.flush(); - bufwrtrRawMSGOut.close(); + appendToUdpBackupFile(this.client.getChatPreferences() + .getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup(), + "\n" + workedCall.toString()); } } @@ -506,8 +508,15 @@ public class ReadUDPbyUCXMessageThread extends Thread { Element element = (Element) node; - String qrg = element.getElementsByTagName("Freq").item(0).getTextContent(); - String mode = element.getElementsByTagName("Mode").item(0).getTextContent(); + Node freqNode = element.getElementsByTagName("Freq").item(0); + Node modeNode = element.getElementsByTagName("Mode").item(0); + if (freqNode == null || modeNode == null) { + System.out.println("[ReadUDPFromUCX, warning]: RadioInfo without Freq or Mode ignored"); + continue; + } + + String qrg = freqNode.getTextContent(); + String mode = modeNode.getTextContent(); // System.out.println("QRG Length: " + qrg.length() + " // " + qrg); @@ -595,6 +604,28 @@ public class ReadUDPbyUCXMessageThread extends Thread { return ""; } + /** + * Appends one line to the UDP backup file. The backup is a convenience copy: + * the QSO has already been applied and stored when this runs, so a missing, + * empty or unwritable path is logged and the write is skipped. + * + * @param backupFilePath configured backup file path, may be null or blank + * @param text text to append + */ + private void appendToUdpBackupFile(String backupFilePath, String text) { + if (backupFilePath == null || backupFilePath.isBlank()) { + System.out.println("[ReadUDPFromUCX, warning]: no UDP backup file configured, backup write skipped"); + return; + } + + try (BufferedWriter backupWriter = new BufferedWriter(new FileWriter(new File(backupFilePath), true))) { + backupWriter.write(text); + } catch (IOException exception) { + System.out.println("[ReadUDPFromUCX, warning]: cannot write UDP backup file \"" + + backupFilePath + "\": " + exception.getMessage()); + } + } + public boolean terminateConnection() throws IOException { // String[] threadStatusMessage = new String[2]; // threadStatusMessage = new String[2]; diff --git a/core/src/test/java/kst4contest/controller/DXClusterThreadPooledServerLoopbackTest.java b/core/src/test/java/kst4contest/controller/DXClusterThreadPooledServerLoopbackTest.java new file mode 100644 index 00000000..3717e031 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/DXClusterThreadPooledServerLoopbackTest.java @@ -0,0 +1,191 @@ +package kst4contest.controller; + +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; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import kst4contest.model.ChatMember; +import kst4contest.model.ChatPreferences; +import kst4contest.model.ThreadStateMessage; +import kst4contest.observe.SimpleValue; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Loopback tests for the local DX Cluster server that logging programs + * connect to. The server runs on a socket bound to an ephemeral loopback port, + * and the clients are plain sockets reading what a telnet client would see. + * + *

The column layout itself is pinned by {@link DXClusterSpotFormatterTest}; + * these tests pin what reaches a connected client and when.

+ */ +class DXClusterThreadPooledServerLoopbackTest { + + /** Prompt sent on connect; the server never reads the answer. */ + private static final String LOGIN_PROMPT = "login: "; + /** 75 characters, two BEL and CR LF. */ + private static final int SPOT_LENGTH = 79; + + private final ChatController controller = mock(ChatController.class); + private final ChatPreferences preferences = mock(ChatPreferences.class); + private final BlockingQueue statusMessages = new LinkedBlockingQueue<>(); + private final List clients = new ArrayList<>(); + + private int port; + private DXClusterThreadPooledServer server; + private Thread serverThread; + + @BeforeEach + void startServer() throws Exception { + when(controller.getChatPreferences()).thenReturn(preferences); + when(preferences.getNotify_DXCSrv_SpottersCallSign()).thenReturn(new SimpleValue<>("dl0kst")); + when(preferences.getNotify_optionalFrequencyPrefix()).thenReturn(new SimpleValue<>("144")); + + ServerSocket bound = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + port = bound.getLocalPort(); + server = new DXClusterThreadPooledServer(bound, controller, + (threadName, message) -> statusMessages.add(message)); + serverThread = new Thread(server, "dxcluster-loopback-test"); + serverThread.setDaemon(true); + serverThread.start(); + } + + @AfterEach + void stopServer() throws Exception { + server.stop(); + serverThread.join(2_000); + for (Socket client : clients) { + client.close(); + } + } + + @Test + void serverReportsTheListeningPort() throws Exception { + ThreadStateMessage message = statusMessages.poll(5, TimeUnit.SECONDS); + assertNotNull(message); + assertEquals("Listening on TCP port " + port, message.getRunningInformation()); + assertTrue(message.isRunning()); + } + + @Test + void connectingClientIsGreetedWithALoginPrompt() throws Exception { + Socket client = connect(); + + assertEquals(LOGIN_PROMPT, read(client, LOGIN_PROMPT.length())); + assertTrue(server.hasConnectedClients()); + } + + /** + * The spot is one DXSpider-style line: spotter in upper case, frequency in + * kHz with one decimal right-aligned to column 24, the DX call from column + * 27, the locator as comment, HHMMZ from column 71, then BEL BEL CR LF. + * A relative frequency is completed with the configured prefix. What the + * client sends after the prompt is ignored. + */ + @Test + void spotReachesTheClientAsOneDxSpiderLine() throws Exception { + Socket client = connect(); + assertEquals(LOGIN_PROMPT, read(client, LOGIN_PROMPT.length())); + client.getOutputStream().write("DL1TEST\r\n".getBytes(StandardCharsets.US_ASCII)); + client.getOutputStream().flush(); + statusMessages.poll(5, TimeUnit.SECONDS); // "Listening on ..." + + assertTrue(server.broadcastSingleDXClusterEntryToLoggers(station("ok1abc", "JO70FD", ".205"))); + + String spot = read(client, SPOT_LENGTH); + assertEquals("DX de DL0KST: 144205.0 OK1ABC JO70FD ", + spot.substring(0, 70)); + assertTrue(spot.substring(70, 75).matches("[0-2]\\d[0-5]\\dZ"), spot); + assertEquals("\u0007\u0007\r\n", spot.substring(75)); + + ThreadStateMessage status = statusMessages.poll(5, TimeUnit.SECONDS); + assertNotNull(status); + assertEquals("Last spot sent to 1 DX Cluster client(s):\n" + spot, status.getRunningInformation()); + } + + @Test + void spotIsSentToEveryConnectedClient() throws Exception { + Socket first = connect(); + Socket second = connect(); + assertEquals(LOGIN_PROMPT, read(first, LOGIN_PROMPT.length())); + assertEquals(LOGIN_PROMPT, read(second, LOGIN_PROMPT.length())); + + assertTrue(server.broadcastSingleDXClusterEntryToLoggers(station("SP2XYZ", "JO93AB", "432.088"))); + + String firstSpot = read(first, SPOT_LENGTH); + assertEquals(firstSpot, read(second, SPOT_LENGTH)); + assertTrue(firstSpot.startsWith("DX de DL0KST: 432088.0 SP2XYZ "), firstSpot); + } + + @Test + void spotWithoutConnectedClientsIsNotDelivered() { + assertFalse(server.hasConnectedClients()); + assertFalse(server.broadcastSingleDXClusterEntryToLoggers(station("OK1ABC", "JO70FD", "144.205"))); + } + + /** A spot that cannot be formatted is rejected, and the client receives nothing. */ + @Test + void unformattableSpotIsRejectedWithoutWritingToTheClient() throws Exception { + Socket client = connect(); + assertEquals(LOGIN_PROMPT, read(client, LOGIN_PROMPT.length())); + + assertFalse(server.broadcastSingleDXClusterEntryToLoggers(station("OK1ABC", "JO70FD", ""))); + assertFalse(server.broadcastSingleDXClusterEntryToLoggers( + station("OK1ABCDEFGHIJ", "JO70FD", "144.205"))); + + // The next valid spot is the first thing after the prompt. + assertTrue(server.broadcastSingleDXClusterEntryToLoggers(station("OK1ABC", "JO70FD", "144.205"))); + assertTrue(read(client, SPOT_LENGTH).startsWith("DX de DL0KST:")); + } + + @Test + void stopClosesTheClientConnectionsAndEndsTheServerThread() throws Exception { + Socket client = connect(); + assertEquals(LOGIN_PROMPT, read(client, LOGIN_PROMPT.length())); + + server.stop(); + serverThread.join(5_000); + + assertFalse(serverThread.isAlive(), "server thread must end after stop()"); + assertEquals(-1, client.getInputStream().read(), "client must see end of stream"); + assertFalse(server.hasConnectedClients()); + } + + private Socket connect() throws IOException { + Socket client = new Socket(InetAddress.getLoopbackAddress(), port); + client.setSoTimeout(5_000); + clients.add(client); + return client; + } + + private static String read(Socket client, int length) throws IOException { + InputStream input = client.getInputStream(); + byte[] bytes = input.readNBytes(length); + assertEquals(length, bytes.length, "connection ended early"); + return new String(bytes, StandardCharsets.US_ASCII); + } + + private static ChatMember station(String callSign, String locator, String frequency) { + ChatMember member = new ChatMember(); + member.setCallSign(callSign); + member.setQra(locator); + member.setFrequency(new SimpleValue<>(frequency)); + return member; + } +} diff --git a/core/src/test/java/kst4contest/controller/PstRotatorClientLoopbackTest.java b/core/src/test/java/kst4contest/controller/PstRotatorClientLoopbackTest.java new file mode 100644 index 00000000..9f3981e8 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/PstRotatorClientLoopbackTest.java @@ -0,0 +1,207 @@ +package kst4contest.controller; + +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; + +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import kst4contest.controller.interfaces.PstRotatorEventListener; +import kst4contest.model.ThreadStateMessage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Loopback tests for the PSTRotator UDP client. A socket on an ephemeral + * loopback port plays PSTRotator; the client is built with the explicit-port + * constructor and local port 0, so it never binds the real PSTRotator ports. + * + *

The reply format used here ({@code AZ:145.0}, {@code EL:010.0}, + * {@code MODE:1}, each terminated by a carriage return) is derived from the + * client's parser and from the PSTRotator UDP documentation, which describes + * the answer as {@code AZ:xxx.x} on the command port plus one. It is not + * taken from a capture of a running PSTRotator; none was available.

+ */ +class PstRotatorClientLoopbackTest { + + private static final String POLL_AZIMUTH = "AZ?"; + private static final String POLL_MODE = "MODE?"; + + private final List statusMessages = new CopyOnWriteArrayList<>(); + private final RecordingListener listener = new RecordingListener(); + + private DatagramSocket pstRotator; + private PstRotatorClient client; + + @BeforeEach + void startClientAgainstFakePstRotator() throws Exception { + InetAddress loopback = InetAddress.getLoopbackAddress(); + pstRotator = new DatagramSocket(new InetSocketAddress(loopback, 0)); + pstRotator.setSoTimeout(5_000); + client = new PstRotatorClient(loopback.getHostAddress(), pstRotator.getLocalPort(), 0, listener, + (threadName, message) -> statusMessages.add(message)); + client.start(); + } + + /** + * The client does not expose its listener thread, so it is found by the + * name start() gives it, which contains the remote port of this test's + * fake PSTRotator. + */ + @AfterEach + void stopClient() throws Exception { + String listenerName = "PSTRotator-Listener-" + pstRotator.getLocalPort(); + List listeners = Thread.getAllStackTraces().keySet().stream() + .filter(thread -> thread.getName().equals(listenerName)) + .toList(); + try { + client.stop(); + assertEquals(1, listeners.size(), "exactly one listener thread expected"); + for (Thread listenerThread : listeners) { + listenerThread.join(2_000); + assertFalse(listenerThread.isAlive(), "listener thread must end after stop()"); + } + } finally { + pstRotator.close(); + } + } + + /** + * The explicit-port constructor once left the status callback null, so + * start() threw before it could report anything (#115). + */ + @Test + void explicitPortConstructorReportsInitializedAfterStart() { + assertFalse(statusMessages.isEmpty(), "start() must report its status"); + ThreadStateMessage first = statusMessages.get(0); + assertEquals("PSTRotator", first.getThreadNickName()); + assertEquals("initialized", first.getRunningInformation()); + assertTrue(first.isRunning()); + assertFalse(first.isCriticalState()); + } + + /** The poller asks for azimuth, then mode, as two separate datagrams. */ + @Test + void pollerQueriesAzimuthThenMode() throws Exception { + assertEquals(POLL_AZIMUTH, receive().text); + assertEquals(POLL_MODE, receive().text); + } + + @Test + void controlCommandsAreSentAsPstXmlFrames() throws Exception { + client.setAzimuth(145.7); + client.setElevation(10.5); + client.stopRotor(); + client.park(); + client.setTrackingMode(true); + client.setTrackingMode(false); + + // The azimuth is truncated to whole degrees; the elevation is not. + assertEquals("145", receiveCommand()); + assertEquals("10.5", receiveCommand()); + assertEquals("1", receiveCommand()); + assertEquals("1", receiveCommand()); + assertEquals("1", receiveCommand()); + assertEquals("0", receiveCommand()); + } + + @Test + void repliesToTheClientSocketReachTheListener() throws Exception { + SocketAddress clientAddress = receive().source; + + reply(clientAddress, "AZ:145.0\r"); + reply(clientAddress, "EL:010.0\r"); + reply(clientAddress, "MODE:1\r"); + reply(clientAddress, "MODE:0\r"); + + assertEquals(145.0, poll(listener.azimuths)); + assertEquals(10.0, poll(listener.elevations)); + assertEquals(Boolean.TRUE, poll(listener.modes)); + assertEquals(Boolean.FALSE, poll(listener.modes)); + // The raw message is handed over trimmed, i.e. without the CR. + assertEquals("AZ:145.0", poll(listener.rawMessages)); + } + + /** An unparsable value is dropped; the receive loop keeps running. */ + @Test + void unparsableReplyDoesNotStopTheListener() throws Exception { + SocketAddress clientAddress = receive().source; + + reply(clientAddress, "AZ:not-a-number\r"); + reply(clientAddress, "AZ:90.0\r"); + + assertEquals(90.0, poll(listener.azimuths)); + assertTrue(listener.azimuths.isEmpty()); + } + + private Received receive() throws Exception { + byte[] buffer = new byte[1024]; + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + pstRotator.receive(packet); + return new Received(new String(packet.getData(), 0, packet.getLength(), StandardCharsets.US_ASCII), + packet.getSocketAddress()); + } + + /** Receives the next datagram that is not one of the poller's queries. */ + private String receiveCommand() throws Exception { + while (true) { + String text = receive().text; + if (!text.equals(POLL_AZIMUTH) && !text.equals(POLL_MODE)) { + return text; + } + } + } + + private void reply(SocketAddress target, String text) throws Exception { + byte[] bytes = text.getBytes(StandardCharsets.US_ASCII); + pstRotator.send(new DatagramPacket(bytes, bytes.length, target)); + } + + private static T poll(BlockingQueue queue) throws InterruptedException { + T value = queue.poll(5, TimeUnit.SECONDS); + assertNotNull(value, "expected a listener callback"); + return value; + } + + private record Received(String text, SocketAddress source) { + } + + private static final class RecordingListener implements PstRotatorEventListener { + final BlockingQueue azimuths = new LinkedBlockingQueue<>(); + final BlockingQueue elevations = new LinkedBlockingQueue<>(); + final BlockingQueue modes = new LinkedBlockingQueue<>(); + final BlockingQueue rawMessages = new LinkedBlockingQueue<>(); + + @Override + public void onAzimuthUpdate(double azimuth) { + azimuths.add(azimuth); + } + + @Override + public void onElevationUpdate(double elevation) { + elevations.add(elevation); + } + + @Override + public void onModeUpdate(boolean isTracking) { + modes.add(isTracking); + } + + @Override + public void onMessageReceived(String rawMessage) { + rawMessages.add(rawMessage); + } + } +} diff --git a/core/src/test/java/kst4contest/controller/ReadUDPbyAirScoutMessageThreadLoopbackTest.java b/core/src/test/java/kst4contest/controller/ReadUDPbyAirScoutMessageThreadLoopbackTest.java new file mode 100644 index 00000000..e12f2347 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/ReadUDPbyAirScoutMessageThreadLoopbackTest.java @@ -0,0 +1,219 @@ +package kst4contest.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import kst4contest.ApplicationConstants; +import kst4contest.model.AirPlane; +import kst4contest.model.AirPlaneReflectionInfo; +import kst4contest.model.ChatMember; +import kst4contest.model.ChatPreferences; +import kst4contest.model.ThreadStateMessage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Loopback tests for the AirScout UDP listener. The listener runs on a socket + * bound to an ephemeral loopback port, and every datagram goes to the loopback + * address, so the tests neither collide with a running AirScout nor broadcast. + * + *

The responses follow the {@code ASNEAREST} format documented in + * {@link ReadUDPbyAirScoutMessageThread#processASUDPMessage(String)}.

+ */ +class ReadUDPbyAirScoutMessageThreadLoopbackTest { + + /** Configured client name with a non-ASCII character: the listener decodes UTF-8 (#115). */ + private static final String CLIENT = "KST-Ä"; + private static final String SERVER = "AS"; + private static final String PLANES = "2023-04-01 21:33:42Z,DO5AMF,JN49GL,G4CLA,IO92JL,2," + + "BAW809,M,250,50,18,KLM1678,L,313,75,22"; + /** Same response for receiver PA0XYZ, so a wrongly processed copy is detectable. */ + private static final String OTHER_RECEIVER_PLANES = "2023-04-01 21:33:42Z,DO5AMF,JN49GL,PA0XYZ,JO22AB,1," + + "BAW809,M,250,50,18"; + + private final ChatController controller = mock(ChatController.class); + private final ChatPreferences preferences = mock(ChatPreferences.class); + private final ScoreService scoreService = mock(ScoreService.class); + private final BlockingQueue statusMessages = new LinkedBlockingQueue<>(); + + private DatagramSocket sender; + private InetSocketAddress target; + private ReadUDPbyAirScoutMessageThread listener; + + @BeforeEach + void startListener() throws Exception { + when(controller.getChatPreferences()).thenReturn(preferences); + when(controller.getScoreService()).thenReturn(scoreService); + when(preferences.isAirScout_asUDPListenerEnabled()).thenReturn(true); + when(preferences.getAirScout_asServerNameString()).thenReturn(SERVER); + when(preferences.getAirScout_asClientNameString()).thenReturn(CLIENT); + + InetAddress loopback = InetAddress.getLoopbackAddress(); + DatagramSocket bound = new DatagramSocket(new InetSocketAddress(loopback, 0)); + target = new InetSocketAddress(loopback, bound.getLocalPort()); + sender = new DatagramSocket(new InetSocketAddress(loopback, 0)); + listener = new ReadUDPbyAirScoutMessageThread(bound, controller, + (threadName, message) -> statusMessages.add(message)); + listener.setDaemon(true); + } + + @AfterEach + void stopListener() throws Exception { + listener.interrupt(); + listener.join(2_000); + sender.close(); + } + + @Test + void matchingResponseIsAppliedToEveryActiveVariantOfTheReceiver() throws Exception { + ChatMember plain = new ChatMember(); + ChatMember portable = new ChatMember(); + when(controller.findActiveChatMembersByRawCall("G4CLA")).thenReturn(List.of(plain, portable)); + listener.start(); + + send(response(SERVER, CLIENT, PLANES)); + + awaitResponseProcessed(); + AirPlaneReflectionInfo info = plain.getAirPlaneReflectInfo(); + assertSame(info, portable.getAirPlaneReflectInfo()); + assertEquals("DO5AMF", info.getSender().getCallSign()); + assertEquals("JN49GL", info.getSender().getQra()); + assertEquals("G4CLA", info.getReceiver().getCallSign()); + assertEquals("IO92JL", info.getReceiver().getQra()); + assertEquals(2, info.getAirPlanesReachableCntr()); + + // Sorted by potential, highest first. + List planes = info.getRisingAirplanes(); + assertEquals(2, planes.size()); + assertPlane(planes.get(0), "KLM1678", "L", 313, 75, 22); + assertPlane(planes.get(1), "BAW809", "M", 250, 50, 18); + + verify(scoreService).requestRecompute("airscout-update"); + } + + /** + * KNOWN DEFECT, pinned on purpose: the parser strips every space from the + * datagram, including the one between date and time. The date is only + * shown in toString(), so it was left as it is. Whoever fixes the parser + * flips this expectation to {@code 2023-04-01 21:33:42Z}. + */ + @Test + void pinsKnownDefectDateLosesTheSpaceBetweenDayAndTime() throws Exception { + ChatMember member = new ChatMember(); + when(controller.findActiveChatMembersByRawCall("G4CLA")).thenReturn(List.of(member)); + listener.start(); + + send(response(SERVER, CLIENT, PLANES)); + + awaitResponseProcessed(); + assertEquals("2023-04-0121:33:42Z", member.getAirPlaneReflectInfo().getDate()); + } + + /** + * The ignored responses name another receiver than the sentinel that + * follows them. Once the sentinel has been processed, the earlier + * datagrams have been read, assuming in-order delivery from one socket to + * one socket on loopback, which UDP does not promise but loopback provides + * in practice. + */ + @Test + void responsesForAnotherClientOrServerAreIgnored() throws Exception { + when(controller.findActiveChatMembersByRawCall("G4CLA")).thenReturn(List.of(new ChatMember())); + listener.start(); + + send(response(SERVER, "KST-A", OTHER_RECEIVER_PLANES)); + send(response("AS2", CLIENT, OTHER_RECEIVER_PLANES)); + send(response(SERVER, "kst-ä", OTHER_RECEIVER_PLANES)); + send(response(SERVER, CLIENT, PLANES)); + + awaitResponseProcessed(); + send(ApplicationConstants.DISCONNECT_RDR_POISONPILL); + listener.join(5_000); + assertFalse(listener.isAlive(), "the poison pill must end the listener"); + verify(controller, never()).findActiveChatMembersByRawCall("PA0XYZ"); + verify(controller).findActiveChatMembersByRawCall("G4CLA"); + assertTrue(statusMessages.isEmpty(), "only the sentinel may be reported"); + } + + /** + * The first response arrives while the listener is disabled; the switch is + * then turned on, so the sentinel is processed. Asking the switch once per + * response shows that the first one was received and then discarded. + */ + @Test + void disabledListenerDiscardsResponsesButStillHonoursThePoisonPill() throws Exception { + when(preferences.isAirScout_asUDPListenerEnabled()).thenReturn(false, true); + when(controller.findActiveChatMembersByRawCall("G4CLA")).thenReturn(List.of(new ChatMember())); + listener.start(); + + send(response(SERVER, CLIENT, OTHER_RECEIVER_PLANES)); + send(response(SERVER, CLIENT, PLANES)); + + awaitResponseProcessed(); + send(ApplicationConstants.DISCONNECT_RDR_POISONPILL); + listener.join(5_000); + assertFalse(listener.isAlive(), "the poison pill must end the listener"); + verify(preferences, times(2)).isAirScout_asUDPListenerEnabled(); + verify(controller, never()).findActiveChatMembersByRawCall("PA0XYZ"); + verify(controller).findActiveChatMembersByRawCall("G4CLA"); + } + + /** A malformed response is dropped; the next valid one is still processed. */ + @Test + void malformedResponseDoesNotStopTheListener() throws Exception { + ChatMember member = new ChatMember(); + when(controller.findActiveChatMembersByRawCall("G4CLA")).thenReturn(List.of(member)); + listener.start(); + + send(response(SERVER, CLIENT, "2023-04-01 21:33:42Z,DO5AMF")); + send(response(SERVER, CLIENT, PLANES)); + + awaitResponseProcessed(); + assertEquals("G4CLA", member.getAirPlaneReflectInfo().getReceiver().getCallSign()); + assertNull(statusMessages.poll()); + } + + private static String response(String server, String client, String body) { + return "ASNEAREST: \"" + server + "\" \"" + client + "\" \"" + body + "\""; + } + + private void send(String payload) throws Exception { + byte[] bytes = payload.getBytes(StandardCharsets.UTF_8); + sender.send(new DatagramPacket(bytes, bytes.length, target)); + } + + private void awaitResponseProcessed() throws InterruptedException { + ThreadStateMessage message = statusMessages.poll(5, TimeUnit.SECONDS); + assertTrue(message != null && message.getRunningInformation().startsWith("Received AirScout response"), + "listener must report the processed response"); + } + + private static void assertPlane(AirPlane plane, String callSign, String size, int distanceKm, + int potential, int minutes) { + assertEquals(callSign, plane.getApCallSign()); + assertEquals(size, plane.getApSizeCategory()); + assertEquals(distanceKm, plane.getDistanceKm()); + assertEquals(potential, plane.getPotential()); + assertEquals(minutes, plane.getArrivingDurationMinutes()); + } +} diff --git a/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java b/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java index 9047d687..61dbf7e7 100644 --- a/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java +++ b/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java @@ -3,7 +3,12 @@ package kst4contest.controller; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.net.DatagramPacket; @@ -11,13 +16,22 @@ import java.net.DatagramSocket; import java.net.InetAddress; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; +import kst4contest.model.Band; +import kst4contest.model.ChatMember; import kst4contest.model.ChatPreferences; +import kst4contest.model.ThreadStateMessage; import kst4contest.observe.DirectUiDispatcher; import kst4contest.observe.SimpleValue; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; /** * Loopback tests for the UCXLog/DXLog UDP listener. Every socket is bound to @@ -26,23 +40,50 @@ import org.junit.jupiter.api.Test; */ class ReadUDPbyUCXMessageThreadLoopbackTest { + /** RadioInfo that sets the first-category QRG to 1296.200.00; used as an ordering marker. */ + private static final String MARKER_PACKET = "129620000CW"; + private final ChatController controller = mock(ChatController.class); private final ChatPreferences preferences = mock(ChatPreferences.class); - private final ThreadStatusCallback statusCallback = (threadName, message) -> { }; + private final BlockingQueue statusMessages = new LinkedBlockingQueue<>(); + /** + * Makes {@link #statusCallback} throw after recording the safety net's + * dropped-packet report, to simulate a failing controller there. Other + * reports pass, so the ordering marker can still be processed. + */ + private volatile boolean statusCallbackThrows; + private final ThreadStatusCallback statusCallback = (threadName, message) -> { + statusMessages.add(message); + if (statusCallbackThrows + && message.getRunningInformation().startsWith("packet dropped after error")) { + throw new IllegalStateException("callback boom"); + } + }; + /** First-category QRG that the RadioInfo ordering marker sets. */ + private final SimpleValue markerQrg = new SimpleValue<>(""); + /** + * A processed RadioInfo packet shows that the receive loop is running, + * instead of a sleep. The receive timeout is 2 s, so ending within 1 s + * shows that interrupt() does not wait for the next timeout. Whether the + * thread sat inside receive() or between two receives at that moment is + * not observable; the closed socket and the interrupt flag end the loop + * in either case. + */ @Test void interruptEndsTheReceiveLoopBeforeTheReceiveTimeout() throws Exception { - when(controller.getChatPreferences()).thenReturn(preferences); - ReadUDPbyUCXMessageThread listener = new ReadUDPbyUCXMessageThread(0, controller, statusCallback); + givenRadioInfoMarkerEnabled(); + CountDownLatch marker = markerLatch(); + InetAddress loopback = InetAddress.getLoopbackAddress(); + DatagramSocket bound = new DatagramSocket(new InetSocketAddress(loopback, 0)); + ReadUDPbyUCXMessageThread listener = new ReadUDPbyUCXMessageThread(bound, controller, statusCallback); listener.setDaemon(true); listener.start(); - // Let the thread bind its socket and block in receive() before interrupting it. - Thread.sleep(200); + try (DatagramSocket sender = new DatagramSocket(new InetSocketAddress(loopback, 0))) { + send(sender, new InetSocketAddress(loopback, bound.getLocalPort()), MARKER_PACKET); + assertTrue(marker.await(5, TimeUnit.SECONDS), "marker packet must be processed"); + } - /* - * The receive timeout is 2 s. Waiting only 1 s proves that interrupt() - * closed the socket, rather than the loop ending on the next timeout. - */ listener.interrupt(); listener.join(1_000); @@ -101,6 +142,201 @@ class ReadUDPbyUCXMessageThreadLoopbackTest { } } + /** + * A UCXLog QSO packet marks the station worked, registers the gross field + * and stores an unknown station in the database and the UDP backup file. + * Bytes before the XML declaration are transport framing and are dropped. + */ + @Test + void contactInfoPacketMarksTheStationWorkedAndStoresIt(@TempDir Path tempDir) throws Exception { + Path backupFile = tempDir.resolve("udp-backup.txt"); + DBController database = givenQsoProcessingEnabled(backupFile); + when(database.updateWkdInfoOnChatMember(any())).thenReturn(false); + + sendThenAwaitMarker("\u0001\u0002UCX" + + "" + + "UCXLogDL0TEST144" + + "1442000014420000CW" + + "OK1ABC599001599" + + "001JO70FDjo70fd350" + + ""); + + ArgumentCaptor qso = ArgumentCaptor.forClass(ExternalLoggedQso.class); + verify(controller).applyExternalLoggedQso(qso.capture()); + assertEquals("OK1ABC", qso.getValue().getCallSign()); + assertEquals(LoggedQsoBand.BAND_144, qso.getValue().getBand()); + assertEquals("JO70FD", qso.getValue().getLocator()); + assertEquals("UCXLOG", qso.getValue().getSource()); + verify(controller).registerWorkedGrossField( + eq(Band.B_144), eq("JO70FD"), any(ChatMember.class), eq("UCXLOG")); + + ArgumentCaptor stored = ArgumentCaptor.forClass(ChatMember.class); + verify(database).storeChatMember(stored.capture()); + assertEquals("OK1ABC", stored.getValue().getCallSign()); + assertEquals("unknown", stored.getValue().getName()); + assertEquals("JO70FD", stored.getValue().getQra()); + assertTrue(stored.getValue().isWorked()); + assertTrue(stored.getValue().isWorked144()); + + String backup = Files.readString(backupFile, StandardCharsets.UTF_8); + assertTrue(backup.startsWith("\nOK1ABC;unknown;JO70FD;"), backup); + } + + /** + * DXLog sends contactreplace when it broadcasts the whole log. Without a + * gridsquare the locator comes from the received exchange, and a station + * already in the database is updated rather than stored again. + */ + @Test + void contactReplaceTakesTheLocatorFromTheReceivedExchange(@TempDir Path tempDir) throws Exception { + DBController database = givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt")); + when(database.updateWkdInfoOnChatMember(any())).thenReturn(true); + + sendThenAwaitMarker("" + + "DXLog432SP2XYZ" + + "042JO93AB"); + + verify(controller).registerWorkedGrossField( + eq(Band.B_432), eq("JO93AB"), any(ChatMember.class), eq("UCXLOG")); + verify(database).updateWkdInfoOnChatMember(any()); + verify(database, never()).storeChatMember(any()); + } + + @Test + void contactInfoIsIgnoredWhileLogSyncIsDisabled(@TempDir Path tempDir) throws Exception { + DBController database = givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt")); + when(preferences.isLogsynch_ucxUDPWkdCallListenerEnabled()).thenReturn(false); + + sendThenAwaitMarker("144OK1ABC"); + + verify(controller, never()).applyExternalLoggedQso(any()); + verify(database, never()).updateWkdInfoOnChatMember(any()); + } + + /** + * An unwritable backup path (here: a directory) used to throw a + * NullPointerException that ended the listener for good. The backup write + * is skipped now; the QSO has already reached the controller and the + * database, and the following packet is still processed. + */ + @Test + void unwritableBackupFileDoesNotStopQsoProcessingOrTheListener(@TempDir Path tempDir) throws Exception { + DBController database = givenQsoProcessingEnabled(tempDir); + when(database.updateWkdInfoOnChatMember(any())).thenReturn(false); + + sendThenAwaitMarker("144OK1ABC" + + "JO70FD"); + + verify(controller).applyExternalLoggedQso(any()); + verify(database).storeChatMember(any()); + } + + /** A RadioInfo without Freq used to end the listener with a NullPointerException. */ + @Test + void radioInfoWithoutFrequencyIsSkippedAndTheListenerKeepsRunning(@TempDir Path tempDir) throws Exception { + givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt")); + + sendThenAwaitMarker("CW"); + + assertEquals("1296.200.00", markerQrg.get()); + } + + /** + * Any runtime exception while processing one packet drops that packet, + * reports a non-critical status and leaves the receive loop running. + */ + @Test + void runtimeExceptionWhileProcessingDropsOnlyThatPacket(@TempDir Path tempDir) throws Exception { + givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt")); + doThrow(new IllegalStateException("boom")).when(controller).applyExternalLoggedQso(any()); + + sendThenAwaitMarker("144OK1ABC"); + + ThreadStateMessage dropped = statusMessages.stream() + .filter(message -> message.getRunningInformation().startsWith("packet dropped after error")) + .findFirst() + .orElseThrow(); + assertTrue(dropped.getRunningInformation().contains("boom")); + assertTrue(dropped.isRunning()); + assertFalse(dropped.isCriticalState()); + } + + /** + * Wires the mocks for QSO processing, including the RadioInfo ordering + * marker of {@link #givenRadioInfoMarkerEnabled()}. + */ + private DBController givenQsoProcessingEnabled(Path backupFile) { + DBController database = mock(DBController.class); + givenRadioInfoMarkerEnabled(); + when(controller.getDbHandler()).thenReturn(database); + when(preferences.isLogsynch_ucxUDPWkdCallListenerEnabled()).thenReturn(true); + when(preferences.getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup()) + .thenReturn(backupFile.toString()); + return database; + } + + /** + * Enables TRX sync so that {@link #MARKER_PACKET} sets {@link #markerQrg}. + * The listener handles datagrams one at a time on a single thread, so once + * the marker's QRG is set, every datagram it received before the marker + * has been processed completely. + */ + private void givenRadioInfoMarkerEnabled() { + when(controller.getChatPreferences()).thenReturn(preferences); + when(controller.uiDispatcher()).thenReturn(new DirectUiDispatcher()); + when(preferences.isTrxSynch_ucxLogUDPListenerEnabled()).thenReturn(true); + when(preferences.getMYQRGFirstCat()).thenReturn(markerQrg); + } + + /** + * The safety net reports a dropped packet through the status callback. If + * that callback throws as well, the listener must still keep running. + */ + @Test + void failingStatusCallbackDoesNotEndTheListener(@TempDir Path tempDir) throws Exception { + givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt")); + doThrow(new IllegalStateException("boom")).when(controller).applyExternalLoggedQso(any()); + statusCallbackThrows = true; + + sendThenAwaitMarker("144OK1ABC"); + + assertTrue(statusMessages.stream() + .anyMatch(message -> message.getRunningInformation().startsWith("packet dropped after error"))); + } + + private CountDownLatch markerLatch() { + CountDownLatch marker = new CountDownLatch(1); + markerQrg.addListener(value -> { + if (value.equals("1296.200.00")) marker.countDown(); + }); + return marker; + } + + /** + * Sends the packet and then the marker, and waits for the marker. This + * relies on the two datagrams arriving in the order they were sent. UDP + * does not guarantee that in general, but on the loopback interface, from + * one socket to one socket, they are delivered in order in practice. + */ + private void sendThenAwaitMarker(String packet) throws Exception { + CountDownLatch marker = markerLatch(); + + InetAddress loopback = InetAddress.getLoopbackAddress(); + DatagramSocket bound = new DatagramSocket(new InetSocketAddress(loopback, 0)); + ReadUDPbyUCXMessageThread listener = new ReadUDPbyUCXMessageThread(bound, controller, statusCallback); + listener.setDaemon(true); + listener.start(); + try (DatagramSocket sender = new DatagramSocket(new InetSocketAddress(loopback, 0))) { + InetSocketAddress target = new InetSocketAddress(loopback, bound.getLocalPort()); + send(sender, target, packet); + send(sender, target, MARKER_PACKET); + assertTrue(marker.await(5, TimeUnit.SECONDS), "marker packet must be processed"); + } finally { + listener.interrupt(); + listener.join(2_000); + } + } + private static void send(DatagramSocket sender, InetSocketAddress target, String payload) throws Exception { byte[] bytes = payload.getBytes(StandardCharsets.UTF_8); sender.send(new DatagramPacket(bytes, bytes.length, target)); diff --git a/core/src/test/java/kst4contest/controller/WinTestSkedSenderLoopbackTest.java b/core/src/test/java/kst4contest/controller/WinTestSkedSenderLoopbackTest.java new file mode 100644 index 00000000..edc0b86a --- /dev/null +++ b/core/src/test/java/kst4contest/controller/WinTestSkedSenderLoopbackTest.java @@ -0,0 +1,113 @@ +package kst4contest.controller; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import kst4contest.model.Band; +import kst4contest.model.ContestSked; +import kst4contest.model.ThreadStateMessage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Loopback tests for the Win-Test sked sender. The production code sends to a + * broadcast address; here the "broadcast address" is the loopback address and + * the receiver is a socket on an ephemeral loopback port, so nothing leaves the + * machine and no running Win-Test sees the datagrams. + * + *

The expected frames are written out literally and their checksum is + * computed here independently of {@link WinTestMessage}: the test pins the wire + * format, not the serializer's current implementation.

+ */ +class WinTestSkedSenderLoopbackTest { + + private static final String STATION = "KST4Contest"; + private static final long SKED_TIME_MS = 1_760_000_000_000L; + + private final List statusMessages = new ArrayList<>(); + + private DatagramSocket winTest; + private WinTestSkedSender sender; + + @BeforeEach + void bindFakeWinTest() throws Exception { + InetAddress loopback = InetAddress.getLoopbackAddress(); + winTest = new DatagramSocket(new InetSocketAddress(loopback, 0)); + winTest.setSoTimeout(5_000); + sender = new WinTestSkedSender(STATION, loopback, winTest.getLocalPort(), + (threadName, message) -> statusMessages.add(message)); + } + + @AfterEach + void closeFakeWinTest() { + winTest.close(); + } + + @Test + void pushSendsLockAddAndUnlockFramesInThisOrder() throws Exception { + ContestSked sked = new ContestSked("DL1ABC", 47.0, SKED_TIME_MS, Band.B_432); + + sender.pushSkedToWinTest(sked, "DL1ABC", 432200.0, "[JO62QM - 47°] sked via KST4Contest", 0); + + assertFrame("LOCKSKED: \"KST4Contest\" \"\" \"KST4Contest\"", receive()); + /* + * Unix seconds, frequency in 0.1 kHz, Win-Test band ID 14 (432 MHz), + * mode 0 (CW), then the quoted callsign and notes. The degree sign is + * escaped as the four ASCII characters \260. + */ + assertFrame("ADDSKED: \"KST4Contest\" \"\" 1760000000 4322000 14 0 \"DL1ABC\"" + + " \"[JO62QM - 47\\260] sked via KST4Contest\"", receive()); + assertFrame("UNLOCKSKED: \"KST4Contest\" \"\" \"KST4Contest\"", receive()); + + assertEquals(1, statusMessages.size()); + assertEquals("Sked pushed to WT: DL1ABC", statusMessages.get(0).getRunningInformation()); + assertFalse(statusMessages.get(0).isCriticalState()); + } + + @Test + void unexpectedModeIdIsSentAsSsbAndMissingNotesAsEmptyQuotes() throws Exception { + ContestSked sked = new ContestSked("OK1XYZ", 120.0, SKED_TIME_MS, Band.B_144); + + sender.pushSkedToWinTest(sked, "OK1XYZ", 144300.0, null, 7); + + receive(); // LOCKSKED + assertFrame("ADDSKED: \"KST4Contest\" \"\" 1760000000 1443000 12 1 \"OK1XYZ\" \"\"", receive()); + receive(); // UNLOCKSKED + } + + private byte[] receive() throws Exception { + byte[] buffer = new byte[2048]; + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + winTest.receive(packet); + return Arrays.copyOf(packet.getData(), packet.getLength()); + } + + /** + * Asserts the Win-Test framing: the ASCII text, one checksum byte (the sum + * of all preceding bytes, OR 0x80, truncated to a byte) and a NUL. + */ + private static void assertFrame(String expectedText, byte[] actual) { + byte[] text = expectedText.getBytes(StandardCharsets.US_ASCII); + int sum = 0; + for (byte b : text) { + sum += b & 0xFF; + } + byte[] expected = Arrays.copyOf(text, text.length + 2); + expected[text.length] = (byte) ((sum | 0x80) & 0xFF); + expected[text.length + 1] = 0; + + assertEquals(expectedText, new String(actual, 0, Math.max(0, actual.length - 2), StandardCharsets.US_ASCII)); + assertArrayEquals(expected, actual); + } +} diff --git a/github_docs/de-Changelog.md b/github_docs/de-Changelog.md index ef9e3b26..2ae3dc49 100644 --- a/github_docs/de-Changelog.md +++ b/github_docs/de-Changelog.md @@ -50,6 +50,8 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un - **UCXLog-Pakete werden nicht mehr abgeschnitten:** Nach einem kurzen UDP-Paket las der Empfänger für UCXLog-kompatible Logprogramme jedes längere Folgepaket nur bis zur Länge des kurzen, sodass Logeinträge unvollständig ankommen konnten. Außerdem schließt das Trennen den Socket des Empfängers jetzt sofort, statt bis zum nächsten Timeout zu warten. UCXLog-Pakete und Antworten von AirScout werden jetzt immer als UTF-8 dekodiert statt im Standardzeichensatz des Systems, sodass Umlaute in Stationsnamen und Operator-Rufzeichen unter Windows nicht mehr verstümmelt ankommen. Damit ist [Issue #115](https://github.com/praktimarc/kst4contest/issues/115) behoben. +- **UCXLog/DXLog-Empfänger läuft weiter:** Eine nicht beschreibbare Sicherungsdatei für empfangene QSOs oder ein RadioInfo-Paket ohne Frequenz oder Betriebsart hielt den Empfänger für UCXLog-kompatible Logprogramme bis zum nächsten Neustart an, sodass weder gearbeitete Stationen noch QRG-Änderungen ankamen. Jetzt wird der Sicherungseintrag beziehungsweise das unvollständige Paket übersprungen, und der Empfänger empfängt weiter. Jeder andere unerwartete Fehler beim Verarbeiten eines Pakets verwirft jetzt nur dieses Paket und erscheint als Statusmeldung, statt den Empfänger anzuhalten. + ### Hinweise zur Aktualisierung - Bestehende Installationen werden **nicht** verändert: `preferences.xml` und `praktiKST.db` bleiben genau dort liegen, wo sie sind, und werden zum Profil **Default**. Es wird keine Datei verschoben oder kopiert. diff --git a/github_docs/en-Changelog.md b/github_docs/en-Changelog.md index 72e333f0..b18fb590 100644 --- a/github_docs/en-Changelog.md +++ b/github_docs/en-Changelog.md @@ -50,6 +50,8 @@ Several operators sharing one computer can now use their own callsigns, locators - **UCXLog packets are no longer cut off:** after a short UDP packet, the listener for UCXLog-compatible loggers read every longer following packet only up to the length of the short one, so log entries could arrive incomplete. In addition, disconnecting now closes the listener's socket immediately instead of waiting for the next timeout. UCXLog packets and AirScout replies are now always decoded as UTF-8 instead of the system default, so umlauts in station names and operator callsigns no longer turn into garbage on Windows. This fixes [Issue #115](https://github.com/praktimarc/kst4contest/issues/115). +- **UCXLog/DXLog listener keeps running:** an unwritable backup file for received QSOs or a RadioInfo packet without frequency or mode used to stop the listener for UCXLog-compatible loggers until the next restart, so neither worked stations nor QRG updates arrived any more. The backup entry or the incomplete packet is now skipped, and the listener keeps receiving. Any other unexpected error while processing a packet now drops only that packet and shows a status message instead of stopping the listener. + ### Upgrade notes - Existing installations are **not** modified: `preferences.xml` and `praktiKST.db` stay exactly where they are and become the **Default** profile. No file is moved or copied.