From e1e6fe7297b234eaeba786b2ad6869f568eca688 Mon Sep 17 00:00:00 2001 From: Philipp Wagner Date: Fri, 16 Oct 2026 10:21:37 +0200 Subject: [PATCH] Make MessageBusManagementThread testable, first slice (#105) (#123) * test: restore the frequency-context and Band.fromPrefix tests (#105) The test file MessageBusManagementThreadFrequencyContextTest sat under src/main, fully commented out, and was deleted with #62 without ever having run. Its two concerns come back as real tests: the bare three-digit context rule of hasExplicitBareFrequencyContext in the controller test package, and Band.fromPrefix as BandFromPrefixTest in the model package. The original expectations still match the current behaviour; a few boundary cases (context window, invalid indices, non-exact prefixes) are added. Co-Authored-By: Claude Fable 5.1 * test: characterise the autoanswer path of MessageBusManagementThread (#105) Runs real CH frames through a started thread (receive queue plus poison pill, real transmit queue, mocked controller collaborators, a real ChatPreferences in a temporary directory) and pins the current behaviour of the private-message branch before it is refactored: - a QRG question addressed to the login callsign yields exactly one reply with the fixed marker and the QRG of the incoming category; - a second question from the same station and category within the cooldown yields none, while another station or category is answered; - a frame carrying the autoanswer marker, a public frame, a frame to another station and a disabled setting yield none; - an empty QRG and a blank generic text are rejected before the cooldown is recorded, so a later valid reply still goes out; - //VER, the generic reply and its precedence below the QRG reply. One oddity is pinned, not fixed: a lower-case login callsign defeats the "directed to me" comparison because the receiver callsign is upper-cased by ChatMember.setCallSign but compared with equals. Co-Authored-By: Claude Fable 5.1 * refactor: extract the autoanswer decisions into pure functions (#105) The QRG-question texts become a constant set; matchesQrgQuestion, isAutoMessage and buildAutoAnswerMessageText are static package-private functions with explicit parameters instead of reaching into the preferences, and the cooldown reads an injectable millisecond clock (default System::currentTimeMillis) through a new constructor overload. Behaviour is unchanged: the characterisation test from the previous commit passes untouched, the new decision and cooldown tests cover the extracted functions without mocks. Co-Authored-By: Claude Fable 5.1 * refactor: extract frequency detection into a pure function (#105) smartFrequencyExtraction now copies the sender's band timestamps, reads the fallback prefix and the injectable clock, and hands the text to the static package-private detectFrequencies, which returns the detections in message order as DetectedChatFrequency records. The thread still stores each one through applyDetectedFrequencyToActiveMembers and logs it as before. Behaviour is unchanged. In production the stored detection lands in the same map the next match of the message reads, so detectFrequencies feeds every detection back as context with the current time; a test pins that "10368.100 then .150" yields 10368.150. The unreachable NumberFormatException catches around parseExplicitFrequency are gone, since that parser already returns null instead of throwing. Also pinned: a plain decimal between 50 and 54 (e.g. "qrb 52,3 km") is read as a 6 m frequency. Co-Authored-By: Claude Opus 5.5 * refactor: extract the directional-opportunity and spot decisions (#105) classifyDirectionalOpportunity returns MISSING_LOCATOR, IN_ANGLE_AND_RANGE or OUT_OF_ANGLE_OR_RANGE from the locators, the maximum QRB and the beam width, and replaces the identical locator check plus isInAngleAndRange call in both the live (CH) and the history (CR) branch. shouldPlayDirectionalTick, decideDxClusterSpot (NO_FREQUENCY, ALREADY_WORKED, SPOT) and buildDxClusterSpotEntry take over the tick condition and the automatic DX Cluster spot of the CH branch. All are static package-private and covered without mocks. Behaviour is unchanged. Pinned: the spot decision only checks that the frequency text is non-blank, so a value outside every supported band is spotted even for a station flagged as worked. Co-Authored-By: Claude Opus 5.5 * refactor: remove dead code from MessageBusManagementThread (#105) processRXMessage23000 belonged to the long-retired raw port-23000 interface and had no caller. With it go the two helpers only it reached, checkIfItsUserListEntry and checkIfMessageInhibitsFrequency, plus the caller-less checkIfItsUserListEntry23001 and checkListForChatMemberIndexByCallSign, the patterns PTRN_USERLISTENTRY, PTRN_QRG_CAT2 and PTRN_QRG_CAT3, the unused chatMemberTable field and the serverReady flag with its never-called accessors. A grep across core and app-desktop finds no remaining reference. Co-Authored-By: Claude Opus 5.5 * refactor: keep the sender-category fallback in the QRG auto-answer warning (#105) Review follow-up. The extracted buildAutoAnswerMessageText logged only the message category, while the old warning used autoAnswerCooldownKey, which falls back to the sender's category. The caller now passes the same fallback, so the log line is unchanged from main. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Fable 5.1 --- .../MessageBusManagementThread.java | 956 +++++++++--------- ...anagementThreadAutoAnswerCooldownTest.java | 125 +++ ...anagementThreadAutoAnswerDecisionTest.java | 270 +++++ ...sageBusManagementThreadAutoAnswerTest.java | 384 +++++++ ...ManagementThreadDetectFrequenciesTest.java | 223 ++++ ...mentThreadDirectionalSpotDecisionTest.java | 162 +++ ...sManagementThreadFrequencyContextTest.java | 104 ++ .../kst4contest/model/BandFromPrefixTest.java | 40 + 8 files changed, 1795 insertions(+), 469 deletions(-) create mode 100644 core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerCooldownTest.java create mode 100644 core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerDecisionTest.java create mode 100644 core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerTest.java create mode 100644 core/src/test/java/kst4contest/controller/MessageBusManagementThreadDetectFrequenciesTest.java create mode 100644 core/src/test/java/kst4contest/controller/MessageBusManagementThreadDirectionalSpotDecisionTest.java create mode 100644 core/src/test/java/kst4contest/controller/MessageBusManagementThreadFrequencyContextTest.java create mode 100644 core/src/test/java/kst4contest/model/BandFromPrefixTest.java diff --git a/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java index 667e7491..e285ac27 100644 --- a/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -6,10 +6,12 @@ import java.sql.SQLException; //import java.net.Socket; //import java.util.ArrayList; import java.util.ArrayList; -import java.util.HashSet; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import java.util.Hashtable; import java.util.Locale; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.logging.Level; @@ -22,6 +24,7 @@ import kst4contest.model.*; import kst4contest.logic.FrequencyTextParser; import java.util.concurrent.LinkedBlockingQueue; import java.util.function.LongPredicate; +import java.util.function.LongSupplier; import kst4contest.observe.SimpleValue; /** @@ -51,12 +54,6 @@ public class MessageBusManagementThread extends Thread { // private File fileLogRAW; // private TimerTask userActualizationTask; // Is used as a temporary userout-print // private TimerTask userActualizationTask; //kst4contest.test 4 23001 - // private boolean serverReady = false; //kst4contest.test 4 23001 - private boolean serverReady = true; - private Hashtable chatMemberTable; - private final String PTRN_USERLISTENTRY = "([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}"; - private final String PTRN_QRG_CAT2 = "(([0-9]{3,4}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)|(([a-zA-Z][0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)|((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)"; - private final String PTRN_QRG_CAT3 = "(([0-9]{3,5}[\\.|,| ]?[0-9]{3})([\\.|,][\\d]{1,2})?)|(([a-zA-Z][0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)|((\\b[0-4]{1}[\\d]{2}\\b)([\\.|,][\\d]{1,2}\\b)?)"; /* * Frequency formats handled by the smart parser: @@ -104,10 +101,25 @@ public class MessageBusManagementThread extends Thread { // ==== Auto-answer flood/ping-pong protection ==== private static final String AUTOANSWER_PREFIX = ApplicationConstants.AUTOANSWER_PREFIX; - private static final long AUTOANSWER_COOLDOWN_MS = 120_000L; // two minutes + static final long AUTOANSWER_COOLDOWN_MS = 120_000L; // two minutes + + /* + * Lower-case fragments that identify a question for the local QRG. An incoming + * private message is matched case-insensitively against each of them. + */ + static final Set QRG_QUESTION_TEXTS = Set.of( + "ur qrg?", + "your qrg?", + "qrg?", + "freq?", + "pse qrg" + ); // Cooldown per remote station and chat category; updated only after this client sends. private final Hashtable lastLocalAutoAnswerPerRemoteMs = new Hashtable<>(); + + // Wall clock for the autoanswer cooldown; injectable for tests. + private final LongSupplier millisClock; // BufferedWriter bufwrtrDBGMSGOut; // private String text; @@ -120,14 +132,6 @@ public class MessageBusManagementThread extends Thread { // this.bufwrtrDBGMSGOut = bufwrtrDBGMSGOut; // } - public boolean isServerready() { - return serverReady; - } - - public void setServerready(boolean serverReady) { - this.serverReady = serverReady; - } - public MessageBusManagementThread(ChatController client, ThreadStatusCallback callBack) { this(client, callBack, 0L, client.getMessageRXBus(), ignored -> true); } @@ -139,134 +143,38 @@ public class MessageBusManagementThread extends Thread { LinkedBlockingQueue receiveQueue, LongPredicate connectionSessionIsActive ) { + this(client, callBack, connectionSessionId, receiveQueue, + connectionSessionIsActive, System::currentTimeMillis); + } + + /** + * Full constructor with an explicit wall clock. + * + * @param millisClock source of the current time in epoch milliseconds, used for + * the autoanswer cooldown; production code passes + * {@code System::currentTimeMillis} + */ + MessageBusManagementThread( + ChatController client, + ThreadStatusCallback callBack, + long connectionSessionId, + LinkedBlockingQueue receiveQueue, + LongPredicate connectionSessionIsActive, + LongSupplier millisClock + ) { this.callBackToController = callBack; this.client = client; this.connectionSessionId = connectionSessionId; this.receiveQueue = receiveQueue; this.connectionSessionIsActive = connectionSessionIsActive; + this.millisClock = millisClock; ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false); callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); } - /** - * check if a chatmessage is part of the userlist via telnet 23000 port
- * Updates userlist! - * - */ - private void checkIfItsUserListEntry(ChatMessage messageToProcess) { - - Pattern pattern = Pattern.compile(PTRN_USERLISTENTRY); - Matcher matcher = pattern.matcher(messageToProcess.getMessageText()); - /** - * "([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}" 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); - -// this.client.getChatMemberTable().put(member.getCallSign(), member);//deleted cause change if list type - -// if (member.getName().) - -// System.out.println("Processed Userlist Entry [" + this.client.getChatMemberTable().size() + "]: Call: " -// + member.getCallSign() + ", QRA: " + member.getQra() + ", Name: " + member.getName()); - } - } - - /** - * check if a chatmessage is part of the userlist via telnet 23000 port
- * This method updates the userlist! - */ - private void checkIfItsUserListEntry23001(ChatMessage messageToProcess) { - - Pattern pattern = Pattern.compile(PTRN_USERLISTENTRY); - Matcher matcher = pattern.matcher(messageToProcess.getMessageText()); - /** - * "([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}" 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); - -// this.client.getChatMemberTable().put(member.getCallSign(), member); - -// if (member.getName().) - -// System.out.println("[MSGBUSMGT:] Processed Userlist Entry [" + this.client.getChatMemberTable().size() -// + "]: Call: " + member.getCallSign() + ", QRA: " + member.getQra() + ", Name: " + member.getName()); - } - } - - /** - * check if a chatmessage or a name of a chatmember contains a frequency
- * returns String = "" if no frequency found - * - - */ - private String checkIfMessageInhibitsFrequency(ChatMessage messageToProcess) { - - Pattern pattern = Pattern.compile(PTRN_QRG_CAT2); // TODO: PTRN should depend to category-selection of own stn, it´s not the case now - Matcher matcher = pattern.matcher(messageToProcess.getMessageText()); - String[] splittedQRGString; -// splittedQRGString[0] = "0"; - - String stringAggregation = ""; - - - while (matcher.find()) { - String matchedString = matcher.group(); - - splittedQRGString = matchedString.split(" "); - - for (String s : splittedQRGString) { - stringAggregation += s + " "; - } - - LOGGER.log(Level.FINE, "Processed QRG info: {0}", stringAggregation); - -// System.out.println("Processed QRG Entry [" + this.client.getChatMemberTable().size() + "]: Call: " -// + member.getCallSign() + ", QRA: " + member.getQra() + ", Name: " + member.getName()); - } - return stringAggregation; - } - /** * Detects complete and relative frequencies in a chat message and stores the * result on the sender. @@ -293,7 +201,127 @@ public class MessageBusManagementThread extends Thread { return; } - String messageText = message.getMessageText(); + Map bandContextTimestamps = new EnumMap<>(Band.class); + if (sender.getKnownActiveBands() != null) { + for (Map.Entry entry + : sender.getKnownActiveBands().entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + bandContextTimestamps.put(entry.getKey(), entry.getValue().timestampEpoch); + } + } + } + + String configuredPrefix = null; + if (prefs != null + && prefs.getNotify_optionalFrequencyPrefix() != null) { + configuredPrefix = + prefs.getNotify_optionalFrequencyPrefix().get(); + } + + List detectedFrequencies = detectFrequencies( + message.getMessageText(), + bandContextTimestamps, + configuredPrefix, + millisClock.getAsLong() + ); + + for (DetectedChatFrequency detected : detectedFrequencies) { + /* + * Store the result in the thread-safe active-member model. The existing + * compatibility property used by the TableView and DX-Cluster code is + * updated by applyDetectedFrequencyToActiveMembers(...), too. + */ + client.applyDetectedFrequencyToActiveMembers( + sender, + detected.band(), + detected.frequencyMHz() + ); + + LOGGER.log(Level.FINE, + "[SmartParser] Detected for " + + sender.getCallSign() + + ": " + + detected.frequencyMHz() + + " MHz (" + + detected.band() + + ") " + + (detected.derivedFromShortForm() + ? "[derived from " + detected.rawMatch() + "]" + : "[full match]") + ); + } + } + + /** + * One frequency found in a chat message by + * {@link #detectFrequencies(String, Map, String, long)}. + * + * @param band band the frequency belongs to + * @param frequencyMHz frequency in MHz, always greater than zero + * @param rawMatch matched text with commas replaced by dots and, for a relative + * value, without its leading dot + * @param derivedFromShortForm {@code true} when the value was relative + * ({@code .210}) or bare ({@code 210}) and the band + * had to be taken from context + */ + record DetectedChatFrequency( + Band band, + double frequencyMHz, + String rawMatch, + boolean derivedFromShortForm + ) { + } + + /** + * Detects complete and relative frequencies in a chat message, in the order in + * which they occur. + * + *

Complete frequencies determine their band directly. A relative or bare + * value is completed with the band whose most recent observation is the newest + * among those not older than 30 minutes ({@code nowMillis - timestamp <= 1_800_000}) + * and whose band range contains the completed frequency. Only when no such band + * exists is the configured fallback prefix used; an unknown or missing prefix + * falls back to 144 MHz.

+ * + *

Every detection is fed back as band context with timestamp + * {@code nowMillis} before the next match is examined. This reproduces the + * production path, where storing a detection on the sender updates the very + * map the next match reads: in {@code "10368.100 then .150"} the second value + * becomes 10368.150, not 144.150.

+ * + *

A bare three-digit value is accepted only with nearby frequency-related + * text, see {@link #hasExplicitBareFrequencyContext(String, int, int)}.

+ * + * @param messageText chat message text, may be {@code null} + * @param bandContextTimestamps epoch milliseconds of the sender's most recent + * observation per band; not modified; may be + * {@code null} + * @param fallbackPrefix configured fallback band prefix such as {@code "432"}, + * may be {@code null} + * @param nowMillis current time in epoch milliseconds + * @return detected frequencies; never {@code null} + */ + static List detectFrequencies( + String messageText, + Map bandContextTimestamps, + String fallbackPrefix, + long nowMillis + ) { + List result = new ArrayList<>(); + + if (messageText == null) { + return result; + } + + Map bandContext = new EnumMap<>(Band.class); + if (bandContextTimestamps != null) { + for (Map.Entry entry : bandContextTimestamps.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + bandContext.put(entry.getKey(), entry.getValue()); + } + } + } + Matcher matcher = SMART_FREQUENCY_PATTERN.matcher(messageText); while (matcher.find()) { @@ -327,49 +355,31 @@ public class MessageBusManagementThread extends Thread { */ long bestTimestamp = 0L; - if (sender.getKnownActiveBands() != null) { - for (java.util.Map.Entry entry - : sender.getKnownActiveBands().entrySet()) { + for (Map.Entry entry : bandContext.entrySet()) { + Band candidateBand = entry.getKey(); + long timestamp = entry.getValue(); - Band candidateBand = entry.getKey(); - ChatMember.ActiveFrequencyInfo info = entry.getValue(); + if (nowMillis - timestamp > 1_800_000L) { + continue; + } - if (candidateBand == null || info == null) { - continue; - } + FrequencyTextParser.DetectedFrequency detectedFrequency = + FrequencyTextParser.parseExplicitFrequency( + candidateBand.getPrefix() + "." + foundRaw + ); - if (System.currentTimeMillis() - info.timestampEpoch > 1_800_000L) { - continue; - } + if (detectedFrequency != null + && detectedFrequency.getBand() == candidateBand + && timestamp > bestTimestamp) { - try { - String reconstructed = - candidateBand.getPrefix() - + "." - + foundRaw; + finalDetectedFrequency = + detectedFrequency.getFrequencyMHz(); - FrequencyTextParser.DetectedFrequency detectedFrequency = - FrequencyTextParser.parseExplicitFrequency( - reconstructed - ); + finalDetectedBand = + candidateBand; - if (detectedFrequency != null - && detectedFrequency.getBand() == candidateBand - && info.timestampEpoch > bestTimestamp) { - - finalDetectedFrequency = - detectedFrequency.getFrequencyMHz(); - - finalDetectedBand = - candidateBand; - - bestTimestamp = - info.timestampEpoch; - - } - } catch (NumberFormatException ignored) { - // Try the next known band. - } + bestTimestamp = + timestamp; } } @@ -379,41 +389,24 @@ public class MessageBusManagementThread extends Thread { * The current UI itself offers only values from Band.values(). */ if (finalDetectedBand == null) { - String configuredPrefix = null; - - if (prefs != null - && prefs.getNotify_optionalFrequencyPrefix() != null) { - configuredPrefix = - prefs.getNotify_optionalFrequencyPrefix().get(); - } - - Band fallbackBand = Band.fromPrefix(configuredPrefix); + Band fallbackBand = Band.fromPrefix(fallbackPrefix); if (fallbackBand == null) { fallbackBand = Band.B_144; } - try { - String reconstructed = - fallbackBand.getPrefix() - + "." - + foundRaw; + FrequencyTextParser.DetectedFrequency detectedFrequency = + FrequencyTextParser.parseExplicitFrequency( + fallbackBand.getPrefix() + "." + foundRaw + ); - FrequencyTextParser.DetectedFrequency detectedFrequency = - FrequencyTextParser.parseExplicitFrequency( - reconstructed - ); + if (detectedFrequency != null + && detectedFrequency.getBand() == fallbackBand) { - if (detectedFrequency != null - && detectedFrequency.getBand() == fallbackBand) { + finalDetectedFrequency = + detectedFrequency.getFrequencyMHz(); - finalDetectedFrequency = - detectedFrequency.getFrequencyMHz(); - - finalDetectedBand = - fallbackBand; - } - } catch (NumberFormatException ignored) { - // The matched value cannot be converted into a frequency. + finalDetectedBand = + fallbackBand; } } } else { @@ -435,30 +428,19 @@ public class MessageBusManagementThread extends Thread { continue; } - /* - * Store the result in the thread-safe active-member model. The existing - * compatibility property used by the TableView and DX-Cluster code is - * updated by applyDetectedFrequencyToActiveMembers(...), too. - */ - client.applyDetectedFrequencyToActiveMembers( - sender, + result.add(new DetectedChatFrequency( finalDetectedBand, - finalDetectedFrequency - ); + finalDetectedFrequency, + foundRaw, + shortForm + )); - LOGGER.log(Level.FINE, - "[SmartParser] Detected for " - + sender.getCallSign() - + ": " - + finalDetectedFrequency - + " MHz (" - + finalDetectedBand - + ") " - + (shortForm - ? "[derived from " + foundRaw + "]" - : "[full match]") - ); + // The caller stores this detection on the sender, which makes it the + // newest context for the remaining matches of the same message. + bandContext.put(finalDetectedBand, nowMillis); } + + return result; } @@ -530,122 +512,6 @@ public class MessageBusManagementThread extends Thread { // } - /** - * Builds UserList and gets meta informations out of the chat, as far as it is - * possible. \n This is the only place where the Chatmember-List will be written - * - * Old Method for port 23000, raw text interface without any comfort, no longer used - * @param messageToProcess - */ - private void processRXMessage23000(ChatMessage messageToProcess) { - - String reduce; - - reduce = new String(messageToProcess.getMessageText()); - reduce = reduce.replaceAll("\\s+", " "); // reduce bursts of spaces to one space sign - - messageToProcess.setMessageText(reduce); - - if (messageToProcess.getMessageText() == null - || messageToProcess.getMessageText().isEmpty()) { -// System.out.println("[MSGBUSMGTT:] ###################### no processable data"); - } else { - - if (reduce.length() >= 14 && reduce.length() <= 40) { - checkIfItsUserListEntry(messageToProcess); // 23001 kst4contest.test unneccessary - } - - checkIfMessageInhibitsFrequency(messageToProcess); - } - } - -// private boolean isUserInTheUserTable(String chatMemberCallsign) { -// -// String checkThisCallsign = chatMemberCallsign; -// -// if (this.client.getChatMemberTable().containsKey(checkThisCallsign)) { -// return true; -// } else -// return false; -// -// } - - /** - * checks if the callsign-String of a given chatmember instance and a given list - * instance is in the list. If yes, returns the index in the List, if not, - * returns -1. - * - * @param lookForThis - * @return Integer (index), -1 for not found - */ - private int checkListForChatMemberIndexByCallSign(List list, ChatMember lookForThis) { - - if (lookForThis == null) { - - LOGGER.warning( - "[ChecklistForChatMemberIndexByCallsign] ERROR: null Value for Chatmember detected! Member cannot be in the list!"); - return -1; - } else if (lookForThis.getCallSign() == null) { - LOGGER.warning( - "[ChecklistForChatMemberIndexByCallsign] ERROR: null Value in Callsign detected! Member cannot be in the list!"); - return -1; - } - /*** - * Old mechanic for index search, new one implemented due concurrentmodificationexc, which works - start - * - */ -// for (Iterator iterator = list.iterator(); iterator.hasNext();) { -// ChatMember chatMember = (ChatMember) iterator.next(); -// if (chatMember.getCallSign().equals(lookForThis.getCallSign())) { -//// System.out -//// .println("MSGBUSHELBER: Found " + chatMember.getCallSign() + " at " + list.indexOf(chatMember)); -// -// return list.indexOf(chatMember); -// } else { -// -// } -// } -// -// System.out.println("[MsgBusMgr, ERROR:] ChecklistForChatMemberIndexByCallsign, not found: " -// + lookForThis.getCallSign() + "\n "); - /*** - * /Old mechanic for index search,new one implemented due concurrentmodificationexc which works - end - * - */ - - for (int i = 0; i < list.size(); i++) { - if (list.get(i).getCallSign().equals(lookForThis.getCallSign())) { - //TODO: New since 1.26! Check against category! - - LOGGER.fine("Checking chat categories for list member " - + list.get(i).getCallSign() + " / " - + list.get(i).getChatCategory() + " against " - + lookForThis.getCallSign() + " / " - + lookForThis.getChatCategory()); - -// System.out -// .println("MSGBUSHELBER: Found " + chatMember.getCallSign() + " at " + list.indexOf(chatMember)); - - if (list.get(i).getChatCategory().equals(lookForThis.getChatCategory())) { //new 1.26 - - return list.indexOf(list.get(i)); - } //new 1.26 - else { - LOGGER.fine("Chat-member category does not match"); - - } - -// System.out.println("--------------------------- chatcategory of list.get(i) = " + list.get(i).getChatCategory().getCategoryNumber()); - LOGGER.log(Level.FINE, - "Requested chat-member category is {0}", - lookForThis.getChatCategory().getCategoryNumber()); - } -// return list.indexOf(list.get(i)); //if no category found, return entry //TODO: ERROR detected here! Should work now, needs some proof -// return -1; //if category dont match, return: member not found - } - return -1; // if it´s not found, the method will always end here and return -1 - } - /** * Creates a minimal receiver object for ON4KST broadcast messages. * Keeping this as an object, not null, protects all downstream chat filters @@ -867,22 +733,6 @@ public class MessageBusManagementThread extends Thread { final String SRVR_LOGINWRONGCALLSYNTAX = "103"; final String SRVR_LOGINWRONGCALLUNKNOWN = "101"; - /** - * here we have a helper Set for identifying questions for my qrg which can be autoanswered later // TODO: move to an extra method - */ - final HashSet qrgQuestionTexts = new HashSet(); -// final ArrayList qrgQuestionTexts = new ArrayList(); - qrgQuestionTexts.add("ur qrg?"); - qrgQuestionTexts.add("your qrg?"); - qrgQuestionTexts.add("qrg?"); - qrgQuestionTexts.add("freq?"); - qrgQuestionTexts.add("pse qrg"); - - - /** - * here we have a helper list for identifying questions for my qrg which can be autoanswered later - */ - if (messageToProcess.getMessageText() == null || messageToProcess.getMessageText().isEmpty()) { // No processable data. @@ -1185,35 +1035,37 @@ public class MessageBusManagementThread extends Thread { // ==== Unified auto-answer (generic + QRG) with ping-pong guard // and per-remote cooldown ==== final String incomingText = newMessageArrived.getMessageText(); - final String incomingLower = - (incomingText == null) - ? "" - : incomingText.toLowerCase(Locale.ROOT); // Never answer another automatically generated message. - if (!isAutoMessage(newMessageArrived)) { + if (!isAutoMessage(incomingText)) { - boolean qrgRequested = false; + ChatPreferences autoAnswerPrefs = + this.client.getChatPreferences(); - if (this.client.getChatPreferences() - .isMessageHandling_autoAnswerToQRGRequestEnabled()) { - - for (String lookForQRGString : qrgQuestionTexts) { - if (incomingLower.contains(lookForQRGString)) { - qrgRequested = true; - break; - } - } - } + boolean qrgRequested = + autoAnswerPrefs.isMessageHandling_autoAnswerToQRGRequestEnabled() + && matchesQrgQuestion(incomingText, QRG_QUESTION_TEXTS); boolean genericEnabled = - this.client.getChatPreferences() - .isMsgHandling_autoAnswerEnabled(); + autoAnswerPrefs.isMsgHandling_autoAnswerEnabled(); + + ChatMember incomingSender = newMessageArrived.getSender(); String automaticAnswerText = buildAutoAnswerMessageText( - newMessageArrived, + incomingSender == null ? null : incomingSender.getCallSign(), + // Diagnostics only: same fallback as autoAnswerCooldownKey. + newMessageArrived.getChatCategory() != null || incomingSender == null + ? newMessageArrived.getChatCategory() + : incomingSender.getChatCategory(), qrgRequested, - genericEnabled + selectAutoAnswerQrg( + newMessageArrived.getChatCategory(), + this.client.getChatCategorySecondChat(), + autoAnswerPrefs.getMYQRGFirstCat().get(), + autoAnswerPrefs.getMYQRGSecondCat().get() + ), + genericEnabled, + autoAnswerPrefs.getMessageHandling_autoAnswerTextMainCat() ); /* @@ -1281,23 +1133,21 @@ public class MessageBusManagementThread extends Thread { * In that case locators may be missing, so angle/range analysis and DX spotting * must be skipped while the chat message itself is still published. */ - boolean senderHasLocator = newMessageArrived.getSender().getQra() != null - && !newMessageArrived.getSender().getQra().isBlank(); - boolean receiverHasLocator = newMessageArrived.getReceiver().getQra() != null - && !newMessageArrived.getReceiver().getQra().isBlank(); + DirectionalOpportunity opportunity = classifyDirectionalOpportunity( + client.getChatPreferences().getStn_loginLocatorMainCat(), + newMessageArrived.getSender().getQra(), + newMessageArrived.getReceiver().getQra(), + client.getChatPreferences().getStn_maxQRBDefault(), + client.getChatPreferences().getStn_antennaBeamWidthDeg()); - if (senderHasLocator && receiverHasLocator) { - if (DirectionUtils.isInAngleAndRange(client.getChatPreferences().getStn_loginLocatorMainCat(), - newMessageArrived.getSender().getQra(), - newMessageArrived.getReceiver().getQra(), - client.getChatPreferences().getStn_maxQRBDefault(), - client.getChatPreferences().getStn_antennaBeamWidthDeg())) { + if (opportunity != DirectionalOpportunity.MISSING_LOCATOR) { + if (opportunity == DirectionalOpportunity.IN_ANGLE_AND_RANGE) { - if (this.client.getChatPreferences().isNotify_playSimpleSounds()) { - //play only tick sound if the sender was not set directedtome before - if (!newMessageArrived.getSender().isInAngleAndRange()) { - this.client.getPlayAudioUtils().playNoiseLauncher('-'); - } + //play only tick sound if the sender was not set directedtome before + if (shouldPlayDirectionalTick( + this.client.getChatPreferences().isNotify_playSimpleSounds(), + newMessageArrived.getSender().isInAngleAndRange())) { + this.client.getPlayAudioUtils().playNoiseLauncher('-'); } newMessageArrived.getSender().setInAngleAndRange(true); @@ -1306,31 +1156,21 @@ public class MessageBusManagementThread extends Thread { try { ChatMember sender = newMessageArrived.getSender(); - String detectedFrequency = sender.getFrequency() == null - ? null - : sender.getFrequency().get(); + DxClusterSpotDecision spotDecision = decideDxClusterSpot(sender); - if (detectedFrequency != null && !detectedFrequency.isBlank() - && !isAlreadyWorkedForDxClusterSpot(sender)) { + if (spotDecision == DxClusterSpotDecision.SPOT) { /* * The DX Cluster spot must not depend on AirScout data. A known * frequency and a valid directional opportunity are sufficient. * Available AP information is added only as an optional comment. */ - ChatMember onlyForSpottingObject = new ChatMember(); - onlyForSpottingObject.setCallSign(sender.getCallSign()); - onlyForSpottingObject.setFrequency(sender.getFrequency()); - onlyForSpottingObject.setQra( - buildDxClusterSpotComment(sender) - ); - this.client .getDxClusterServer() .broadcastSingleDXClusterEntryToLoggers( - onlyForSpottingObject + buildDxClusterSpotEntry(sender) ); - } else if (detectedFrequency != null && !detectedFrequency.isBlank()) { + } else if (spotDecision == DxClusterSpotDecision.ALREADY_WORKED) { // Only reached when the worked check was the reason (issue #91). LOGGER.fine("DX Cluster spot suppressed, station already worked on this band: " + sender.getCallSign()); @@ -1699,19 +1539,15 @@ public class MessageBusManagementThread extends Thread { } else { //message sent to other user // message sent from one other station to another other station - boolean senderHasLocator = newMessageArrived.getSender().getQra() != null - && !newMessageArrived.getSender().getQra().isBlank(); + DirectionalOpportunity opportunity = classifyDirectionalOpportunity( + client.getChatPreferences().getStn_loginLocatorMainCat(), + newMessageArrived.getSender().getQra(), + newMessageArrived.getReceiver().getQra(), + client.getChatPreferences().getStn_maxQRBDefault(), + client.getChatPreferences().getStn_antennaBeamWidthDeg()); - boolean receiverHasLocator = newMessageArrived.getReceiver().getQra() != null - && !newMessageArrived.getReceiver().getQra().isBlank(); - - if (senderHasLocator && receiverHasLocator) { - if (DirectionUtils.isInAngleAndRange( - client.getChatPreferences().getStn_loginLocatorMainCat(), - newMessageArrived.getSender().getQra(), - newMessageArrived.getReceiver().getQra(), - client.getChatPreferences().getStn_maxQRBDefault(), - client.getChatPreferences().getStn_antennaBeamWidthDeg())) { + if (opportunity != DirectionalOpportunity.MISSING_LOCATOR) { + if (opportunity == DirectionalOpportunity.IN_ANGLE_AND_RANGE) { newMessageArrived.getSender().setInAngleAndRange(true); @@ -1852,7 +1688,6 @@ public class MessageBusManagementThread extends Thread { // this.client.getLst_chatMemberList().remove(thisMemberActualizesUserListForRefreshingIntheGuy); // ******************************************************************QUICKNDIRTY........ -// checkIfMessageInhibitsFrequency(messageToProcess); } } @@ -1965,6 +1800,133 @@ public class MessageBusManagementThread extends Thread { : locator + " " + apComment; } + /** + * Outcome of the directional check for a message between two other stations. + */ + enum DirectionalOpportunity { + /** Sender or receiver locator is missing or blank; nothing was calculated. */ + MISSING_LOCATOR, + /** The sender beams towards the receiver and the local station is covered. */ + IN_ANGLE_AND_RANGE, + /** Both locators are known, but the local station is outside beam or range. */ + OUT_OF_ANGLE_OR_RANGE + } + + /** + * Decides whether a message from one other station to another one is a + * directional opportunity ("Richtungs-Chance") for the local station. + * + *

Without both a sender and a receiver locator the geometry is not + * evaluated at all. Otherwise the decision is + * {@link DirectionUtils#isInAngleAndRange(String, String, String, double, double)}: + * the local station must be within {@code maxQrbKm} of the sender and inside + * the sender's beam towards the receiver.

+ * + * @param ownLocator locator of the local station + * @param senderLocator locator of the sending station, may be {@code null} + * @param receiverLocator locator of the receiving station, may be {@code null} + * @param maxQrbKm maximum distance from the local station to the sender + * @param beamWidthDeg assumed antenna beam width of the sender in degrees + * @return the classification of the message + */ + static DirectionalOpportunity classifyDirectionalOpportunity( + String ownLocator, + String senderLocator, + String receiverLocator, + double maxQrbKm, + double beamWidthDeg + ) { + boolean senderHasLocator = senderLocator != null && !senderLocator.isBlank(); + boolean receiverHasLocator = receiverLocator != null && !receiverLocator.isBlank(); + + if (!senderHasLocator || !receiverHasLocator) { + return DirectionalOpportunity.MISSING_LOCATOR; + } + + return DirectionUtils.isInAngleAndRange( + ownLocator, + senderLocator, + receiverLocator, + maxQrbKm, + beamWidthDeg) + ? DirectionalOpportunity.IN_ANGLE_AND_RANGE + : DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE; + } + + /** + * Returns whether the short tick sound for a new directional opportunity is + * played: only with simple sounds enabled and only on the transition into + * the opportunity, not again while the sender is already marked. + * + * @param simpleSoundsEnabled whether simple notification sounds are enabled + * @param senderAlreadyInAngleAndRange the sender's flag before this message + * @return {@code true} when the tick is to be played + */ + static boolean shouldPlayDirectionalTick( + boolean simpleSoundsEnabled, + boolean senderAlreadyInAngleAndRange + ) { + return simpleSoundsEnabled && !senderAlreadyInAngleAndRange; + } + + /** + * Outcome of the automatic DX Cluster spot decision for a directional opportunity. + */ + enum DxClusterSpotDecision { + /** The sender has no known frequency; nothing is spotted or logged. */ + NO_FREQUENCY, + /** The sender is already worked on the band of its frequency (issue #91). */ + ALREADY_WORKED, + /** The spot is to be sent. */ + SPOT + } + + /** + * Decides whether an automatic DX Cluster spot is sent for the sender of a + * directional opportunity. Whether the local DX Cluster server is enabled at + * all is checked by the caller. + * + *

A frequency counts as known when its text is neither {@code null} nor + * blank; it is not parsed here. A text that names no supported band therefore + * still leads to {@link DxClusterSpotDecision#SPOT}, see + * {@link #isAlreadyWorkedForDxClusterSpot(ChatMember)}.

+ * + * @param sender station that triggered the directional opportunity, may be {@code null} + * @return the decision + */ + static DxClusterSpotDecision decideDxClusterSpot(ChatMember sender) { + String detectedFrequency = sender == null || sender.getFrequency() == null + ? null + : sender.getFrequency().get(); + + if (detectedFrequency == null || detectedFrequency.isBlank()) { + return DxClusterSpotDecision.NO_FREQUENCY; + } + + return isAlreadyWorkedForDxClusterSpot(sender) + ? DxClusterSpotDecision.ALREADY_WORKED + : DxClusterSpotDecision.SPOT; + } + + /** + * Builds the entry handed to the local DX Cluster server for an automatic spot: + * the sender's callsign, the sender's frequency value (the same instance, not a + * copy) and, in the locator field, the comment from + * {@link #buildDxClusterSpotComment(ChatMember)}. + * + * @param sender station to spot + * @return a new member object used only for spotting + */ + static ChatMember buildDxClusterSpotEntry(ChatMember sender) { + ChatMember onlyForSpottingObject = new ChatMember(); + onlyForSpottingObject.setCallSign(sender.getCallSign()); + onlyForSpottingObject.setFrequency(sender.getFrequency()); + onlyForSpottingObject.setQra( + buildDxClusterSpotComment(sender) + ); + return onlyForSpottingObject; + } + /** * Method gets a String with a messagecategory-number and returns out of which of the existing categories * (chat channels) this message/user had written from @@ -1994,6 +1956,71 @@ public class MessageBusManagementThread extends Thread { } + /** + * Returns whether a private message asks for the local QRG. + * + *

The comparison is a case-insensitive substring match against each of the + * given fragments, so {@code "GM, PSE QRG for a sked"} matches {@code "pse qrg"}.

+ * + * @param text incoming message text, may be {@code null} + * @param questionTexts lower-case fragments that identify a QRG question + * @return {@code true} when any fragment occurs in the text + */ + static boolean matchesQrgQuestion(String text, Set questionTexts) { + if (text == null || questionTexts == null) { + return false; + } + + String lower = text.toLowerCase(Locale.ROOT); + + for (String questionText : questionTexts) { + if (lower.contains(questionText)) { + return true; + } + } + + return false; + } + + /** + * Returns whether a message carries the fixed marker used for automatic replies. + * Such a message must never be answered automatically (ping-pong guard). + * + * @param text message text, may be {@code null} + * @return {@code true} when the marker occurs anywhere in the text + */ + static boolean isAutoMessage(String text) { + return text != null && text.contains(AUTOANSWER_PREFIX); + } + + /** + * Selects the configured QRG for the category in which a request was received. + * + *

Only a request from the second chat category is answered with the second + * QRG. Any other category, including an unresolved one, falls back to the first + * QRG. Categories are compared by number, not by identity.

+ * + * @param incomingCategory category of the incoming message, may be {@code null} + * @param secondCategory configured second chat category, may be {@code null} + * @param firstCategoryQrg QRG configured for the main category, may be {@code null} + * @param secondCategoryQrg QRG configured for the second category, may be {@code null} + * @return the QRG belonging to the incoming category + */ + static String selectAutoAnswerQrg( + ChatCategory incomingCategory, + ChatCategory secondCategory, + String firstCategoryQrg, + String secondCategoryQrg + ) { + if (incomingCategory != null + && secondCategory != null + && incomingCategory.getCategoryNumber() == secondCategory.getCategoryNumber()) { + return secondCategoryQrg; + } + + return firstCategoryQrg; + } + /** * Builds and validates one automatic private reply. * @@ -2006,21 +2033,24 @@ public class MessageBusManagementThread extends Thread { * queue. Invalid configuration values must neither produce an empty * automatic reply nor start the cooldown.

* - * @param incoming incoming private message + * @param senderCallSign callsign of the station that sent the private message + * @param category chat category of the incoming message, used for diagnostics only * @param qrgRequested whether the message contains a recognised QRG request + * @param categoryQrg the QRG configured for that category, see + * {@link #selectAutoAnswerQrg(ChatCategory, ChatCategory, String, String)} * @param genericEnabled whether the general automatic reply is enabled + * @param genericAnswerText the configured general reply text * @return validated message text or {@code null} when no reply may be sent */ - private String buildAutoAnswerMessageText( - ChatMessage incoming, + static String buildAutoAnswerMessageText( + String senderCallSign, + ChatCategory category, boolean qrgRequested, - boolean genericEnabled + String categoryQrg, + boolean genericEnabled, + String genericAnswerText ) { - if (incoming == null - || incoming.getSender() == null - || incoming.getSender().getCallSign() == null - || incoming.getSender().getCallSign().isBlank()) { - + if (senderCallSign == null || senderCallSign.isBlank()) { LOGGER.warning( "KST4Contest auto-answer skipped: " + "incoming message has no valid sender callsign." @@ -2031,37 +2061,32 @@ public class MessageBusManagementThread extends Thread { String payload; if (qrgRequested) { - String qrg = getAutoAnswerQrgForCategory( - incoming.getChatCategory() - ); - - if (qrg == null || qrg.isBlank()) { + if (categoryQrg == null || categoryQrg.isBlank()) { LOGGER.warning( "KST4Contest QRG auto-answer skipped for " - + incoming.getSender().getCallSign() + + senderCallSign + ": no QRG is available for chat category " - + autoAnswerCooldownKey(incoming) + + senderCallSign.toUpperCase() + + "|" + + (category == null ? 0 : category.getCategoryNumber()) + "." ); return null; } - payload = "QRG is: " + qrg.trim(); + payload = "QRG is: " + categoryQrg.trim(); } else if (genericEnabled) { - payload = this.client.getChatPreferences() - .getMessageHandling_autoAnswerTextMainCat(); - - if (payload == null || payload.isBlank()) { + if (genericAnswerText == null || genericAnswerText.isBlank()) { LOGGER.warning( "KST4Contest generic auto-answer skipped for " - + incoming.getSender().getCallSign() + + senderCallSign + ": the configured answer text is empty." ); return null; } - payload = payload.trim(); + payload = genericAnswerText.trim(); } else { return null; @@ -2069,7 +2094,7 @@ public class MessageBusManagementThread extends Thread { String messageText = "/CQ " - + incoming.getSender().getCallSign().trim() + + senderCallSign.trim() + " " + AUTOANSWER_PREFIX + " " @@ -2081,7 +2106,7 @@ public class MessageBusManagementThread extends Thread { } catch (IllegalArgumentException invalidMessage) { LOGGER.warning( "KST4Contest auto-answer skipped for " - + incoming.getSender().getCallSign() + + senderCallSign + ": " + invalidMessage.getMessage() ); @@ -2090,31 +2115,16 @@ public class MessageBusManagementThread extends Thread { } /** - * Returns whether a message carries the fixed marker used for automatic replies. + * Builds the cooldown identity of an incoming private message: the upper-cased + * remote callsign and the category number, separated by {@code |}. + * + *

The message category wins; the sender's category is only a fallback. A + * missing callsign yields {@code UNKNOWN}, a missing category {@code 0}.

+ * + * @param incoming incoming private message, may be {@code null} + * @return key such as {@code DL1ABC|2} */ - private boolean isAutoMessage(ChatMessage msg) { - return msg != null - && msg.getMessageText() != null - && msg.getMessageText().contains(AUTOANSWER_PREFIX); - } - - /** - * Returns the configured QRG for the category in which the request was received. - * If the category cannot be resolved, the main category remains the safe fallback. - */ - private String getAutoAnswerQrgForCategory(ChatCategory incomingCategory) { - ChatCategory secondCategory = this.client.getChatCategorySecondChat(); - - if (incomingCategory != null - && secondCategory != null - && incomingCategory.getCategoryNumber() == secondCategory.getCategoryNumber()) { - return this.client.getChatPreferences().getMYQRGSecondCat().get(); - } - - return this.client.getChatPreferences().getMYQRGFirstCat().get(); - } - - private String autoAnswerCooldownKey(ChatMessage incoming) { + static String autoAnswerCooldownKey(ChatMessage incoming) { String remoteCall = "UNKNOWN"; if (incoming != null && incoming.getSender() != null && incoming.getSender().getCallSign() != null) { @@ -2133,17 +2143,25 @@ public class MessageBusManagementThread extends Thread { return remoteCall + "|" + categoryNumber; } + /** + * Returns whether the cooldown has elapsed since the last local automatic reply. + * + * @param lastSentMillis epoch milliseconds of the last reply, {@code null} if none + * @param nowMillis current epoch milliseconds + * @return {@code true} when no reply was sent yet or at least + * {@link #AUTOANSWER_COOLDOWN_MS} have passed + */ + static boolean isCooldownElapsed(Long lastSentMillis, long nowMillis) { + return lastSentMillis == null || (nowMillis - lastSentMillis) >= AUTOANSWER_COOLDOWN_MS; + } + private boolean isAutoAnswerAllowedNow(ChatMessage incoming) { - - String key = autoAnswerCooldownKey(incoming); - Long last = lastLocalAutoAnswerPerRemoteMs.get(key); - - long now = System.currentTimeMillis(); - return last == null || (now - last) >= AUTOANSWER_COOLDOWN_MS; + Long last = lastLocalAutoAnswerPerRemoteMs.get(autoAnswerCooldownKey(incoming)); + return isCooldownElapsed(last, millisClock.getAsLong()); } private void markLocalAutoAnswerSent(ChatMessage incoming) { - lastLocalAutoAnswerPerRemoteMs.put(autoAnswerCooldownKey(incoming), System.currentTimeMillis()); + lastLocalAutoAnswerPerRemoteMs.put(autoAnswerCooldownKey(incoming), millisClock.getAsLong()); } diff --git a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerCooldownTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerCooldownTest.java new file mode 100644 index 00000000..f3a7c7f7 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerCooldownTest.java @@ -0,0 +1,125 @@ +package kst4contest.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; + +import kst4contest.ApplicationConstants; +import kst4contest.model.AirPlaneReflectionInfo; +import kst4contest.model.ChatCategory; +import kst4contest.model.ChatMember; +import kst4contest.model.ChatMessage; +import kst4contest.model.ChatPreferences; +import kst4contest.utils.PlayAudioUtils; + +/** + * Drives the autoanswer cooldown of a running {@link MessageBusManagementThread} + * with an injected clock instead of waiting two minutes of wall-clock time. + * + *

The clock is advanced by a marker frame ({@code UE}) placed between the + * questions: the thread processes frames strictly in order, and the stubbed + * completion callback of the marker moves the clock before the next question is + * taken from the queue.

+ */ +class MessageBusManagementThreadAutoAnswerCooldownTest { + + @TempDir + Path tempDir; + + @Test + @Timeout(10) + void questionIsAnsweredAgainOnceTheCooldownHasElapsed() throws InterruptedException { + AtomicLong clock = new AtomicLong(1_700_000_000_000L); + LinkedBlockingQueue txBus = new LinkedBlockingQueue<>(); + LinkedBlockingQueue rxQueue = new LinkedBlockingQueue<>(); + ChatController controller = controller(txBus); + + // Each marker frame advances the clock by one step before the next question. + long[] steps = { + MessageBusManagementThread.AUTOANSWER_COOLDOWN_MS - 1, // still closed + 1L, // now exactly elapsed + MessageBusManagementThread.AUTOANSWER_COOLDOWN_MS // elapsed again + }; + AtomicLong markerIndex = new AtomicLong(); + when(controller.getChatCategoryMain()).thenReturn(new ChatCategory(ChatCategory.VUHF)); + org.mockito.Mockito.doAnswer(invocation -> { + clock.addAndGet(steps[(int) markerIndex.getAndIncrement()]); + return null; + }).when(controller).onOn4KstInitialUserListCompleted( + org.mockito.ArgumentMatchers.anyLong(), any()); + + MessageBusManagementThread thread = new MessageBusManagementThread( + controller, mock(ThreadStatusCallback.class), 9L, rxQueue, + ignored -> true, clock::get); + + rxQueue.add(question()); // t0: answered + rxQueue.add(marker()); // +119_999 ms + rxQueue.add(question()); // t0 + 119_999: suppressed + rxQueue.add(marker()); // +1 ms + rxQueue.add(question()); // t0 + 120_000: answered + rxQueue.add(marker()); // +120_000 ms + rxQueue.add(question()); // t0 + 240_000: answered + rxQueue.add(poisonPill()); + + thread.start(); + thread.join(); + + assertEquals(3, txBus.size(), "first, third and fourth question are answered"); + } + + private ChatController controller(LinkedBlockingQueue txBus) { + ChatPreferences prefs = new ChatPreferences(tempDir.resolve("preferences.xml")); + prefs.setStn_loginCallSign("DN9APW"); + prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true); + prefs.setMYQRGFirstCat("144.300"); + + ChatController controller = mock(ChatController.class); + when(controller.getChatPreferences()).thenReturn(prefs); + when(controller.getMessageTXBus()).thenReturn(txBus); + when(controller.getChatCategorySecondChat()) + .thenReturn(new ChatCategory(ChatCategory.MICROWAVE)); + when(controller.getPlayAudioUtils()).thenReturn(mock(PlayAudioUtils.class)); + when(controller.getStationMetricsService()) + .thenReturn(mock(StationMetricsService.class)); + when(controller.getScoreService()).thenReturn(mock(ScoreService.class)); + when(controller.findActiveChatMember(any(ChatMember.class))) + .thenAnswer(invocation -> { + ChatMember lookup = invocation.getArgument(0); + ChatMember member = new ChatMember(); + member.setCallSign(lookup.getCallSign()); + member.setChatCategory(lookup.getChatCategory()); + member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo()); + return member; + }); + return controller; + } + + private static ChatMessage question() { + return message("CH|2|1700000000|DL1ABC|Hans|0|ur qrg?|DN9APW|"); + } + + private static ChatMessage marker() { + return message("UE|2|22562|"); + } + + private static ChatMessage message(String text) { + ChatMessage message = new ChatMessage(); + message.setMessageText(text); + return message; + } + + private static ChatMessage poisonPill() { + ChatMessage message = message(ApplicationConstants.DISCONNECT_RDR_POISONPILL); + message.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL); + return message; + } +} diff --git a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerDecisionTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerDecisionTest.java new file mode 100644 index 00000000..f88568d7 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerDecisionTest.java @@ -0,0 +1,270 @@ +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.assertTrue; + +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import kst4contest.ApplicationConstants; +import kst4contest.model.ChatCategory; +import kst4contest.model.ChatMember; +import kst4contest.model.ChatMessage; + +/** + * Unit tests for the pure autoanswer decisions extracted from the private-message + * branch of {@link MessageBusManagementThread}. No mocks, no thread, no preferences. + */ +class MessageBusManagementThreadAutoAnswerDecisionTest { + + private static final String MARKER = ApplicationConstants.AUTOANSWER_PREFIX; + private static final ChatCategory VUHF = new ChatCategory(ChatCategory.VUHF); + private static final ChatCategory MICROWAVE = new ChatCategory(ChatCategory.MICROWAVE); + + // --- matchesQrgQuestion ------------------------------------------------- + + @ParameterizedTest + @ValueSource(strings = { + "ur qrg?", + "UR QRG?", + "hi, your qrg? 73", + "qrg?", + "freq?", + "GM Hans, PSE QRG for a sked" + }) + void recognisesEveryConfiguredQuestionCaseInsensitively(String text) { + assertTrue(MessageBusManagementThread.matchesQrgQuestion( + text, MessageBusManagementThread.QRG_QUESTION_TEXTS)); + } + + @ParameterizedTest + @ValueSource(strings = { + "my qrg is 144.300", + "qrg", + "what is your frequency", + "pse qrg", + "" + }) + void ignoresTextsWithoutAConfiguredQuestion(String text) { + assertFalse(MessageBusManagementThread.matchesQrgQuestion( + text, MessageBusManagementThread.QRG_QUESTION_TEXTS)); + } + + @Test + void matchesQrgQuestionHandlesNullInputs() { + assertFalse(MessageBusManagementThread.matchesQrgQuestion( + null, MessageBusManagementThread.QRG_QUESTION_TEXTS)); + assertFalse(MessageBusManagementThread.matchesQrgQuestion("ur qrg?", null)); + assertFalse(MessageBusManagementThread.matchesQrgQuestion("ur qrg?", Set.of())); + } + + @Test + void questionTextsAreTheEstablishedFive() { + assertEquals( + Set.of("ur qrg?", "your qrg?", "qrg?", "freq?", "pse qrg"), + MessageBusManagementThread.QRG_QUESTION_TEXTS); + } + + // --- isAutoMessage ------------------------------------------------------ + + @Test + void detectsTheMarkerAnywhereInTheText() { + assertTrue(MessageBusManagementThread.isAutoMessage(MARKER)); + assertTrue(MessageBusManagementThread.isAutoMessage("/CQ DL1ABC " + MARKER + " QRG is: 144.300")); + assertTrue(MessageBusManagementThread.isAutoMessage("ur qrg? " + MARKER)); + } + + @Test + void plainTextsAreNotAutoMessages() { + assertFalse(MessageBusManagementThread.isAutoMessage(null)); + assertFalse(MessageBusManagementThread.isAutoMessage("")); + assertFalse(MessageBusManagementThread.isAutoMessage("ur qrg?")); + // The marker is matched literally, not case-insensitively. + assertFalse(MessageBusManagementThread.isAutoMessage(MARKER.toLowerCase())); + } + + // --- selectAutoAnswerQrg ------------------------------------------------ + + @Test + void secondCategoryGetsTheSecondQrg() { + assertEquals("1296.200", MessageBusManagementThread.selectAutoAnswerQrg( + MICROWAVE, MICROWAVE, "144.300", "1296.200")); + // Categories are compared by number, not by identity. + assertEquals("1296.200", MessageBusManagementThread.selectAutoAnswerQrg( + new ChatCategory(ChatCategory.MICROWAVE), MICROWAVE, "144.300", "1296.200")); + } + + @Test + void everyOtherCategoryFallsBackToTheFirstQrg() { + assertEquals("144.300", MessageBusManagementThread.selectAutoAnswerQrg( + VUHF, MICROWAVE, "144.300", "1296.200")); + assertEquals("144.300", MessageBusManagementThread.selectAutoAnswerQrg( + null, MICROWAVE, "144.300", "1296.200")); + assertEquals("144.300", MessageBusManagementThread.selectAutoAnswerQrg( + MICROWAVE, null, "144.300", "1296.200")); + assertNull(MessageBusManagementThread.selectAutoAnswerQrg( + VUHF, MICROWAVE, null, "1296.200")); + } + + // --- buildAutoAnswerMessageText ---------------------------------------- + + @Test + void buildsTheQrgReply() { + assertEquals( + "/CQ DL1ABC " + MARKER + " QRG is: 144.300", + MessageBusManagementThread.buildAutoAnswerMessageText( + "DL1ABC", VUHF, true, " 144.300 ", false, null)); + } + + @Test + void qrgRequestWinsOverTheGenericReply() { + assertEquals( + "/CQ DL1ABC " + MARKER + " QRG is: 144.300", + MessageBusManagementThread.buildAutoAnswerMessageText( + "DL1ABC", VUHF, true, "144.300", true, "not qrv")); + } + + @Test + void qrgRequestWithoutQrgYieldsNoReply() { + assertNull(MessageBusManagementThread.buildAutoAnswerMessageText( + "DL1ABC", VUHF, true, null, true, "not qrv")); + assertNull(MessageBusManagementThread.buildAutoAnswerMessageText( + "DL1ABC", VUHF, true, " ", true, "not qrv")); + // The category is used for diagnostics only; a null category must not fail. + assertNull(MessageBusManagementThread.buildAutoAnswerMessageText( + "DL1ABC", null, true, "", false, null)); + } + + @Test + void buildsTheGenericReplyTrimmed() { + assertEquals( + "/CQ DL1ABC " + MARKER + " not qrv, back at 18z", + MessageBusManagementThread.buildAutoAnswerMessageText( + "DL1ABC", VUHF, false, "144.300", true, " not qrv, back at 18z ")); + } + + @Test + void genericReplyNeedsTextAndTheSwitch() { + assertNull(MessageBusManagementThread.buildAutoAnswerMessageText( + "DL1ABC", VUHF, false, "144.300", true, null)); + assertNull(MessageBusManagementThread.buildAutoAnswerMessageText( + "DL1ABC", VUHF, false, "144.300", true, " ")); + assertNull(MessageBusManagementThread.buildAutoAnswerMessageText( + "DL1ABC", VUHF, false, "144.300", false, "not qrv")); + } + + @Test + void missingSenderCallSignYieldsNoReply() { + assertNull(MessageBusManagementThread.buildAutoAnswerMessageText( + null, VUHF, true, "144.300", true, "not qrv")); + assertNull(MessageBusManagementThread.buildAutoAnswerMessageText( + " ", VUHF, true, "144.300", true, "not qrv")); + } + + @Test + void senderCallSignIsTrimmedButNotUpperCased() { + assertEquals( + "/CQ dl1abc " + MARKER + " QRG is: 144.300", + MessageBusManagementThread.buildAutoAnswerMessageText( + " dl1abc ", VUHF, true, "144.300", false, null)); + } + + @Test + void replyContainingTheFieldSeparatorIsRejected() { + // On4KstProtocol.messageText refuses '|'; the reply is dropped, not repaired. + assertNull(MessageBusManagementThread.buildAutoAnswerMessageText( + "DL1ABC", VUHF, true, "144.300|432.200", false, null)); + assertNull(MessageBusManagementThread.buildAutoAnswerMessageText( + "DL1ABC", VUHF, false, null, true, "not qrv | back later")); + } + + @Test + void replyContainingALineBreakIsRejected() { + assertNull(MessageBusManagementThread.buildAutoAnswerMessageText( + "DL1ABC", VUHF, false, null, true, "not qrv\nback later")); + } + + // --- autoAnswerCooldownKey --------------------------------------------- + + @Test + void cooldownKeyIsUpperCaseCallAndMessageCategory() { + ChatMessage incoming = incoming("dl1abc", VUHF, MICROWAVE); + + assertEquals("DL1ABC|2", MessageBusManagementThread.autoAnswerCooldownKey(incoming)); + } + + @Test + void cooldownKeyFallsBackToTheSenderCategory() { + ChatMessage incoming = incoming("DL1ABC", null, MICROWAVE); + + assertEquals("DL1ABC|3", MessageBusManagementThread.autoAnswerCooldownKey(incoming)); + } + + @Test + void cooldownKeyKeepsSuffixVariantsApart() { + // Cooldown identity must not collapse DL1ABC and DL1ABC-2 (AGENTS.md). + assertEquals("DL1ABC-2|2", MessageBusManagementThread.autoAnswerCooldownKey( + incoming("DL1ABC-2", VUHF, null))); + assertEquals("DL1ABC/P|2", MessageBusManagementThread.autoAnswerCooldownKey( + incoming("DL1ABC/P", VUHF, null))); + } + + @Test + void cooldownKeyHasSafeDefaults() { + assertEquals("UNKNOWN|0", MessageBusManagementThread.autoAnswerCooldownKey(null)); + assertEquals("UNKNOWN|0", MessageBusManagementThread.autoAnswerCooldownKey(new ChatMessage())); + + ChatMessage withoutCall = new ChatMessage(); + withoutCall.setSender(new ChatMember()); + withoutCall.setChatCategory(VUHF); + assertEquals("UNKNOWN|2", MessageBusManagementThread.autoAnswerCooldownKey(withoutCall)); + } + + // --- isCooldownElapsed ------------------------------------------------- + + @Test + void cooldownIsElapsedWithoutAnEarlierReply() { + assertTrue(MessageBusManagementThread.isCooldownElapsed(null, 0L)); + } + + @Test + void cooldownBoundaryIsInclusive() { + long last = 1_000_000L; + long cooldown = MessageBusManagementThread.AUTOANSWER_COOLDOWN_MS; + + assertEquals(120_000L, cooldown); + assertFalse(MessageBusManagementThread.isCooldownElapsed(last, last)); + assertFalse(MessageBusManagementThread.isCooldownElapsed(last, last + cooldown - 1)); + assertTrue(MessageBusManagementThread.isCooldownElapsed(last, last + cooldown)); + assertTrue(MessageBusManagementThread.isCooldownElapsed(last, last + cooldown + 1)); + } + + /** + * ODDITY (pinned, not fixed): a clock that runs backwards, for example after an + * NTP correction, does not re-open the cooldown; the difference is simply + * negative and therefore below the threshold until the wall clock catches up. + */ + @Test + void clockRunningBackwardsKeepsTheCooldownClosed() { + assertFalse(MessageBusManagementThread.isCooldownElapsed(1_000_000L, 500_000L)); + } + + // ----------------------------------------------------------------------- + + private static ChatMessage incoming( + String senderCall, ChatCategory messageCategory, ChatCategory senderCategory) { + ChatMember sender = new ChatMember(); + sender.setCallSign(senderCall); + sender.setChatCategory(senderCategory); + + ChatMessage incoming = new ChatMessage(); + incoming.setSender(sender); + incoming.setChatCategory(messageCategory); + return incoming; + } +} diff --git a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerTest.java new file mode 100644 index 00000000..cc8e25f1 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadAutoAnswerTest.java @@ -0,0 +1,384 @@ +package kst4contest.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +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.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; + +import kst4contest.ApplicationConstants; +import kst4contest.model.AirPlaneReflectionInfo; +import kst4contest.model.ChatCategory; +import kst4contest.model.ChatMember; +import kst4contest.model.ChatMessage; +import kst4contest.model.ChatPreferences; +import kst4contest.utils.PlayAudioUtils; + +/** + * Characterises the automatic reply to private ON4KST chat messages by running + * real {@code CH} frames through a started {@link MessageBusManagementThread}. + * + *

The thread reads from its receive queue until it takes the poison pill, so + * every test enqueues its frames followed by the pill and joins the thread. The + * transmit bus is a real queue: an automatic reply is observable as exactly one + * element on it.

+ * + *

Frame layout, as split on {@code |} by the thread: + * {@code CH|category|epoch|senderCall|senderName|0|text|receiverCall|}; a + * receiver of {@code 0} marks a public message.

+ */ +class MessageBusManagementThreadAutoAnswerTest { + + private static final String MY_CALL = "DN9APW"; + private static final String REMOTE_CALL = "DL1ABC"; + private static final String FIRST_QRG = "144.300"; + private static final String SECOND_QRG = "1296.200"; + + @TempDir + Path tempDir; + + private ChatController controller; + private ChatPreferences prefs; + private LinkedBlockingQueue txBus; + private LinkedBlockingQueue rxQueue; + private ChatCategory mainCategory; + private ChatCategory secondCategory; + + @BeforeEach + void setUp() { + prefs = new ChatPreferences(tempDir.resolve("preferences.xml")); + prefs.setStn_loginCallSign(MY_CALL); + prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true); + prefs.setMessageHandling_autoAnswerEnabled(false); + prefs.setMYQRGFirstCat(FIRST_QRG); + prefs.setMYQRGSecondCat(SECOND_QRG); + + mainCategory = new ChatCategory(ChatCategory.VUHF); + secondCategory = new ChatCategory(ChatCategory.MICROWAVE); + txBus = new LinkedBlockingQueue<>(); + rxQueue = new LinkedBlockingQueue<>(); + + controller = mock(ChatController.class); + when(controller.getChatPreferences()).thenReturn(prefs); + when(controller.getMessageTXBus()).thenReturn(txBus); + when(controller.getChatCategoryMain()).thenReturn(mainCategory); + when(controller.getChatCategorySecondChat()).thenReturn(secondCategory); + when(controller.getPlayAudioUtils()).thenReturn(mock(PlayAudioUtils.class)); + when(controller.getStationMetricsService()) + .thenReturn(mock(StationMetricsService.class)); + when(controller.getScoreService()).thenReturn(mock(ScoreService.class)); + when(controller.getDxClusterServer()) + .thenReturn(mock(DXClusterThreadPooledServer.class)); + when(controller.findActiveChatMember(any(ChatMember.class))) + .thenAnswer(invocation -> activeMemberFor(invocation.getArgument(0))); + } + + @Test + @Timeout(10) + void answersQrgQuestionAddressedToMeExactlyOnce() throws InterruptedException { + runThrough( + chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL)); + + List replies = new ArrayList<>(txBus); + assertEquals(1, replies.size(), "exactly one automatic reply expected"); + + ChatMessage reply = replies.get(0); + assertEquals( + "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX + + " QRG is: " + FIRST_QRG, + reply.getMessageText()); + assertEquals(MY_CALL, reply.getSender().getCallSign()); + assertEquals(REMOTE_CALL, reply.getReceiver().getCallSign()); + assertSame(mainCategory, reply.getChatCategory()); + + // The incoming private message itself is still published to the UI. + ArgumentCaptor published = ArgumentCaptor.forClass(ChatMessage.class); + verify(controller, times(1)).publishChatMessage(published.capture()); + assertEquals("ur qrg?", published.getValue().getMessageText()); + assertEquals(REMOTE_CALL, published.getValue().getSender().getCallSign()); + } + + @Test + @Timeout(10) + void matchesQuestionTextCaseInsensitivelyInsideLongerMessage() + throws InterruptedException { + runThrough( + chatFrame(2, REMOTE_CALL, "GM Hans, PSE QRG for a sked", MY_CALL)); + + assertEquals(1, txBus.size()); + } + + @Test + @Timeout(10) + void secondQuestionFromSameStationWithinCooldownIsNotAnswered() + throws InterruptedException { + runThrough( + chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL), + chatFrame(2, REMOTE_CALL, "qrg?", MY_CALL)); + + assertEquals(1, txBus.size(), "cooldown must suppress the second reply"); + verify(controller, times(2)).publishChatMessage(any(ChatMessage.class)); + } + + @Test + @Timeout(10) + void cooldownIsKeyedByStationAndCategory() throws InterruptedException { + runThrough( + chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL), + chatFrame(2, "DL2XYZ", "ur qrg?", MY_CALL), + chatFrame(3, REMOTE_CALL, "ur qrg?", MY_CALL)); + + List replies = new ArrayList<>(txBus); + assertEquals(3, replies.size(), + "another station and another category each get their own reply"); + assertEquals( + "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX + + " QRG is: " + SECOND_QRG, + replies.get(2).getMessageText(), + "the second-category question is answered with the second QRG"); + assertSame(secondCategory, replies.get(2).getChatCategory()); + } + + @Test + @Timeout(10) + void questionCarryingTheAutoAnswerMarkerIsNeverAnswered() + throws InterruptedException { + runThrough( + chatFrame(2, REMOTE_CALL, + ApplicationConstants.AUTOANSWER_PREFIX + " QRG is: 432.200 ur qrg?", + MY_CALL)); + + assertTrue(txBus.isEmpty(), "automatic messages must not be answered"); + verify(controller, times(1)).publishChatMessage(any(ChatMessage.class)); + } + + @Test + @Timeout(10) + void publicQuestionIsNotAnswered() throws InterruptedException { + runThrough( + chatFrame(2, REMOTE_CALL, "ur qrg?", "0")); + + assertTrue(txBus.isEmpty()); + verify(controller, times(1)).publishChatMessage(any(ChatMessage.class)); + } + + @Test + @Timeout(10) + void questionToAnotherStationIsNotAnswered() throws InterruptedException { + runThrough( + chatFrame(2, REMOTE_CALL, "ur qrg?", "DL2XYZ")); + + assertTrue(txBus.isEmpty()); + verify(controller, times(1)).publishChatMessage(any(ChatMessage.class)); + } + + @Test + @Timeout(10) + void disabledQrgAutoAnswerStaysSilent() throws InterruptedException { + prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(false); + + runThrough( + chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL)); + + assertTrue(txBus.isEmpty()); + } + + @Test + @Timeout(10) + void emptyQrgIsNotAnsweredAndDoesNotConsumeTheCooldown() + throws InterruptedException { + prefs.setMYQRGFirstCat(""); + + MessageBusManagementThread thread = newThread(); + CountDownLatch markerProcessed = new CountDownLatch(1); + doAnswer(invocation -> { + markerProcessed.countDown(); + return null; + }).when(controller).onOn4KstInitialUserListCompleted(anyLong(), any()); + + thread.start(); + rxQueue.add(chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL)); + // The thread handles frames strictly in order: once the marker frame has + // been processed, the question above has been decided without a QRG. + rxQueue.add(message("UE|2|22562|")); + assertTrue(markerProcessed.await(5, TimeUnit.SECONDS)); + assertTrue(txBus.isEmpty(), "no reply without a QRG"); + + prefs.setMYQRGFirstCat(FIRST_QRG); + rxQueue.add(chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL)); + rxQueue.add(poisonPill()); + thread.join(); + + assertEquals(1, txBus.size(), + "the rejected reply must not have started the cooldown"); + assertEquals( + "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX + + " QRG is: " + FIRST_QRG, + txBus.peek().getMessageText()); + } + + @Test + @Timeout(10) + void genericAutoAnswerRepliesToOtherPrivateMessages() throws InterruptedException { + prefs.setMessageHandling_autoAnswerEnabled(true); + prefs.setMessageHandling_autoAnswerTextMainCat(" not qrv, back at 18z "); + + runThrough( + chatFrame(2, REMOTE_CALL, "hi, sked on 144?", MY_CALL)); + + assertEquals(1, txBus.size()); + assertEquals( + "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX + + " not qrv, back at 18z", + txBus.peek().getMessageText(), + "the configured text is trimmed"); + } + + @Test + @Timeout(10) + void qrgQuestionWinsOverGenericAnswer() throws InterruptedException { + prefs.setMessageHandling_autoAnswerEnabled(true); + prefs.setMessageHandling_autoAnswerTextMainCat("not qrv"); + + runThrough( + chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL)); + + assertEquals(1, txBus.size()); + assertEquals( + "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX + + " QRG is: " + FIRST_QRG, + txBus.peek().getMessageText()); + } + + @Test + @Timeout(10) + void genericAnswerWithBlankTextIsRejectedWithoutConsumingCooldown() + throws InterruptedException { + prefs.setMessageHandling_autoAnswerEnabled(true); + prefs.setMessageHandling_autoAnswerTextMainCat(" "); + + runThrough( + chatFrame(2, REMOTE_CALL, "hi", MY_CALL), + chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL)); + + assertEquals(1, txBus.size(), + "the blank generic reply is dropped; the QRG reply still goes out"); + assertTrue(txBus.peek().getMessageText().endsWith("QRG is: " + FIRST_QRG)); + } + + @Test + @Timeout(10) + void versionRequestIsAnsweredOutsideTheCooldown() throws InterruptedException { + runThrough( + chatFrame(2, REMOTE_CALL, "//ver", MY_CALL)); + + assertEquals(1, txBus.size()); + assertEquals( + "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX + + " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION + + " by DO5AMF", + txBus.peek().getMessageText()); + } + + /** + * ODDITY (pinned, not fixed): the receiver callsign of a CH frame is upper-cased + * by {@link ChatMember#setCallSign(String)}, but it is compared with + * {@code equals} against the login callsign exactly as configured. With a + * lower-case login callsign a private message is therefore not recognised as + * addressed to the local station, and no automatic reply is sent. The + * "sent by me" branch directly below compares both sides upper-cased. + */ + @Test + @Timeout(10) + void lowerCaseLoginCallSignDefeatsTheToMeDetection() throws InterruptedException { + prefs.setStn_loginCallSign(MY_CALL.toLowerCase()); + + runThrough( + chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL)); + + assertTrue(txBus.isEmpty()); + verify(controller, never()).getPlayAudioUtils(); + verify(controller, times(1)).publishChatMessage(any(ChatMessage.class)); + } + + @Test + @Timeout(10) + void autoMarkerInMyOwnEchoIsNotAnswered() throws InterruptedException { + // The server echoes our own directed message; the sender equals the login. + runThrough( + chatFrame(2, MY_CALL, "ur qrg?", REMOTE_CALL)); + + assertTrue(txBus.isEmpty()); + ArgumentCaptor published = ArgumentCaptor.forClass(ChatMessage.class); + verify(controller, times(1)).publishChatMessage(published.capture()); + assertEquals("(>" + REMOTE_CALL + ")ur qrg?", published.getValue().getMessageText()); + } + + // ---------------------------------------------------------------------- + + private void runThrough(ChatMessage... frames) throws InterruptedException { + MessageBusManagementThread thread = newThread(); + for (ChatMessage frame : frames) { + rxQueue.add(frame); + } + rxQueue.add(poisonPill()); + thread.start(); + thread.join(); + } + + private MessageBusManagementThread newThread() { + return new MessageBusManagementThread( + controller, + mock(ThreadStatusCallback.class), + 7L, + rxQueue, + ignored -> true); + } + + private static ChatMessage chatFrame( + int category, String senderCall, String text, String receiverCall) { + return message("CH|" + category + "|1700000000|" + senderCall + "|Hans|0|" + + text + "|" + receiverCall + "|"); + } + + private static ChatMessage message(String text) { + ChatMessage message = new ChatMessage(); + message.setMessageText(text); + return message; + } + + private static ChatMessage poisonPill() { + ChatMessage message = message(ApplicationConstants.DISCONNECT_RDR_POISONPILL); + message.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL); + return message; + } + + /** Mimics the active-member lookup: every remote station is known with a locator. */ + private static ChatMember activeMemberFor(ChatMember lookup) { + ChatMember member = new ChatMember(); + member.setCallSign(lookup.getCallSign()); + member.setChatCategory(lookup.getChatCategory()); + member.setQra("JO31AA"); + member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo()); + return member; + } +} diff --git a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadDetectFrequenciesTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadDetectFrequenciesTest.java new file mode 100644 index 00000000..c1f13de7 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadDetectFrequenciesTest.java @@ -0,0 +1,223 @@ +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 java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import kst4contest.controller.MessageBusManagementThread.DetectedChatFrequency; +import kst4contest.model.Band; + +/** + * Unit tests for the pure frequency detection extracted from + * {@link MessageBusManagementThread#smartFrequencyExtraction}. No mocks, no thread, + * no preferences; the clock is an explicit parameter. + */ +class MessageBusManagementThreadDetectFrequenciesTest { + + private static final long NOW = 1_000_000_000_000L; + private static final long THIRTY_MINUTES = 1_800_000L; + private static final double EPSILON = 1e-9; + + private static List detect(String text) { + return MessageBusManagementThread.detectFrequencies(text, Map.of(), null, NOW); + } + + private static List detect( + String text, Map context, String fallbackPrefix) { + return MessageBusManagementThread.detectFrequencies(text, context, fallbackPrefix, NOW); + } + + private static void assertSingle( + List result, Band band, double frequencyMHz) { + assertEquals(1, result.size(), () -> "detections: " + result); + assertEquals(band, result.get(0).band()); + assertEquals(frequencyMHz, result.get(0).frequencyMHz(), EPSILON); + } + + // --- complete frequencies ----------------------------------------------- + + @Test + void detectsDottedFullFrequency() { + List result = detect("pse qsy 144.300"); + + assertSingle(result, Band.B_144, 144.300); + assertFalse(result.get(0).derivedFromShortForm()); + assertEquals("144.300", result.get(0).rawMatch()); + } + + @Test + void detectsCommaAsDecimalSeparator() { + List result = detect("qrg 432,210"); + + assertSingle(result, Band.B_432, 432.210); + assertEquals("432.210", result.get(0).rawMatch()); + } + + @Test + void detectsCompactDigitOnlyFrequency() { + assertSingle(detect("pse try 10368100"), Band.B_10G, 10368.100); + } + + @Test + void detectsKstStyleSecondDecimalSeparator() { + assertSingle(detect("cq on 144.300.03"), Band.B_144, 144.30003); + } + + @Test + void fullFrequencyIgnoresBandContextAndFallback() { + Map context = Map.of(Band.B_432, NOW); + + assertSingle(detect("144.300", context, "1296"), Band.B_144, 144.300); + } + + @Test + void ignoresFullFrequencyOutsideEverySupportedBand() { + assertTrue(detect("try 146.500").isEmpty()); + assertTrue(detect("cq 28.500").isEmpty()); + assertSingle(detect("146.500 or 433.900"), Band.B_432, 433.900); + } + + @Test + void reportsAllFrequenciesInMessageOrder() { + List result = detect("144.300 or 432.200"); + + assertEquals(2, result.size()); + assertEquals(Band.B_144, result.get(0).band()); + assertEquals(Band.B_432, result.get(1).band()); + } + + /* + * Pinned oddity: any decimal number between 50.0 and 54.0 (or 70.0 and 70.5) + * is read as a frequency, so a distance such as "52,3 km" becomes 52.3 MHz. + */ + @Test + void pinsThatAPlainDecimalInsideTheSixMetreRangeCountsAsFrequency() { + assertSingle(detect("qrb 52,3 km"), Band.B_50, 52.3); + } + + // --- relative and bare values ------------------------------------------- + + @Test + void relativeValueWithoutContextUsesTheConfiguredFallback() { + List result = detect("pse .210", Map.of(), "432"); + + assertSingle(result, Band.B_432, 432.210); + assertTrue(result.get(0).derivedFromShortForm()); + assertEquals("210", result.get(0).rawMatch()); + } + + @ParameterizedTest + @ValueSource(strings = {"", "28", "abc"}) + void unknownFallbackPrefixFallsBackTo144(String prefix) { + assertSingle(detect("pse .210", Map.of(), prefix), Band.B_144, 144.210); + } + + @Test + void missingFallbackPrefixFallsBackTo144() { + assertSingle(detect("pse .210", null, null), Band.B_144, 144.210); + } + + @Test + void relativeValueWithCommaIsAccepted() { + assertSingle(detect("pse ,210"), Band.B_144, 144.210); + } + + @Test + void bareValueNeedsFrequencyContext() { + assertTrue(detect("ur 599 tnx").isEmpty()); + assertTrue(detect("144 tonight").isEmpty()); + assertSingle(detect("qrg 210"), Band.B_144, 144.210); + assertSingle(detect("210 MHz"), Band.B_144, 144.210); + } + + @Test + void relativeValueUsesFreshSenderBandBeforeFallback() { + Map context = Map.of(Band.B_1296, NOW - 60_000L); + + assertSingle(detect("pse .210", context, "432"), Band.B_1296, 1296.210); + } + + @Test + void newestFreshBandWins() { + Map context = new HashMap<>(); + context.put(Band.B_144, NOW - 120_000L); + context.put(Band.B_432, NOW - 60_000L); + context.put(Band.B_1296, NOW - 180_000L); + + assertSingle(detect("pse .210", context, null), Band.B_432, 432.210); + } + + @Test + void contextExactlyThirtyMinutesOldIsStillUsed() { + Map context = Map.of(Band.B_432, NOW - THIRTY_MINUTES); + + assertSingle(detect("pse .210", context, "144"), Band.B_432, 432.210); + } + + @Test + void contextOlderThanThirtyMinutesIsIgnored() { + Map context = Map.of(Band.B_432, NOW - THIRTY_MINUTES - 1L); + + assertSingle(detect("pse .210", context, "144"), Band.B_144, 144.210); + } + + @Test + void contextBandIsSkippedWhenTheCompletedValueLeavesTheBand() { + // 70.900 is outside 70.000-70.500, so the older 432 context is used. + Map context = new EnumMap<>(Band.class); + context.put(Band.B_70, NOW - 1_000L); + context.put(Band.B_432, NOW - 5_000L); + + assertSingle(detect("pse .900", context, "144"), Band.B_432, 432.900); + } + + @Test + void relativeValueOutsideTheFallbackBandIsDropped() { + // 70.900 lies outside the 70 MHz band and nothing else is tried. + assertTrue(detect("pse .900", Map.of(), "70").isEmpty()); + } + + @Test + void earlierDetectionInTheSameMessageBecomesContextForALaterRelativeValue() { + Map context = Map.of(Band.B_432, NOW - 60_000L); + + List result = + detect("10368.100 then .150", context, "144"); + + assertEquals(2, result.size()); + assertEquals(Band.B_10G, result.get(1).band()); + assertEquals(10368.150, result.get(1).frequencyMHz(), EPSILON); + } + + @Test + void doesNotModifyTheGivenContext() { + Map context = new EnumMap<>(Band.class); + context.put(Band.B_432, NOW - 60_000L); + + detect("10368.100 then .150", context, "144"); + + assertEquals(Map.of(Band.B_432, NOW - 60_000L), context); + } + + // --- edge cases --------------------------------------------------------- + + @Test + void nullOrTextWithoutNumbersYieldsNothing() { + assertTrue(MessageBusManagementThread.detectFrequencies(null, null, null, NOW).isEmpty()); + assertTrue(detect("gm all, tnx for qso").isEmpty()); + } + + @Test + void callsignDigitsAndLocatorsAreNotFrequencies() { + assertTrue(detect("DL144ABC in JO62QM").isEmpty()); + } +} diff --git a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadDirectionalSpotDecisionTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadDirectionalSpotDecisionTest.java new file mode 100644 index 00000000..2f172659 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadDirectionalSpotDecisionTest.java @@ -0,0 +1,162 @@ +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import kst4contest.controller.MessageBusManagementThread.DirectionalOpportunity; +import kst4contest.controller.MessageBusManagementThread.DxClusterSpotDecision; +import kst4contest.model.ChatMember; +import kst4contest.observe.SimpleValue; + +/** + * Unit tests for the directional-opportunity and DX Cluster spot decisions + * extracted from the message branch for traffic between two other stations. + * No mocks, no thread, no preferences. + */ +class MessageBusManagementThreadDirectionalSpotDecisionTest { + + // Local station between a western sender and an eastern receiver, all on one latitude. + private static final String OWN = "JO50LL"; + private static final String WEST = "JO30LL"; + private static final String EAST = "JO70LL"; + private static final String FAR_WEST = "IO70LL"; + private static final double MAX_QRB_KM = 900.0; + private static final double BEAM_WIDTH_DEG = 50.0; + + // --- classifyDirectionalOpportunity ------------------------------------- + + @Test + void senderBeamingAcrossTheLocalStationIsAnOpportunity() { + assertEquals(DirectionalOpportunity.IN_ANGLE_AND_RANGE, + MessageBusManagementThread.classifyDirectionalOpportunity( + OWN, WEST, EAST, MAX_QRB_KM, BEAM_WIDTH_DEG)); + } + + @Test + void senderBeamingAwayFromTheLocalStationIsNoOpportunity() { + assertEquals(DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE, + MessageBusManagementThread.classifyDirectionalOpportunity( + OWN, WEST, FAR_WEST, MAX_QRB_KM, BEAM_WIDTH_DEG)); + } + + @Test + void senderBeyondTheMaximumDistanceIsNoOpportunity() { + assertEquals(DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE, + MessageBusManagementThread.classifyDirectionalOpportunity( + OWN, WEST, EAST, 50.0, BEAM_WIDTH_DEG)); + } + + @ParameterizedTest + @CsvSource(value = { + "NULL, JO70LL", + "'', JO70LL", + "' ', JO70LL", + "JO30LL, NULL", + "JO30LL, ''", + "NULL, NULL" + }, nullValues = "NULL") + void missingOrBlankLocatorSkipsTheGeometry(String senderLocator, String receiverLocator) { + // The own locator is invalid on purpose: it must not be evaluated at all. + assertEquals(DirectionalOpportunity.MISSING_LOCATOR, + MessageBusManagementThread.classifyDirectionalOpportunity( + null, senderLocator, receiverLocator, MAX_QRB_KM, BEAM_WIDTH_DEG)); + } + + // --- shouldPlayDirectionalTick ------------------------------------------ + + @Test + void tickOnlyOnTheTransitionIntoAnOpportunityAndOnlyWithSounds() { + assertTrue(MessageBusManagementThread.shouldPlayDirectionalTick(true, false)); + assertFalse(MessageBusManagementThread.shouldPlayDirectionalTick(true, true)); + assertFalse(MessageBusManagementThread.shouldPlayDirectionalTick(false, false)); + assertFalse(MessageBusManagementThread.shouldPlayDirectionalTick(false, true)); + } + + // --- decideDxClusterSpot ------------------------------------------------ + + @Test + void knownFrequencyAndNotWorkedIsSpotted() { + assertEquals(DxClusterSpotDecision.SPOT, + MessageBusManagementThread.decideDxClusterSpot(senderOn("144.300"))); + } + + @Test + void workedOnTheBandOfTheFrequencyIsSuppressed() { + ChatMember sender = senderOn("144.300"); + sender.setWorked144(true); + + assertEquals(DxClusterSpotDecision.ALREADY_WORKED, + MessageBusManagementThread.decideDxClusterSpot(sender)); + } + + @Test + void workedOnlyOnAnotherBandIsSpotted() { + ChatMember sender = senderOn("432.200"); + sender.setWorked144(true); + + assertEquals(DxClusterSpotDecision.SPOT, + MessageBusManagementThread.decideDxClusterSpot(sender)); + } + + @Test + void missingFrequencyIsNeitherSpottedNorReportedAsWorked() { + ChatMember noWrapper = new ChatMember(); + noWrapper.setCallSign("DL1ABC"); + noWrapper.setFrequency(null); + noWrapper.setWorked(true); + + ChatMember noValue = new ChatMember(); + noValue.setCallSign("DL1ABC"); + noValue.setFrequency(new SimpleValue<>()); + noValue.setWorked(true); + + assertEquals(DxClusterSpotDecision.NO_FREQUENCY, + MessageBusManagementThread.decideDxClusterSpot(noWrapper)); + assertEquals(DxClusterSpotDecision.NO_FREQUENCY, + MessageBusManagementThread.decideDxClusterSpot(noValue)); + assertEquals(DxClusterSpotDecision.NO_FREQUENCY, + MessageBusManagementThread.decideDxClusterSpot(senderOn(" "))); + assertEquals(DxClusterSpotDecision.NO_FREQUENCY, + MessageBusManagementThread.decideDxClusterSpot(null)); + } + + /* + * Pinned oddity: the frequency text is only checked for being non-blank. A value + * that names no supported band is spotted, even for a station flagged as worked. + */ + @Test + void pinsThatAnUnparseableFrequencyIsStillSpotted() { + ChatMember sender = senderOn("999.9"); + sender.setWorked144(true); + + assertEquals(DxClusterSpotDecision.SPOT, + MessageBusManagementThread.decideDxClusterSpot(sender)); + } + + // --- buildDxClusterSpotEntry -------------------------------------------- + + @Test + void spotEntryCarriesCallsignFrequencyAndCommentInTheLocatorField() { + ChatMember sender = senderOn("144.300"); + sender.setQra("jo51hk"); + + ChatMember entry = MessageBusManagementThread.buildDxClusterSpotEntry(sender); + + assertEquals("DL1ABC", entry.getCallSign()); + assertSame(sender.getFrequency(), entry.getFrequency()); + assertEquals("JO51HK", entry.getQra()); + } + + private static ChatMember senderOn(String frequencyMHz) { + ChatMember sender = new ChatMember(); + sender.setCallSign("DL1ABC"); + sender.setFrequency(new SimpleValue<>(frequencyMHz)); + return sender; + } +} diff --git a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadFrequencyContextTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadFrequencyContextTest.java new file mode 100644 index 00000000..fbb660c0 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadFrequencyContextTest.java @@ -0,0 +1,104 @@ +package kst4contest.controller; + +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the context rule that decides whether a bare three-digit value such as + * {@code 210} is read as a frequency. Restored from the test file that sat under + * {@code src/main} before #62 and was therefore never executed. + */ +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", + "pse 210", + "at 210", + "210 MHz", + "210 qrg", + "210 freq" + }) + 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", + "210 stations worked" + }) + void rejectsBareThreeDigitValueWithoutFrequencyContext(String messageText) { + Matcher matcher = findThreeDigitValue(messageText); + + assertFalse( + MessageBusManagementThread.hasExplicitBareFrequencyContext( + messageText, + matcher.start(), + matcher.end() + ) + ); + } + + @Test + void contextWindowIsLimitedToNearbyText() { + // "qrg" is present but more than 32 characters before the value. + String messageText = + "qrg will follow later in this long sentence about nothing 210"; + Matcher matcher = findThreeDigitValue(messageText); + + assertFalse( + MessageBusManagementThread.hasExplicitBareFrequencyContext( + messageText, + matcher.start(), + matcher.end() + ) + ); + } + + @Test + void rejectsInvalidIndices() { + assertFalse(MessageBusManagementThread + .hasExplicitBareFrequencyContext(null, 0, 3)); + assertFalse(MessageBusManagementThread + .hasExplicitBareFrequencyContext("qrg 210", -1, 3)); + assertFalse(MessageBusManagementThread + .hasExplicitBareFrequencyContext("qrg 210", 5, 4)); + assertFalse(MessageBusManagementThread + .hasExplicitBareFrequencyContext("qrg 210", 4, 8)); + } + + 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; + } +} diff --git a/core/src/test/java/kst4contest/model/BandFromPrefixTest.java b/core/src/test/java/kst4contest/model/BandFromPrefixTest.java new file mode 100644 index 00000000..4578e9cf --- /dev/null +++ b/core/src/test/java/kst4contest/model/BandFromPrefixTest.java @@ -0,0 +1,40 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Covers the fallback-prefix lookup shared by the smart frequency parser and the + * preferences UI. Restored from the frequency-context test file that sat under + * {@code src/main} before #62 and was therefore never executed. + */ +class BandFromPrefixTest { + + @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)); + } + + @Test + void rejectsPrefixesThatAreNotSpelledExactly() { + // The lookup is a trimmed string comparison, not a numeric one. + assertNull(Band.fromPrefix("")); + assertNull(Band.fromPrefix("144.0")); + assertNull(Band.fromPrefix("0144")); + assertNull(Band.fromPrefix("10G")); + } + + @ParameterizedTest + @EnumSource(Band.class) + void everyBandResolvesFromItsOwnPrefix(Band band) { + assertEquals(band, Band.fromPrefix(band.getPrefix())); + } +}