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())); + } +}