mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-18 21:34:03 +02:00
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:
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+425
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user