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