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 <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-16 19:45:21 +02:00
1 parent 797b01c748
commit e0c1c6b6d4
8 files changed
+1083 -478

No files matched your search

@@ -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);
}
@@ -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
}
@@ -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|}.
*
* <p>Read exactly as the frame branches always did: no trimming and no validation, so a
* short frame fails where it failed before.</p>
*/
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]);
}
}
@@ -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).
*
* <p>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.</p>
*
* <p>The fields are read in the same order as before, so a truncated frame
* fails at the same point, after the same side effects.</p>
*
* @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.
* <ul>
* <li>Public and own messages: none.</li>
* <li>Live message to me: PM sound, CW and voice call per settings, bell for {@code //BELL}
* (with simple sounds), version reply for {@code //VER}, auto answer unless the text is automatic.</li>
* <li>History message to me: none.</li>
* <li>Between others: the sender's flag follows the opportunity; live and in angle and range
* additionally the tick and the DX Cluster spot evaluation.</li>
* </ul>
*
* @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.
*/
@@ -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());
}
}
}
@@ -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.
*
* <p>Frame layout, as split on {@code |} by the thread:
* {@code <CH|CR>|category|epoch|senderCall|senderName|0|text|receiverCall|}; a
* receiver of {@code 0} marks a public message.</p>
*/
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<ChatMessage> txBus;
private LinkedBlockingQueue<ChatMessage> rxQueue;
private Map<String, ChatMember> 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<ChatMember> 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<Boolean> knownAtPublish = new ArrayList<>();
List<ChatMember> 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<Boolean> knownAtSpot = new ArrayList<>();
doAnswer(invocation -> {
knownAtSpot.add(hasFrequency(invocation.<ChatMember>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<Boolean> knownAtPublish = new ArrayList<>();
doAnswer(invocation -> {
knownAtPublish.add(hasFrequency(invocation.<ChatMessage>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<ChatMessage> 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;
}
}
+1
View File
@@ -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
@@ -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.