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));
|
+ ", 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)
|
* 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|
|
* CH|2|1663966535|DM5M|dm5m-team|0|kst4contest.test|0|
|
||||||
*/
|
*/
|
||||||
if (splittedMessageLine[0].contains(CHATCHANNELMESSAGE)) {
|
if (splittedMessageLine[0].contains(CHATCHANNELMESSAGE)) {
|
||||||
|
processChatFrame(splittedMessageLine, true);
|
||||||
//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?
|
|
||||||
} else
|
} else
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1462,137 +1429,7 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
* ^^^^^^^^^^time ...
|
* ^^^^^^^^^^time ...
|
||||||
*/
|
*/
|
||||||
if (splittedMessageLine[0].contains(SERVERMESSAGEHISTORIC)) {
|
if (splittedMessageLine[0].contains(SERVERMESSAGEHISTORIC)) {
|
||||||
|
processChatFrame(splittedMessageLine, false);
|
||||||
|
|
||||||
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());
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
} else
|
} else
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1853,6 +1690,30 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
: DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE;
|
: 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
|
* Returns whether the short tick sound for a new directional opportunity is
|
||||||
* played: only with simple sounds enabled and only on the transition into
|
* played: only with simple sounds enabled and only on the transition into
|
||||||
@@ -1869,6 +1730,78 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
return simpleSoundsEnabled && !senderAlreadyInAngleAndRange;
|
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.
|
* 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.
|
- 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.
|
- 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.
|
- 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
|
## 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