From e0c1c6b6d4e1c90bfba6d1a1b9091ca588ab9187 Mon Sep 17 00:00:00 2001 From: Philipp Wagner Date: Fri, 16 Oct 2026 19:45:21 +0200 Subject: [PATCH] One chat-frame flow for live and history messages (#105) (#139) Second slice of making MessageBusManagementThread testable. The CH (live) and CR (history) chat-frame branches of processRXMessage23001 were near copies; they are now one processChatFrame(fields, live), with behaviour unchanged. - InboundChatFrame, ChatRoute and classifyRoute: where a message belongs (public, to me, from me, between others), pure. - ChatMessageEffects and decideChatFrameEffects: what a message triggers (sounds, //BELL, //VER reply, auto-answer, directional tick, in-angle flag, DX cluster spot), pure. History frames trigger nothing but the flag. - The four live/history differences are kept on purpose: frequency detection before routing (live) or after publishing (history), effects for messages to me, tick and spot between others, AirScout info on the SERVER sender. - MessageBusManagementThreadChatFrameTest pins both branches through the real thread, including the frequency order on directed frames and the spot entry; ChatFrameDecisionTest covers the pure functions. - Spec: docs/superpowers/specs/2026-10-16-message-bus-second-slice-design.md Co-authored-by: Claude Opus 5.5 --- .../controller/ChatMessageEffects.java | 30 + .../kst4contest/controller/ChatRoute.java | 13 + .../controller/InboundChatFrame.java | 21 + .../MessageBusManagementThread.java | 889 ++++++++---------- .../controller/ChatFrameDecisionTest.java | 115 +++ ...ssageBusManagementThreadChatFrameTest.java | 425 +++++++++ docs/PROJECT_CONTEXT.md | 1 + ...6-10-16-message-bus-second-slice-design.md | 67 ++ 8 files changed, 1083 insertions(+), 478 deletions(-) create mode 100644 core/src/main/java/kst4contest/controller/ChatMessageEffects.java create mode 100644 core/src/main/java/kst4contest/controller/ChatRoute.java create mode 100644 core/src/main/java/kst4contest/controller/InboundChatFrame.java create mode 100644 core/src/test/java/kst4contest/controller/ChatFrameDecisionTest.java create mode 100644 core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java create mode 100644 docs/superpowers/specs/2026-10-16-message-bus-second-slice-design.md diff --git a/core/src/main/java/kst4contest/controller/ChatMessageEffects.java b/core/src/main/java/kst4contest/controller/ChatMessageEffects.java new file mode 100644 index 00000000..53b19e1f --- /dev/null +++ b/core/src/main/java/kst4contest/controller/ChatMessageEffects.java @@ -0,0 +1,30 @@ +package kst4contest.controller; + +/** + * Side effects a chat frame asks for, decided without touching sound, network or model. + * + * @param playPmSound whether the private message sound ('P') is played + * @param playCwCallsign whether the sender's call is sent in CW + * @param playVoiceCallsign whether the sender's call is spoken + * @param playBell whether the voice bell ("!") is played + * @param sendVersionReply whether the version reply is sent + * @param autoAnswerText the auto answer to send, {@code null} for none; the per-remote cooldown is still checked by the executor + * @param inAngleAndRange the new in-angle flag of the sender, {@code null} to leave it unchanged + * @param playDirectionalTick whether the short directional tick is played + * @param evaluateDxClusterSpot whether a DX Cluster spot is to be evaluated + */ +record ChatMessageEffects( + boolean playPmSound, + boolean playCwCallsign, + boolean playVoiceCallsign, + boolean playBell, + boolean sendVersionReply, + String autoAnswerText, + Boolean inAngleAndRange, + boolean playDirectionalTick, + boolean evaluateDxClusterSpot) { + + /** No effect at all. */ + static final ChatMessageEffects NONE = + new ChatMessageEffects(false, false, false, false, false, null, null, false, false); +} diff --git a/core/src/main/java/kst4contest/controller/ChatRoute.java b/core/src/main/java/kst4contest/controller/ChatRoute.java new file mode 100644 index 00000000..2f50f39f --- /dev/null +++ b/core/src/main/java/kst4contest/controller/ChatRoute.java @@ -0,0 +1,13 @@ +package kst4contest.controller; + +/** Where an ON4KST chat message belongs, seen from the local station. */ +enum ChatRoute { + /** Receiver field {@code 0}: a message to everyone. */ + PUBLIC, + /** Addressed to the local login call. */ + TO_ME, + /** Sent by the local station to someone else. */ + FROM_ME, + /** Between two other stations. */ + BETWEEN_OTHERS +} diff --git a/core/src/main/java/kst4contest/controller/InboundChatFrame.java b/core/src/main/java/kst4contest/controller/InboundChatFrame.java new file mode 100644 index 00000000..e9292025 --- /dev/null +++ b/core/src/main/java/kst4contest/controller/InboundChatFrame.java @@ -0,0 +1,21 @@ +package kst4contest.controller; + +/** + * The fields of an ON4KST chat frame ({@code CH} live, {@code CR} history), as split on + * {@code |}: {@code opcode|category|epoch|senderCall|senderName|0|text|receiver|}. + * + *

Read exactly as the frame branches always did: no trimming and no validation, so a + * short frame fails where it failed before.

+ */ +record InboundChatFrame( + String categoryNumber, + String epoch, + String senderCall, + String senderName, + String text, + String receiverField) { + + static InboundChatFrame of(String[] fields) { + return new InboundChatFrame(fields[1], fields[2], fields[3], fields[4], fields[6], fields[7]); + } +} diff --git a/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java index e285ac27..6c918cf4 100644 --- a/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -694,6 +694,319 @@ public class MessageBusManagementThread extends Thread { + ", fieldCount=" + (fields == null ? 0 : fields.length)); } + /** + * Processes one ON4KST chat frame: {@code CH} (live) or {@code CR} (history). + * + *

Both share sender and receiver resolution, routing and publishing. The + * differences are kept exactly as they were in the two former branches: + * a live SERVER sender gets an empty AirScout info, a historic one does not; + * live frames detect the sender's frequency before routing, historic frames + * after publishing and the diagnostic log; historic frames never play sounds, + * answer, tick or spot. Which side effects apply is decided by + * {@link #decideChatFrameEffects}; this method only executes them.

+ * + *

The fields are read in the same order as before, so a truncated frame + * fails at the same point, after the same side effects.

+ * + * @param fields the frame split on {@code |} + * @param live {@code true} for {@code CH}, {@code false} for {@code CR} + */ + private void processChatFrame(String[] fields, boolean live) { + + ChatPreferences prefs = this.client.getChatPreferences(); + + ChatMessage newMessageArrived = new ChatMessage(); + ChatCategory chategoryForMessageAndMessageSender; + + newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(fields[1])); + + chategoryForMessageAndMessageSender = newMessageArrived.getChatCategory(); + newMessageArrived.setMessageGeneratedTime(fields[2]); + newMessageArrived.setMessageSenderName(fields[4]); + newMessageArrived.setMessageText(fields[6]); + + if (fields[3].equals("SERVER")) { + ChatMember dummy = new ChatMember(); + if (live) { + dummy.setAirPlaneReflectInfo(new AirPlaneReflectionInfo()); + } + dummy.setCallSign("SERVER"); + dummy.setName("Sysop"); + newMessageArrived.setSender(dummy); + newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(fields[1])); + dummy.setChatCategory(util_getChatCategoryByCategoryNrString(fields[1])); + + } else { + + newMessageArrived.setSender(resolveInboundSender( + fields[3], + chategoryForMessageAndMessageSender, + newMessageArrived)); + } + + if (live) { + /* + * Detect and store the sender's QRG before any directional-opportunity + * or DX-Cluster processing. A frequency first mentioned in this directed + * message is then immediately available for the resulting spot. + * + * The parser needs only the message text, sender and preferences. The + * receiver does not have to be resolved yet. + */ + smartFrequencyExtraction(newMessageArrived, prefs); + } + + // Built only now: the receiver field has always been read after the sender. + InboundChatFrame frame = InboundChatFrame.of(fields); + + if (frame.receiverField().equals("0")) { + // message is not directed to anyone, move it to the cq messages. + newMessageArrived.setReceiver(createAllReceiver()); + + this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) + + } else { + //message is directed to another chatmember, process as such! + + newMessageArrived.setReceiver(resolveInboundReceiver( + frame.receiverField(), + chategoryForMessageAndMessageSender)); + + try { + ChatRoute route = classifyRoute( + frame.receiverField(), + newMessageArrived.getSender().getCallSign(), + newMessageArrived.getReceiver().getCallSign(), + prefs.getStn_loginCallSign()); + + DirectionalOpportunity opportunity = null; + boolean senderAlreadyInAngleAndRange = false; + if (route == ChatRoute.BETWEEN_OTHERS) { + /* + * With explicit fallback sender/receiver objects, this path may also see + * stations that already left the chat or arrived before their user-list + * entry. Their locators may be missing, so angle/range analysis and DX + * spotting are skipped while the chat message itself is still published. + */ + opportunity = classifyDirectionalOpportunity( + prefs.getStn_loginLocatorMainCat(), + newMessageArrived.getSender().getQra(), + newMessageArrived.getReceiver().getQra(), + prefs.getStn_maxQRBDefault(), + prefs.getStn_antennaBeamWidthDeg()); + senderAlreadyInAngleAndRange = newMessageArrived.getSender().isInAngleAndRange(); + } + + // Built only where it was built before: it logs when no reply is possible. + String autoAnswerTextCandidate = null; + if (route == ChatRoute.TO_ME && live && !isAutoMessage(newMessageArrived.getMessageText())) { + boolean qrgRequested = + prefs.isMessageHandling_autoAnswerToQRGRequestEnabled() + && matchesQrgQuestion(newMessageArrived.getMessageText(), QRG_QUESTION_TEXTS); + + ChatMember incomingSender = newMessageArrived.getSender(); + + autoAnswerTextCandidate = buildAutoAnswerMessageText( + incomingSender == null ? null : incomingSender.getCallSign(), + // Diagnostics only: same fallback as autoAnswerCooldownKey. + newMessageArrived.getChatCategory() != null || incomingSender == null + ? newMessageArrived.getChatCategory() + : incomingSender.getChatCategory(), + qrgRequested, + selectAutoAnswerQrg( + newMessageArrived.getChatCategory(), + this.client.getChatCategorySecondChat(), + prefs.getMYQRGFirstCat().get(), + prefs.getMYQRGSecondCat().get() + ), + prefs.isMsgHandling_autoAnswerEnabled(), + prefs.getMessageHandling_autoAnswerTextMainCat() + ); + } + + ChatMessageEffects effects = decideChatFrameEffects( + route, + live, + newMessageArrived.getMessageText(), + opportunity, + senderAlreadyInAngleAndRange, + new ChatFrameSettings( + prefs.isNotify_playSimpleSounds(), + prefs.isNotify_playCWCallsignsOnRxedPMs(), + prefs.isNotify_playVoiceCallsignsOnRxedPMs(), + prefs.isNotify_dxClusterServerEnabled(), + autoAnswerTextCandidate)); + + if (route == ChatRoute.TO_ME) { + /** + * message is directed to me, will be put in the "to me" messagelist + */ + this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) + + if (effects.playPmSound()) { + this.client.getPlayAudioUtils().playNoiseLauncher('P'); + } + if (effects.playCwCallsign()) { + this.client.getPlayAudioUtils().playCWLauncher(" " + " " + newMessageArrived.getSender().getCallSign().toUpperCase()); + } + if (effects.playVoiceCallsign()) { + this.client.getPlayAudioUtils().playVoiceLauncher( "?" + newMessageArrived.getSender().getCallSign().toUpperCase()); + } + if (effects.playBell()) { + this.client.getPlayAudioUtils().playVoiceLauncher("!"); + } + if (effects.sendVersionReply()) { + + ChatMessage versionInfo = new ChatMessage(); + ChatMember itsMe = new ChatMember(); + itsMe.setCallSign(prefs.getStn_loginCallSign()); + + versionInfo.setSender(itsMe); + versionInfo.setReceiver(newMessageArrived.getSender()); + versionInfo.setChatCategory(newMessageArrived.getChatCategory()); + versionInfo.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + + " " + AUTOANSWER_PREFIX + + " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION + + " by DO5AMF"); + + this.client.getMessageTXBus().add(versionInfo); + } + + /* + * Unified auto-answer (generic + QRG) with ping-pong guard and + * per-remote cooldown. Invalid or incomplete replies are rejected + * before the cooldown is checked or updated. A missing QRG must + * therefore not suppress a later valid reply. + */ + if (effects.autoAnswerText() != null + && isAutoAnswerAllowedNow(newMessageArrived)) { + + ChatMessage automaticAnswer = new ChatMessage(); + ChatMember itsMe = new ChatMember(); + + itsMe.setCallSign(prefs.getStn_loginCallSign()); + + automaticAnswer.setSender(itsMe); + automaticAnswer.setReceiver(newMessageArrived.getSender()); + automaticAnswer.setChatCategory(newMessageArrived.getChatCategory()); + automaticAnswer.setMessageText(effects.autoAnswerText()); + + this.client.getMessageTXBus().add(automaticAnswer); + + /* + * Record the cooldown only after a complete and locally + * validated reply has been placed in the transmit queue. + */ + markLocalAutoAnswerSent(newMessageArrived); + } + + LOGGER.fine((live + ? "Message directed to local callsign: " + : "Historic message directed to local callsign: ") + + newMessageArrived.getReceiver().getCallSign()); + + } else if (route == ChatRoute.FROM_ME) { + /** + * message sent by me! + * message from me will appear in the PM window, too, with (>CALLSIGN) before + */ + String originalMessage = newMessageArrived.getMessageText(); + newMessageArrived + .setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage); + this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) + + // if you sent the message to another station, it will be sorted in to + // the "to me message list" with modified messagetext, added rxers callsign + + } else { + // Message sent from one other station to another other station. + + //play only tick sound if the sender was not set directedtome before + if (effects.playDirectionalTick()) { + this.client.getPlayAudioUtils().playNoiseLauncher('-'); + } + + // The log statements keep their former position relative to the flag change. + if (live && opportunity == DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE) { + LOGGER.fine("Not in angle " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " + + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(newMessageArrived.getReceiver().getQra())) + + " ; sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(prefs.getStn_loginLocatorMainCat()))); + } + + newMessageArrived.getSender().setInAngleAndRange(effects.inAngleAndRange()); + + if (effects.evaluateDxClusterSpot()) { + try { + ChatMember sender = newMessageArrived.getSender(); + + DxClusterSpotDecision spotDecision = decideDxClusterSpot(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. + */ + this.client + .getDxClusterServer() + .broadcastSingleDXClusterEntryToLoggers( + buildDxClusterSpotEntry(sender) + ); + + } 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()); + } + } catch (Exception exception) { + LOGGER.log(Level.WARNING, + "DXCluster message-server error while processing spot for " + + newMessageArrived.getSender().getCallSign(), + exception); + } + } + + if (live && opportunity == DirectionalOpportunity.IN_ANGLE_AND_RANGE) { + LOGGER.fine("Angle warning " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " + + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(newMessageArrived.getReceiver().getQra())) + + " / sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(prefs.getStn_loginLocatorMainCat()))); + } else if (live && opportunity == DirectionalOpportunity.MISSING_LOCATOR) { + LOGGER.info("Skipping angle/range analysis for message with missing locator: " + + newMessageArrived.getSender().getCallSign() + " -> " + + newMessageArrived.getReceiver().getCallSign()); + } + + this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) + } + } catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) { + LOGGER.log(Level.WARNING, + live + ? "Referenced user disappeared while processing a message" + : "Referenced user disappeared while processing a historic message", + referenceDeletedByUserLeftChatDuringMessageprocessing); + } + } + + try { + + LOGGER.fine((live ? "Processed message: " : "Processed historic message: ") + + newMessageArrived.getChatCategory().getCategoryNumber() + + " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> " + + newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText()); + } catch (Exception exceptionOccured) { + LOGGER.log(Level.WARNING, + live + ? "Could not format processed-message diagnostic" + : "Could not format historic-message diagnostic", + exceptionOccured); + } + + if (!live) { + // --- Band/QRG recognition (fills ChatMember.knownActiveBands) --- + smartFrequencyExtraction(newMessageArrived, prefs); + } + } + /** * Processes received messages via port 23001 (improved telnet Interface) @@ -886,353 +1199,7 @@ public class MessageBusManagementThread extends Thread { * CH|2|1663966535|DM5M|dm5m-team|0|kst4contest.test|0| */ if (splittedMessageLine[0].contains(CHATCHANNELMESSAGE)) { - - //experimental 1.26: multi channel messages - ChatMessage newMessageArrived = new ChatMessage(); - ChatCategory chategoryForMessageAndMessageSender; - - newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1])); - - chategoryForMessageAndMessageSender = newMessageArrived.getChatCategory(); - newMessageArrived.setMessageGeneratedTime(splittedMessageLine[2]); - newMessageArrived.setMessageSenderName(splittedMessageLine[4]); - newMessageArrived.setMessageText(splittedMessageLine[6]); - - if (splittedMessageLine[3].equals("SERVER")) { - ChatMember dummy = new ChatMember(); - dummy.setAirPlaneReflectInfo(new AirPlaneReflectionInfo()); - dummy.setCallSign("SERVER"); - dummy.setName("Sysop"); - newMessageArrived.setSender(dummy); - newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1])); - dummy.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1])); -// System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> servers cat " + newMessageArrived.getChatCategory()); - - } else { - - newMessageArrived.setSender(resolveInboundSender( - splittedMessageLine[3], - chategoryForMessageAndMessageSender, - newMessageArrived)); - } - - /* - * Detect and store the sender's QRG before any directional-opportunity - * or DX-Cluster processing. A frequency first mentioned in this directed - * message is then immediately available for the resulting spot. - * - * The parser needs only the message text, sender and preferences. The - * receiver does not have to be resolved yet. - */ - smartFrequencyExtraction( - newMessageArrived, - this.client.getChatPreferences()); - - // TODO: Next: get frequency infos out of name? - - if (splittedMessageLine[7].equals("0")) { - // message is not directed to anyone, move it to the cq messages. - newMessageArrived.setReceiver(createAllReceiver()); - - this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) - -// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List - - } else { - //message is directed to another chatmember, process as such! - - newMessageArrived.setReceiver(resolveInboundReceiver( - splittedMessageLine[7], - chategoryForMessageAndMessageSender)); - -// System.out.println("message directed to: " + newMessageArrived.getReceiver().getCallSign() + ". EQ?: " + this.client.getownChatMemberObject().getCallSign() + " sent by: " + newMessageArrived.getSender().getCallSign().toUpperCase() + " -> EQ?: "+ this.client.getChatPreferences().getLoginCallSign().toUpperCase()); - - try { - /** - * message is directed to me, will be put in the "to me" messagelist - */ - if (newMessageArrived.getReceiver().getCallSign() - .equals(this.client.getChatPreferences().getStn_loginCallSign())) { - -// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); - - this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) - - if (this.client.getChatPreferences().isNotify_playSimpleSounds()) { - this.client.getPlayAudioUtils().playNoiseLauncher('P'); - } - if (this.client.getChatPreferences().isNotify_playCWCallsignsOnRxedPMs()) { - this.client.getPlayAudioUtils().playCWLauncher(" " + " " + newMessageArrived.getSender().getCallSign().toUpperCase()); - } - if (this.client.getChatPreferences().isNotify_playVoiceCallsignsOnRxedPMs()) { - this.client.getPlayAudioUtils().playVoiceLauncher( "?" + newMessageArrived.getSender().getCallSign().toUpperCase()); - } - - if (this.client.getChatPreferences().isNotify_playSimpleSounds()) { - if (newMessageArrived.getMessageText().toUpperCase().contains("//BELL")) { - this.client.getPlayAudioUtils().playVoiceLauncher("!"); - } - } - if (newMessageArrived.getMessageText().toUpperCase().contains("//VER")) { - - ChatMessage versionInfo = new ChatMessage(); - ChatMember itsMe = new ChatMember(); - itsMe.setCallSign(this.client.getChatPreferences().getStn_loginCallSign()); - - versionInfo.setSender(itsMe); - versionInfo.setReceiver(newMessageArrived.getSender()); - versionInfo.setChatCategory(newMessageArrived.getChatCategory()); - versionInfo.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() - + " " + AUTOANSWER_PREFIX - + " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION - + " by DO5AMF"); - - this.client.getMessageTXBus().add(versionInfo); - } - -// if (this.client.getChatPreferences().isMsgHandling_autoAnswerEnabled()) { -// -// ChatMessage automaticAnswer = new ChatMessage(); -// ChatMember itsMe = new ChatMember(); -// itsMe.setCallSign(this.client.getChatPreferences().getStn_loginCallSign()); -// -// automaticAnswer.setSender(itsMe); -// automaticAnswer.setReceiver(newMessageArrived.getSender()); -// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " " + this.client.getChatPreferences().getMessageHandling_autoAnswerTextMainCat()); -// -// this.client.getMessageTXBus().add(automaticAnswer); -// -// } - - /** - * auto reply/answer to QRG requests is here - */ -// if (this.client.getChatPreferences().isMessageHandling_autoAnswerToQRGRequestEnabled()) { -// -// for (String lookForQRGString : qrgQuestionTexts) { -// if (newMessageArrived.getMessageText().contains(lookForQRGString)) { -// -// ChatMessage automaticAnswer = new ChatMessage(); -// ChatMember itsMe = new ChatMember(); -// itsMe.setCallSign(this.client.getChatPreferences().getStn_loginCallSign()); -// -// automaticAnswer.setSender(itsMe); -// automaticAnswer.setReceiver(newMessageArrived.getSender()); -// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().get()); -// -// if (this.client.getChatPreferences().isLoginToSecondChatEnabled()) { -// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRGs: " + this.client.getChatPreferences().getMYQRGFirstCat().get() + " / " + this.client.getChatPreferences().getMYQRGSecondCat().get()); -// } else { -// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().get()); -// } -// -// this.client.getMessageTXBus().add(automaticAnswer); -// -// } -// } -// } - - // ==== Unified auto-answer (generic + QRG) with ping-pong guard -// and per-remote cooldown ==== - final String incomingText = newMessageArrived.getMessageText(); - -// Never answer another automatically generated message. - if (!isAutoMessage(incomingText)) { - - ChatPreferences autoAnswerPrefs = - this.client.getChatPreferences(); - - boolean qrgRequested = - autoAnswerPrefs.isMessageHandling_autoAnswerToQRGRequestEnabled() - && matchesQrgQuestion(incomingText, QRG_QUESTION_TEXTS); - - boolean genericEnabled = - autoAnswerPrefs.isMsgHandling_autoAnswerEnabled(); - - ChatMember incomingSender = newMessageArrived.getSender(); - - String automaticAnswerText = buildAutoAnswerMessageText( - incomingSender == null ? null : incomingSender.getCallSign(), - // Diagnostics only: same fallback as autoAnswerCooldownKey. - newMessageArrived.getChatCategory() != null || incomingSender == null - ? newMessageArrived.getChatCategory() - : incomingSender.getChatCategory(), - qrgRequested, - selectAutoAnswerQrg( - newMessageArrived.getChatCategory(), - this.client.getChatCategorySecondChat(), - autoAnswerPrefs.getMYQRGFirstCat().get(), - autoAnswerPrefs.getMYQRGSecondCat().get() - ), - genericEnabled, - autoAnswerPrefs.getMessageHandling_autoAnswerTextMainCat() - ); - - /* - * Invalid or incomplete replies are rejected before the cooldown - * is checked or updated. A missing QRG must therefore not suppress - * a later valid reply. - */ - if (automaticAnswerText != null - && isAutoAnswerAllowedNow(newMessageArrived)) { - - ChatMessage automaticAnswer = new ChatMessage(); - ChatMember itsMe = new ChatMember(); - - itsMe.setCallSign( - this.client.getChatPreferences() - .getStn_loginCallSign() - ); - - automaticAnswer.setSender(itsMe); - automaticAnswer.setReceiver( - newMessageArrived.getSender() - ); - automaticAnswer.setChatCategory( - newMessageArrived.getChatCategory() - ); - automaticAnswer.setMessageText(automaticAnswerText); - - this.client.getMessageTXBus().add(automaticAnswer); - - /* - * Record the cooldown only after a complete and locally - * validated reply has been placed in the transmit queue. - */ - markLocalAutoAnswerSent(newMessageArrived); - } - } - - - LOGGER.fine("Message directed to local callsign: " - + newMessageArrived.getReceiver().getCallSign()); - - } else if (newMessageArrived.getSender().getCallSign().toUpperCase() - .equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) { - /** - * message sent by me! - * message from me will appear in the PM window, too, with (>CALLSIGN) before - */ - String originalMessage = newMessageArrived.getMessageText(); - newMessageArrived - .setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage); -// this.client.getLst_globalChatMessageList().add(0,newMessageArrived); - this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) - - - // if you sent the message to another station, it will be sorted in to - // the "to me message list" with modified messagetext, added rxers callsign - - } else { - /* - * Message sent from one other station to another other station. - * - * The old code reached this point only with members from the visible user list. - * With explicit fallback sender/receiver objects, this path may also see - * stations that already left the chat or arrived before their user-list entry. - * 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. - */ - DirectionalOpportunity opportunity = classifyDirectionalOpportunity( - 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) { - - //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); - - if (client.getChatPreferences().isNotify_dxClusterServerEnabled()) { - try { - ChatMember sender = newMessageArrived.getSender(); - - DxClusterSpotDecision spotDecision = decideDxClusterSpot(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. - */ - this.client - .getDxClusterServer() - .broadcastSingleDXClusterEntryToLoggers( - buildDxClusterSpotEntry(sender) - ); - - } 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()); - } - } catch (Exception exception) { - LOGGER.log(Level.WARNING, - "DXCluster message-server error while processing spot for " - + newMessageArrived.getSender().getCallSign(), - exception); -// exception.printStackTrace(); - } - } - - LOGGER.fine("Angle warning " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " + - new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(newMessageArrived.getReceiver().getQra())) + - " / sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(client.getChatPreferences().getStn_loginLocatorMainCat()))); - - } else { - LOGGER.fine("Not in angle " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getSender().getQra() + " -> " + newMessageArrived.getReceiver().getCallSign() + ", " + newMessageArrived.getReceiver().getQra() + " = " + - new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(newMessageArrived.getReceiver().getQra())) + - " ; sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(client.getChatPreferences().getStn_loginLocatorMainCat()))); - newMessageArrived.getSender().setInAngleAndRange(false); - } - } else { - newMessageArrived.getSender().setInAngleAndRange(false); - LOGGER.info("Skipping angle/range analysis for message with missing locator: " - + newMessageArrived.getSender().getCallSign() + " -> " - + newMessageArrived.getReceiver().getCallSign()); - } - -// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); - this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) -// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign()); - } - } catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) { - LOGGER.log(Level.WARNING, - "Referenced user disappeared while processing a message", - referenceDeletedByUserLeftChatDuringMessageprocessing); - } - - // sdtout to me message-List - -// newMessageArrived.setReceiver(this.client.getChatMemberTable().get(splittedMessageLine[7])); // set sender - // to the - // member of - // before - // initialized - // list - } - - try { - - LOGGER.fine("Processed message: " + newMessageArrived.getChatCategory().getCategoryNumber() - + " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> " - + newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText()); - } catch (Exception exceptionOccured) { - LOGGER.log(Level.WARNING, - "Could not format processed-message diagnostic", - exceptionOccured); - } - - // TODO: Next: get frequency infos out of name? + processChatFrame(splittedMessageLine, true); } else /** @@ -1462,137 +1429,7 @@ public class MessageBusManagementThread extends Thread { * ^^^^^^^^^^time ... */ if (splittedMessageLine[0].contains(SERVERMESSAGEHISTORIC)) { - - - ChatMessage newMessageArrived = new ChatMessage(); - ChatCategory chategoryForMessageAndMessageSender; - - newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1])); - - chategoryForMessageAndMessageSender = newMessageArrived.getChatCategory(); - newMessageArrived.setMessageGeneratedTime(splittedMessageLine[2]); - newMessageArrived.setMessageSenderName(splittedMessageLine[4]); - newMessageArrived.setMessageText(splittedMessageLine[6]); - - if (splittedMessageLine[3].equals("SERVER")) { - ChatMember dummy = new ChatMember(); - dummy.setCallSign("SERVER"); - dummy.setName("Sysop"); - newMessageArrived.setSender(dummy); - newMessageArrived.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1])); - dummy.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1])); -// System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> servers cat " + newMessageArrived.getChatCategory()); - - } else { - - newMessageArrived.setSender(resolveInboundSender( - splittedMessageLine[3], - chategoryForMessageAndMessageSender, - newMessageArrived)); - } - - - if (splittedMessageLine[7].equals("0")) { - // message is not directed to anyone, move it to the cq messages. - newMessageArrived.setReceiver(createAllReceiver()); - -// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List - this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) - - } else { - //message is directed to another chatmember, process as such! - - newMessageArrived.setReceiver(resolveInboundReceiver( - splittedMessageLine[7], - chategoryForMessageAndMessageSender)); - -// System.out.println("message directed to: " + newMessageArrived.getReceiver().getCallSign() + ". EQ?: " + this.client.getownChatMemberObject().getCallSign() + " sent by: " + newMessageArrived.getSender().getCallSign().toUpperCase() + " -> EQ?: "+ this.client.getChatPreferences().getLoginCallSign().toUpperCase()); - - try { - /** - * message is directed to me, will be put in the "to me" messagelist - */ - if (newMessageArrived.getReceiver().getCallSign() - .equals(this.client.getChatPreferences().getStn_loginCallSign())) { - -// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); - this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) - - LOGGER.fine("Historic message directed to local callsign: " - + newMessageArrived.getReceiver().getCallSign()); - - } else if (newMessageArrived.getSender().getCallSign().toUpperCase() - .equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) { - /** - * message sent by me! - * message from me will appear in the PM window, too, with (>CALLSIGN) before - */ - String originalMessage = newMessageArrived.getMessageText(); - newMessageArrived - .setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage); -// this.client.getLst_globalChatMessageList().add(0,newMessageArrived); - - this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) - // if you sent the message to another station, it will be sorted in to - // the "to me message list" with modified messagetext, added rxers callsign - - } else { - //message sent to other user - // message sent from one other station to another other station - DirectionalOpportunity opportunity = classifyDirectionalOpportunity( - 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); - - } else { - newMessageArrived.getSender().setInAngleAndRange(false); - } - } else { - /* - * Historic fallback senders/receivers may not have locators. - * The message is still valid and should be displayed, but angle/range - * analysis cannot be calculated without both locators. - */ - newMessageArrived.getSender().setInAngleAndRange(false); - } - - this.client.publishChatMessage(newMessageArrived); -// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign()); - } - } catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) { - LOGGER.log(Level.WARNING, - "Referenced user disappeared while processing a historic message", - referenceDeletedByUserLeftChatDuringMessageprocessing); - } - - // sdtout to me message-List - - } - - try { - - LOGGER.fine("Processed historic message: " + newMessageArrived.getChatCategory().getCategoryNumber() - + " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> " - + newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText()); - } catch (Exception exceptionOccured) { - LOGGER.log(Level.WARNING, - "Could not format historic-message diagnostic", - exceptionOccured); - } - - // --- Band/QRG recognition (fills ChatMember.knownActiveBands) --- - smartFrequencyExtraction(newMessageArrived, this.client.getChatPreferences()); - - - - + processChatFrame(splittedMessageLine, false); } else /** @@ -1853,6 +1690,30 @@ public class MessageBusManagementThread extends Thread { : DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE; } + /** + * Where a chat message belongs. Keeps the long-standing asymmetry on purpose: the + * receiver is compared with the login call case-sensitively, the sender + * case-insensitively. Changing either changes which messages are answered or marked. + * + * @param receiverField the raw receiver field; {@code 0} means public + * @param senderCall the sender's call + * @param receiverCall the resolved receiver's call, unused for public messages + * @param ownCall the raw login call from the preferences + * @return the route of the message + */ + static ChatRoute classifyRoute(String receiverField, String senderCall, String receiverCall, String ownCall) { + if ("0".equals(receiverField)) { + return ChatRoute.PUBLIC; + } + if (receiverCall.equals(ownCall)) { + return ChatRoute.TO_ME; + } + if (senderCall.toUpperCase().equals(ownCall.toUpperCase())) { + return ChatRoute.FROM_ME; + } + return ChatRoute.BETWEEN_OTHERS; + } + /** * Returns whether the short tick sound for a new directional opportunity is * played: only with simple sounds enabled and only on the transition into @@ -1869,6 +1730,78 @@ public class MessageBusManagementThread extends Thread { return simpleSoundsEnabled && !senderAlreadyInAngleAndRange; } + /** + * Settings the chat frame decision depends on. + * + * @param simpleSounds whether simple notification sounds are enabled + * @param cwOnPm whether the sender's call is sent in CW for private messages + * @param voiceOnPm whether the sender's call is spoken for private messages + * @param dxClusterServerEnabled whether the DX Cluster server is enabled + * @param autoAnswerTextCandidate the auto answer text built for this message, or {@code null} + */ + record ChatFrameSettings( + boolean simpleSounds, + boolean cwOnPm, + boolean voiceOnPm, + boolean dxClusterServerEnabled, + String autoAnswerTextCandidate + ) { + } + + /** + * Decides the side effects of a chat frame. + * + * + * @param route the route of the message + * @param live {@code true} for a live frame, {@code false} for history + * @param messageText the message text + * @param opportunity the directional opportunity, only used between others + * @param senderAlreadyInAngleAndRange the sender's flag before this message + * @param settings the settings the decision depends on + * @return the effects to execute + */ + static ChatMessageEffects decideChatFrameEffects( + ChatRoute route, + boolean live, + String messageText, + DirectionalOpportunity opportunity, + boolean senderAlreadyInAngleAndRange, + ChatFrameSettings settings + ) { + switch (route) { + case TO_ME: + if (!live) { + return ChatMessageEffects.NONE; + } + String upper = messageText.toUpperCase(); + return new ChatMessageEffects( + settings.simpleSounds(), + settings.cwOnPm(), + settings.voiceOnPm(), + settings.simpleSounds() && upper.contains("//BELL"), + upper.contains("//VER"), + isAutoMessage(messageText) ? null : settings.autoAnswerTextCandidate(), + null, + false, + false); + case BETWEEN_OTHERS: + boolean inRange = opportunity == DirectionalOpportunity.IN_ANGLE_AND_RANGE; + boolean tick = live && inRange + && shouldPlayDirectionalTick(settings.simpleSounds(), senderAlreadyInAngleAndRange); + boolean spot = live && inRange && settings.dxClusterServerEnabled(); + return new ChatMessageEffects(false, false, false, false, false, null, inRange, tick, spot); + default: + return ChatMessageEffects.NONE; + } + } + /** * Outcome of the automatic DX Cluster spot decision for a directional opportunity. */ diff --git a/core/src/test/java/kst4contest/controller/ChatFrameDecisionTest.java b/core/src/test/java/kst4contest/controller/ChatFrameDecisionTest.java new file mode 100644 index 00000000..e38ee8d2 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/ChatFrameDecisionTest.java @@ -0,0 +1,115 @@ +package kst4contest.controller; + +import kst4contest.ApplicationConstants; +import kst4contest.controller.MessageBusManagementThread.DirectionalOpportunity; +import org.junit.jupiter.api.Test; + +import static kst4contest.controller.ChatRoute.*; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ChatFrameDecisionTest { + + @Test + void frameFieldsAreReadFromTheTraditionalIndices() { + InboundChatFrame frame = InboundChatFrame.of( + "CH|2|1700000000|DL1ABC|Hans|0|qrv 144.300|DN9APW|".split("\\|")); + + assertEquals(new InboundChatFrame("2", "1700000000", "DL1ABC", "Hans", "qrv 144.300", "DN9APW"), frame); + } + + @Test + void receiverZeroIsPublic() { + assertEquals(PUBLIC, MessageBusManagementThread.classifyRoute("0", "DL1ABC", null, "DN9APW")); + } + + @Test + void toMeComparesCaseSensitively() { + assertEquals(TO_ME, MessageBusManagementThread.classifyRoute("DN9APW", "DL1ABC", "DN9APW", "DN9APW")); + assertEquals(BETWEEN_OTHERS, MessageBusManagementThread.classifyRoute("dn9apw", "DL1ABC", "dn9apw", "DN9APW")); + } + + @Test + void fromMeComparesCaseInsensitively() { + assertEquals(FROM_ME, MessageBusManagementThread.classifyRoute("DL1ABC", "dn9apw", "DL1ABC", "DN9APW")); + } + + @Test + void toMeWinsOverFromMe() { + assertEquals(TO_ME, MessageBusManagementThread.classifyRoute("DN9APW", "DN9APW", "DN9APW", "DN9APW")); + } + + @Test + void anythingElseIsBetweenOthers() { + assertEquals(BETWEEN_OTHERS, MessageBusManagementThread.classifyRoute("DL2XYZ", "DL1ABC", "DL2XYZ", "DN9APW")); + } + + private static final MessageBusManagementThread.ChatFrameSettings ALL_ON = + new MessageBusManagementThread.ChatFrameSettings(true, true, true, true, "/CQ DL1ABC [auto] QRG is: 144.300"); + + @Test + void publicAndFromMeDoNothing() { + for (boolean live : new boolean[] { true, false }) { + assertEquals(ChatMessageEffects.NONE, MessageBusManagementThread.decideChatFrameEffects(PUBLIC, live, "//ver", null, false, ALL_ON)); + assertEquals(ChatMessageEffects.NONE, MessageBusManagementThread.decideChatFrameEffects(FROM_ME, live, "//ver", null, false, ALL_ON)); + } + } + + @Test + void liveMessageToMeRingsAnswersAndReplies() { + ChatMessageEffects effects = MessageBusManagementThread.decideChatFrameEffects(TO_ME, true, "pse //Bell //ver", null, false, ALL_ON); + + assertEquals(new ChatMessageEffects(true, true, true, true, true, "/CQ DL1ABC [auto] QRG is: 144.300", null, false, false), effects); + } + + @Test + void bellNeedsSimpleSounds() { + var noSounds = new MessageBusManagementThread.ChatFrameSettings(false, false, false, false, null); + ChatMessageEffects effects = MessageBusManagementThread.decideChatFrameEffects(TO_ME, true, "//bell", null, false, noSounds); + + assertEquals(false, effects.playBell()); + assertEquals(false, effects.playPmSound()); + } + + @Test + void anAutomaticMessageIsNeverAnswered() { + String auto = ApplicationConstants.AUTOANSWER_PREFIX + " QRG is: 432.100"; + assertEquals(null, MessageBusManagementThread.decideChatFrameEffects(TO_ME, true, auto, null, false, ALL_ON).autoAnswerText()); + } + + @Test + void historyMessageToMeDoesNothing() { + assertEquals(ChatMessageEffects.NONE, MessageBusManagementThread.decideChatFrameEffects(TO_ME, false, "//bell //ver", null, false, ALL_ON)); + } + + @Test + void liveOpportunityTicksOnceAndSpots() { + ChatMessageEffects first = MessageBusManagementThread.decideChatFrameEffects( + BETWEEN_OTHERS, true, "", DirectionalOpportunity.IN_ANGLE_AND_RANGE, false, ALL_ON); + ChatMessageEffects again = MessageBusManagementThread.decideChatFrameEffects( + BETWEEN_OTHERS, true, "", DirectionalOpportunity.IN_ANGLE_AND_RANGE, true, ALL_ON); + + assertEquals(Boolean.TRUE, first.inAngleAndRange()); + assertEquals(true, first.playDirectionalTick()); + assertEquals(true, first.evaluateDxClusterSpot()); + assertEquals(false, again.playDirectionalTick()); + } + + @Test + void historyOpportunityOnlySetsTheFlag() { + ChatMessageEffects effects = MessageBusManagementThread.decideChatFrameEffects( + BETWEEN_OTHERS, false, "", DirectionalOpportunity.IN_ANGLE_AND_RANGE, false, ALL_ON); + + assertEquals(new ChatMessageEffects(false, false, false, false, false, null, Boolean.TRUE, false, false), effects); + } + + @Test + void outOfAngleOrMissingLocatorClearsTheFlag() { + for (DirectionalOpportunity o : new DirectionalOpportunity[] { + DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE, DirectionalOpportunity.MISSING_LOCATOR }) { + ChatMessageEffects effects = MessageBusManagementThread.decideChatFrameEffects(BETWEEN_OTHERS, true, "", o, true, ALL_ON); + assertEquals(Boolean.FALSE, effects.inAngleAndRange()); + assertEquals(false, effects.playDirectionalTick()); + assertEquals(false, effects.evaluateDxClusterSpot()); + } + } +} diff --git a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java new file mode 100644 index 00000000..2e0fa608 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java @@ -0,0 +1,425 @@ +package kst4contest.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +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.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.LinkedBlockingQueue; + +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.controller.MessageBusManagementThread.DirectionalOpportunity; +import kst4contest.controller.MessageBusManagementThread.DxClusterSpotDecision; +import kst4contest.model.AirPlaneReflectionInfo; +import kst4contest.model.Band; +import kst4contest.model.ChatCategory; +import kst4contest.model.ChatMember; +import kst4contest.model.ChatMessage; +import kst4contest.model.ChatPreferences; +import kst4contest.observe.SimpleValue; +import kst4contest.utils.PlayAudioUtils; + +/** + * Characterises the live ({@code CH}) and history ({@code CR}) chat-frame branches + * of {@link MessageBusManagementThread} by running real frames through a started + * thread. The history branch must publish like the live one but never play sounds, + * answer, tick or spot. + * + *

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

+ */ +class MessageBusManagementThreadChatFrameTest { + + private static final String MY_CALL = "DN9APW"; + private static final String REMOTE_CALL = "DL1ABC"; + private static final String OTHER_CALL = "DL2XYZ"; + private static final String FIRST_QRG = "144.300"; + + // Local station between a western sender and an eastern receiver, all on one latitude. + private static final String OWN_LOCATOR = "JO50LL"; + private static final String SENDER_LOCATOR = "JO30LL"; + private static final String RECEIVER_LOCATOR = "JO70LL"; + private static final double MAX_QRB_KM = 900.0; + private static final double BEAM_WIDTH_DEG = 50.0; + + @TempDir + Path tempDir; + + private ChatController controller; + private ChatPreferences prefs; + private PlayAudioUtils playAudioUtils; + private DXClusterThreadPooledServer dxClusterServer; + private LinkedBlockingQueue txBus; + private LinkedBlockingQueue rxQueue; + private Map activeMembers; + + @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("1296.200"); + + ChatCategory mainCategory = new ChatCategory(ChatCategory.VUHF); + ChatCategory secondCategory = new ChatCategory(ChatCategory.MICROWAVE); + txBus = new LinkedBlockingQueue<>(); + rxQueue = new LinkedBlockingQueue<>(); + activeMembers = new HashMap<>(); + playAudioUtils = mock(PlayAudioUtils.class); + dxClusterServer = mock(DXClusterThreadPooledServer.class); + + 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(playAudioUtils); + when(controller.getStationMetricsService()) + .thenReturn(mock(StationMetricsService.class)); + when(controller.getScoreService()).thenReturn(mock(ScoreService.class)); + when(controller.getDxClusterServer()).thenReturn(dxClusterServer); + when(controller.findActiveChatMember(any(ChatMember.class))) + .thenAnswer(invocation -> activeMemberFor(invocation.getArgument(0))); + } + + private void enableAllPrivateMessageSounds() { + prefs.setNotify_playSimpleSounds(true); + prefs.setNotify_playCWCallsignsOnRxedPMs(true); + prefs.setNotify_playVoiceCallsignsOnRxedPMs(true); + } + + // --- addressed to me ---------------------------------------------------- + + @Test + @Timeout(10) + void liveMessageToMePlaysSoundsAndAnswersVersion() throws InterruptedException { + enableAllPrivateMessageSounds(); + + runThrough(chatFrame("CH", REMOTE_CALL, "//ver //bell", MY_CALL)); + + verify(playAudioUtils).playNoiseLauncher('P'); + verify(playAudioUtils).playCWLauncher(" " + REMOTE_CALL); + verify(playAudioUtils).playVoiceLauncher("?" + REMOTE_CALL); + verify(playAudioUtils).playVoiceLauncher("!"); + assertEquals(1, txBus.size()); + assertEquals( + "/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX + + " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION + + " by DO5AMF", + txBus.peek().getMessageText()); + verify(controller, times(1)).publishChatMessage(any(ChatMessage.class)); + } + + @Test + @Timeout(10) + void historyMessageToMePublishesOnly() throws InterruptedException { + enableAllPrivateMessageSounds(); + + runThrough(chatFrame("CR", REMOTE_CALL, "//ver //bell ur qrg?", MY_CALL)); + + verify(controller, times(1)).publishChatMessage(any(ChatMessage.class)); + assertTrue(txBus.isEmpty()); + verifyNoInteractions(playAudioUtils); + } + + // --- sent by me --------------------------------------------------------- + + @Test + @Timeout(10) + void liveMessageFromMeIsMarkedWithTheReceiver() throws InterruptedException { + runThrough(chatFrame("CH", MY_CALL.toLowerCase(), "hello", REMOTE_CALL)); + + assertEquals("(>" + REMOTE_CALL + ")hello", singlePublished().getMessageText()); + } + + @Test + @Timeout(10) + void historyMessageFromMeIsMarkedWithTheReceiver() throws InterruptedException { + runThrough(chatFrame("CR", MY_CALL.toLowerCase(), "hello", REMOTE_CALL)); + + assertEquals("(>" + REMOTE_CALL + ")hello", singlePublished().getMessageText()); + } + + /** + * Pinned: the receiver is resolved case-insensitively against the login call and + * {@code ChatMember#setCallSign} upper-cases it, so a lower-case receiver in the + * frame still counts as addressed to me. (Only a lower-case login call defeats + * the case-sensitive TO_ME comparison, see the auto-answer characterisation.) + */ + @Test + @Timeout(10) + void receiverInDifferentCaseIsStillTreatedAsMe() throws InterruptedException { + runThrough(chatFrame("CH", REMOTE_CALL, "ur qrg?", MY_CALL.toLowerCase())); + + assertEquals(1, txBus.size()); + verify(controller, times(1)).publishChatMessage(any(ChatMessage.class)); + } + + // --- between two other stations ---------------------------------------- + + @Test + @Timeout(10) + void liveMessageBetweenOthersInAngleTicksAndSpots() throws InterruptedException { + ChatMember sender = prepareInAngleScene(); + prefs.setNotify_playSimpleSounds(true); + prefs.setNotify_dxClusterServerEnabled(true); + + runThrough(chatFrame("CH", REMOTE_CALL, "hello", OTHER_CALL)); + + verify(playAudioUtils).playNoiseLauncher('-'); + ArgumentCaptor spot = ArgumentCaptor.forClass(ChatMember.class); + verify(dxClusterServer, times(1)).broadcastSingleDXClusterEntryToLoggers(spot.capture()); + assertEquals(REMOTE_CALL, spot.getValue().getCallSign()); + assertEquals(FIRST_QRG, spot.getValue().getFrequency().get()); + assertTrue(sender.isInAngleAndRange()); + verify(controller, times(1)).publishChatMessage(any(ChatMessage.class)); + } + + @Test + @Timeout(10) + void historyMessageBetweenOthersInAngleOnlySetsTheFlag() throws InterruptedException { + ChatMember sender = prepareInAngleScene(); + prefs.setNotify_playSimpleSounds(true); + prefs.setNotify_dxClusterServerEnabled(true); + + runThrough(chatFrame("CR", REMOTE_CALL, "hello", OTHER_CALL)); + + assertTrue(sender.isInAngleAndRange()); + verify(playAudioUtils, never()).playNoiseLauncher('-'); + verify(dxClusterServer, never()).broadcastSingleDXClusterEntryToLoggers(any()); + verify(controller, times(1)).publishChatMessage(any(ChatMessage.class)); + } + + // --- SERVER sender ------------------------------------------------------ + + @Test + @Timeout(10) + void liveServerSenderGetsAirScoutInfo() throws InterruptedException { + runThrough(chatFrame("CH", "SERVER", "welcome", "0")); + + ChatMessage live = singlePublished(); + assertEquals("SERVER", live.getSender().getCallSign()); + assertEquals("Sysop", live.getSender().getName()); + assertNotNull(live.getSender().getAirPlaneReflectInfo()); + } + + @Test + @Timeout(10) + void historyServerSenderGetsNoAirScoutInfo() throws InterruptedException { + runThrough(chatFrame("CR", "SERVER", "welcome", "0")); + + ChatMessage history = singlePublished(); + assertEquals("SERVER", history.getSender().getCallSign()); + assertEquals("Sysop", history.getSender().getName()); + assertNull(history.getSender().getAirPlaneReflectInfo()); + } + + // --- frequency detection order ----------------------------------------- + + @Test + @Timeout(10) + void liveDetectsFrequencyBeforePublishingHistoryAfter() throws InterruptedException { + stubFrequencyDetection(); + + List knownAtPublish = new ArrayList<>(); + List senders = new ArrayList<>(); + doAnswer(invocation -> { + ChatMessage published = invocation.getArgument(0); + senders.add(published.getSender()); + knownAtPublish.add(hasFrequency(published.getSender())); + return null; + }).when(controller).publishChatMessage(any(ChatMessage.class)); + + runThrough(chatFrame("CH", REMOTE_CALL, "qrv 144.300", "0")); + assertEquals(List.of(true), knownAtPublish); + + knownAtPublish.clear(); + senders.clear(); + runThrough(chatFrame("CR", REMOTE_CALL, "qrv 144.300", "0")); + assertEquals(List.of(false), knownAtPublish); + assertTrue(hasFrequency(senders.get(0)), "known once the thread has finished"); + } + + @Test + @Timeout(10) + void liveFrequencyFromADirectedMessageIsAvailableForTheSpot() throws InterruptedException { + stubFrequencyDetection(); + ChatMember sender = prepareInAngleSceneWithoutFrequency(); + prefs.setNotify_dxClusterServerEnabled(true); + List knownAtSpot = new ArrayList<>(); + doAnswer(invocation -> { + knownAtSpot.add(hasFrequency(invocation.getArgument(0))); + return null; + }).when(dxClusterServer).broadcastSingleDXClusterEntryToLoggers(any()); + + runThrough(chatFrame("CH", REMOTE_CALL, "qrv 144.300", OTHER_CALL)); + + assertEquals(List.of(true), knownAtSpot); + assertTrue(sender.isInAngleAndRange()); + verify(controller, times(1)).publishChatMessage(any(ChatMessage.class)); + } + + @Test + @Timeout(10) + void historyFrequencyFromADirectedMessageComesAfterPublishingWithoutSpot() + throws InterruptedException { + stubFrequencyDetection(); + ChatMember sender = prepareInAngleSceneWithoutFrequency(); + prefs.setNotify_dxClusterServerEnabled(true); + List knownAtPublish = new ArrayList<>(); + doAnswer(invocation -> { + knownAtPublish.add(hasFrequency(invocation.getArgument(0).getSender())); + return null; + }).when(controller).publishChatMessage(any(ChatMessage.class)); + + runThrough(chatFrame("CR", REMOTE_CALL, "qrv 144.300", OTHER_CALL)); + + assertEquals(List.of(false), knownAtPublish); + assertTrue(hasFrequency(sender), "known once the thread has finished"); + assertTrue(sender.isInAngleAndRange()); + verify(dxClusterServer, never()).broadcastSingleDXClusterEntryToLoggers(any()); + } + + // ---------------------------------------------------------------------- + + /** The controller is a mock: mimic what the real one does to the sender. */ + private void stubFrequencyDetection() { + doAnswer(invocation -> { + ChatMember sender = invocation.getArgument(0); + sender.setFrequency(new SimpleValue<>(String.valueOf((double) invocation.getArgument(2)))); + return null; + }).when(controller).applyDetectedFrequencyToActiveMembers( + any(ChatMember.class), any(Band.class), anyDouble()); + } + + /** In-angle scene whose sender has no known frequency yet. */ + private ChatMember prepareInAngleSceneWithoutFrequency() { + prefs.setStn_loginLocatorMainCat(OWN_LOCATOR); + prefs.setStn_maxQRBDefault(MAX_QRB_KM); + prefs.setStn_antennaBeamWidthDeg(BEAM_WIDTH_DEG); + + ChatMember sender = knownMember(REMOTE_CALL, SENDER_LOCATOR); + knownMember(OTHER_CALL, RECEIVER_LOCATOR); + + assertEquals(DirectionalOpportunity.IN_ANGLE_AND_RANGE, + MessageBusManagementThread.classifyDirectionalOpportunity( + OWN_LOCATOR, SENDER_LOCATOR, RECEIVER_LOCATOR, MAX_QRB_KM, BEAM_WIDTH_DEG)); + assertFalse(hasFrequency(sender)); + assertFalse(sender.isInAngleAndRange()); + return sender; + } + + /** + * Registers sender and receiver with locators that form an in-angle opportunity + * and a spottable sender; the setup is checked with the pure functions first. + */ + private ChatMember prepareInAngleScene() { + prefs.setStn_loginLocatorMainCat(OWN_LOCATOR); + prefs.setStn_maxQRBDefault(MAX_QRB_KM); + prefs.setStn_antennaBeamWidthDeg(BEAM_WIDTH_DEG); + + ChatMember sender = knownMember(REMOTE_CALL, SENDER_LOCATOR); + sender.setFrequency(new SimpleValue<>(FIRST_QRG)); + knownMember(OTHER_CALL, RECEIVER_LOCATOR); + + assertEquals(DirectionalOpportunity.IN_ANGLE_AND_RANGE, + MessageBusManagementThread.classifyDirectionalOpportunity( + OWN_LOCATOR, SENDER_LOCATOR, RECEIVER_LOCATOR, MAX_QRB_KM, BEAM_WIDTH_DEG)); + assertEquals(DxClusterSpotDecision.SPOT, + MessageBusManagementThread.decideDxClusterSpot(sender)); + assertFalse(sender.isInAngleAndRange()); + return sender; + } + + private ChatMember knownMember(String callSign, String locator) { + ChatMember member = new ChatMember(); + member.setCallSign(callSign); + member.setQra(locator); + member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo()); + activeMembers.put(callSign, member); + return member; + } + + private static boolean hasFrequency(ChatMember member) { + return member.getFrequency() != null + && member.getFrequency().get() != null + && !member.getFrequency().get().isBlank(); + } + + private ChatMessage singlePublished() { + ArgumentCaptor published = ArgumentCaptor.forClass(ChatMessage.class); + verify(controller, times(1)).publishChatMessage(published.capture()); + return published.getValue(); + } + + private void runThrough(ChatMessage... frames) throws InterruptedException { + MessageBusManagementThread thread = new MessageBusManagementThread( + controller, + mock(ThreadStatusCallback.class), + 7L, + rxQueue, + ignored -> true); + for (ChatMessage frame : frames) { + rxQueue.add(frame); + } + rxQueue.add(poisonPill()); + thread.start(); + thread.join(); + } + + private static ChatMessage chatFrame( + String opcode, String senderCall, String text, String receiverCall) { + return message(opcode + "|2|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: registered stations as-is, others with a default locator. */ + private ChatMember activeMemberFor(ChatMember lookup) { + ChatMember registered = activeMembers.get(lookup.getCallSign()); + if (registered != null) { + return registered; + } + ChatMember member = new ChatMember(); + member.setCallSign(lookup.getCallSign()); + member.setChatCategory(lookup.getChatCategory()); + member.setQra("JO31AA"); + member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo()); + return member; + } +} diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index ab2be8b8..605358ce 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -365,6 +365,7 @@ section records only the durable architecture and operational boundaries. - Keep canonical worker-thread domain state separate from the row lists the user interface draws from. - The station map is a Compose canvas that fetches its own tiles. The JavaFX WebView, the bundled Leaflet resources, the CSS-3D-transform workaround and the `TileProxyServer` were removed with JavaFX; offline DEM, terrain packages and the profile cache are unaffected and must stay that way. - Deliberate test data, comments and Easter eggs are preserved unless explicitly changed. +- Chat frames (issue #105, second slice): CH (live) and CR (history) go through one `MessageBusManagementThread.processChatFrame(fields, live)`. `classifyRoute` and `decideChatFrameEffects` are pure; history frames never play sounds, answer or spot, and keep their frequency extraction after publishing. Known oddities kept on purpose: TO_ME is case-sensitive, FROM_ME case-insensitive; the CR SERVER sender has no AirScout info. ## Planned Technical Direction diff --git a/docs/superpowers/specs/2026-10-16-message-bus-second-slice-design.md b/docs/superpowers/specs/2026-10-16-message-bus-second-slice-design.md new file mode 100644 index 00000000..c931351f --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-message-bus-second-slice-design.md @@ -0,0 +1,67 @@ +# Entwurf — MessageBusManagementThread, zweite Scheibe (#105) + +Freigegeben vom Maintainer am 2026-10-16 (Gespräch). Baut auf der ersten Scheibe auf +(PR #123: Frequenz- und Auto-Antwort-Charakterisierung, reine Entscheidungsfunktionen für +Auto-Antwort, Frequenzerkennung, Richtungs-Chance und DX-Cluster-Spot). + +## Warum + +`processRXMessage23001` behandelt Chatnachrichten zweimal: im CH-Zweig (Live-Chat, rund +350 Zeilen) und im CR-Zweig (Verlauf beim Anmelden, rund 130 Zeilen). Der CR-Zweig ist eine +nachgeführte Kopie des CH-Zweigs. Jede Korrektur muss zweimal gemacht werden, und was im +CH-Zweig passiert — Töne, Befehle, Auto-Antwort, Tick, DX-Cluster-Spot — ist nur über einen +laufenden Thread mit gemockten Mitspielern erreichbar. + +## Ziel + +Ein gemeinsamer Ablauf für CH und CR, dessen Entscheidungen als reine Funktionen ohne Thread +testbar sind. **Verhalten bleibt unverändert**, auch dort, wo es eigenartig ist. + +## Die vier Unterschiede, die erhalten bleiben + +| | CH (live) | CR (Verlauf) | +|---|---|---| +| Frequenzerkennung | vor dem Zuordnen und Veröffentlichen | nach dem Veröffentlichen und dem Diagnose-Log | +| an mich | veröffentlichen, Töne (P, CW, Stimme), `//BELL`, `//VER`-Antwort, Auto-Antwort | nur veröffentlichen | +| zwischen anderen | Winkel-Flag, Tick, DX-Cluster-Spot, Diagnose-Logs | nur Winkel-Flag | +| SERVER-Absender | mit leerer `AirPlaneReflectionInfo` | ohne | + +Gleich in beiden: öffentliche Nachricht (Empfängerfeld `0`) wird mit dem „alle“-Empfänger +veröffentlicht; eigene Nachricht an andere bekommt `(>CALL)` vorangestellt; ein +`NullPointerException` beim Zuordnen wird geloggt und verschluckt. + +## Bausteine + +1. **Charakterisierungstests für CR** durch den echten Thread, wie die erste Scheibe es für + CH getan hat. Sie halten die vier Unterschiede fest, bevor irgendetwas verschoben wird. +2. **`InboundChatFrame`** — reines Record aus den Feldern eines CH/CR-Frames: Kategorie-Nummer, + Zeit, Absender-Call, Absender-Name, Text, Empfängerfeld. Fabrikmethode aus `String[]`. +3. **`ChatRoute classifyRoute(String receiverField, String senderCall, String receiverCall, + String ownCall)`** → `PUBLIC | TO_ME | FROM_ME | BETWEEN_OTHERS`. Rein. Erhält die bekannte + Eigenart: „an mich“ vergleicht mit Groß-/Kleinschreibung, „von mir“ ohne. +4. **`ChatMessageEffects`** — Entscheidungsobjekt. Eine reine Funktion + `decide(frame, route, live, context)` liefert, was zu tun ist: Töne, `//BELL`, + `//VER`-Antwort, Auto-Antwort-Text, Tick, Winkel-Flag, Spot-Entscheidung. Der Kontext + trägt die Präferenzwerte und den bisherigen Winkel-Zustand des Absenders, keine Objekte mit + Seiteneffekten. Die vorhandenen reinen Funktionen der ersten Scheibe werden genutzt, nicht + dupliziert. +5. **Ein Ausführer** wendet die Effekte an (Töne abspielen, in die TX-Queue legen, + Cooldown setzen, Spot senden, veröffentlichen) — in der heutigen Reihenfolge. +6. **`processChatFrame(String[] fields, boolean live)`** ersetzt CH- und CR-Zweig. `live` + steuert die vier Unterschiede oben, sonst nichts. + +## Bewusst nicht + +- Die Eigenarten werden nicht korrigiert; sie werden als Kandidaten für eigene Issues gelistet + (Groß-/Kleinschreibung bei „an mich“, Reihenfolge der Frequenzerkennung in CR, SERVER ohne + AirScout-Info in CR). +- Sniffer, Erinnerungen und Skeds im `ChatController` sind ein eigenes Thema. +- Andere Frame-Typen (Benutzerliste, LOC, DX-Cluster, UE …) bleiben, wie sie sind. + +## Tests + +- CR-Charakterisierung durch den Thread (Schritt 1); die vorhandenen CH-Tests der ersten + Scheibe bleiben unverändert grün — sie sichern das Zusammenlegen ab. +- `InboundChatFrame`, `classifyRoute` (alle vier Routen, beide Schreibweisen-Eigenarten), + `decide` (je Route live und Verlauf, mit und ohne Töne/Befehle/Auto-Antwort/Spot). +- Voller Build grün; Testzahlen aus den XML-Berichten.