Make MessageBusManagementThread testable, first slice (#105) (#123)

* test: restore the frequency-context and Band.fromPrefix tests (#105)

The test file MessageBusManagementThreadFrequencyContextTest sat under
src/main, fully commented out, and was deleted with #62 without ever
having run. Its two concerns come back as real tests: the bare
three-digit context rule of hasExplicitBareFrequencyContext in the
controller test package, and Band.fromPrefix as BandFromPrefixTest in
the model package. The original expectations still match the current
behaviour; a few boundary cases (context window, invalid indices,
non-exact prefixes) are added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: characterise the autoanswer path of MessageBusManagementThread (#105)

Runs real CH frames through a started thread (receive queue plus poison
pill, real transmit queue, mocked controller collaborators, a real
ChatPreferences in a temporary directory) and pins the current
behaviour of the private-message branch before it is refactored:

- a QRG question addressed to the login callsign yields exactly one
  reply with the fixed marker and the QRG of the incoming category;
- a second question from the same station and category within the
  cooldown yields none, while another station or category is answered;
- a frame carrying the autoanswer marker, a public frame, a frame to
  another station and a disabled setting yield none;
- an empty QRG and a blank generic text are rejected before the
  cooldown is recorded, so a later valid reply still goes out;
- //VER, the generic reply and its precedence below the QRG reply.

One oddity is pinned, not fixed: a lower-case login callsign defeats
the "directed to me" comparison because the receiver callsign is
upper-cased by ChatMember.setCallSign but compared with equals.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor: extract the autoanswer decisions into pure functions (#105)

The QRG-question texts become a constant set; matchesQrgQuestion,
isAutoMessage and buildAutoAnswerMessageText are static package-private
functions with explicit parameters instead of reaching into the
preferences, and the cooldown reads an injectable millisecond clock
(default System::currentTimeMillis) through a new constructor overload.
Behaviour is unchanged: the characterisation test from the previous
commit passes untouched, the new decision and cooldown tests cover the
extracted functions without mocks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor: extract frequency detection into a pure function (#105)

smartFrequencyExtraction now copies the sender's band timestamps, reads
the fallback prefix and the injectable clock, and hands the text to the
static package-private detectFrequencies, which returns the detections
in message order as DetectedChatFrequency records. The thread still
stores each one through applyDetectedFrequencyToActiveMembers and logs
it as before.

Behaviour is unchanged. In production the stored detection lands in
the same map the next match of the message reads, so detectFrequencies
feeds every detection back as context with the current time; a test
pins that "10368.100 then .150" yields 10368.150. The unreachable
NumberFormatException catches around parseExplicitFrequency are gone,
since that parser already returns null instead of throwing. Also pinned:
a plain decimal between 50 and 54 (e.g. "qrb 52,3 km") is read as a
6 m frequency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: extract the directional-opportunity and spot decisions (#105)

classifyDirectionalOpportunity returns MISSING_LOCATOR, IN_ANGLE_AND_RANGE
or OUT_OF_ANGLE_OR_RANGE from the locators, the maximum QRB and the beam
width, and replaces the identical locator check plus isInAngleAndRange
call in both the live (CH) and the history (CR) branch.
shouldPlayDirectionalTick, decideDxClusterSpot (NO_FREQUENCY,
ALREADY_WORKED, SPOT) and buildDxClusterSpotEntry take over the tick
condition and the automatic DX Cluster spot of the CH branch. All are
static package-private and covered without mocks.

Behaviour is unchanged. Pinned: the spot decision only checks that the
frequency text is non-blank, so a value outside every supported band is
spotted even for a station flagged as worked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: remove dead code from MessageBusManagementThread (#105)

processRXMessage23000 belonged to the long-retired raw port-23000
interface and had no caller. With it go the two helpers only it
reached, checkIfItsUserListEntry and checkIfMessageInhibitsFrequency,
plus the caller-less checkIfItsUserListEntry23001 and
checkListForChatMemberIndexByCallSign, the patterns PTRN_USERLISTENTRY,
PTRN_QRG_CAT2 and PTRN_QRG_CAT3, the unused chatMemberTable field and
the serverReady flag with its never-called accessors. A grep across core
and app-desktop finds no remaining reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: keep the sender-category fallback in the QRG auto-answer warning (#105)

Review follow-up. The extracted buildAutoAnswerMessageText logged only
the message category, while the old warning used autoAnswerCooldownKey,
which falls back to the sender's category. The caller now passes the
same fallback, so the log line is unchanged from main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-16 10:21:37 +02:00
1 parent 4b10d96091
commit e1e6fe7297
8 files changed
+1766 -440

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,125 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.nio.file.Path;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.io.TempDir;
import kst4contest.ApplicationConstants;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
import kst4contest.utils.PlayAudioUtils;
/**
* Drives the autoanswer cooldown of a running {@link MessageBusManagementThread}
* with an injected clock instead of waiting two minutes of wall-clock time.
*
* <p>The clock is advanced by a marker frame ({@code UE}) placed between the
* questions: the thread processes frames strictly in order, and the stubbed
* completion callback of the marker moves the clock before the next question is
* taken from the queue.</p>
*/
class MessageBusManagementThreadAutoAnswerCooldownTest {
@TempDir
Path tempDir;
@Test
@Timeout(10)
void questionIsAnsweredAgainOnceTheCooldownHasElapsed() throws InterruptedException {
AtomicLong clock = new AtomicLong(1_700_000_000_000L);
LinkedBlockingQueue<ChatMessage> txBus = new LinkedBlockingQueue<>();
LinkedBlockingQueue<ChatMessage> rxQueue = new LinkedBlockingQueue<>();
ChatController controller = controller(txBus);
// Each marker frame advances the clock by one step before the next question.
long[] steps = {
MessageBusManagementThread.AUTOANSWER_COOLDOWN_MS - 1, // still closed
1L, // now exactly elapsed
MessageBusManagementThread.AUTOANSWER_COOLDOWN_MS // elapsed again
};
AtomicLong markerIndex = new AtomicLong();
when(controller.getChatCategoryMain()).thenReturn(new ChatCategory(ChatCategory.VUHF));
org.mockito.Mockito.doAnswer(invocation -> {
clock.addAndGet(steps[(int) markerIndex.getAndIncrement()]);
return null;
}).when(controller).onOn4KstInitialUserListCompleted(
org.mockito.ArgumentMatchers.anyLong(), any());
MessageBusManagementThread thread = new MessageBusManagementThread(
controller, mock(ThreadStatusCallback.class), 9L, rxQueue,
ignored -> true, clock::get);
rxQueue.add(question()); // t0: answered
rxQueue.add(marker()); // +119_999 ms
rxQueue.add(question()); // t0 + 119_999: suppressed
rxQueue.add(marker()); // +1 ms
rxQueue.add(question()); // t0 + 120_000: answered
rxQueue.add(marker()); // +120_000 ms
rxQueue.add(question()); // t0 + 240_000: answered
rxQueue.add(poisonPill());
thread.start();
thread.join();
assertEquals(3, txBus.size(), "first, third and fourth question are answered");
}
private ChatController controller(LinkedBlockingQueue<ChatMessage> txBus) {
ChatPreferences prefs = new ChatPreferences(tempDir.resolve("preferences.xml"));
prefs.setStn_loginCallSign("DN9APW");
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true);
prefs.setMYQRGFirstCat("144.300");
ChatController controller = mock(ChatController.class);
when(controller.getChatPreferences()).thenReturn(prefs);
when(controller.getMessageTXBus()).thenReturn(txBus);
when(controller.getChatCategorySecondChat())
.thenReturn(new ChatCategory(ChatCategory.MICROWAVE));
when(controller.getPlayAudioUtils()).thenReturn(mock(PlayAudioUtils.class));
when(controller.getStationMetricsService())
.thenReturn(mock(StationMetricsService.class));
when(controller.getScoreService()).thenReturn(mock(ScoreService.class));
when(controller.findActiveChatMember(any(ChatMember.class)))
.thenAnswer(invocation -> {
ChatMember lookup = invocation.getArgument(0);
ChatMember member = new ChatMember();
member.setCallSign(lookup.getCallSign());
member.setChatCategory(lookup.getChatCategory());
member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
return member;
});
return controller;
}
private static ChatMessage question() {
return message("CH|2|1700000000|DL1ABC|Hans|0|ur qrg?|DN9APW|");
}
private static ChatMessage marker() {
return message("UE|2|22562|");
}
private static ChatMessage message(String text) {
ChatMessage message = new ChatMessage();
message.setMessageText(text);
return message;
}
private static ChatMessage poisonPill() {
ChatMessage message = message(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
message.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
return message;
}
}
@@ -0,0 +1,270 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
/**
* Unit tests for the pure autoanswer decisions extracted from the private-message
* branch of {@link MessageBusManagementThread}. No mocks, no thread, no preferences.
*/
class MessageBusManagementThreadAutoAnswerDecisionTest {
private static final String MARKER = ApplicationConstants.AUTOANSWER_PREFIX;
private static final ChatCategory VUHF = new ChatCategory(ChatCategory.VUHF);
private static final ChatCategory MICROWAVE = new ChatCategory(ChatCategory.MICROWAVE);
// --- matchesQrgQuestion -------------------------------------------------
@ParameterizedTest
@ValueSource(strings = {
"ur qrg?",
"UR QRG?",
"hi, your qrg? 73",
"qrg?",
"freq?",
"GM Hans, PSE QRG for a sked"
})
void recognisesEveryConfiguredQuestionCaseInsensitively(String text) {
assertTrue(MessageBusManagementThread.matchesQrgQuestion(
text, MessageBusManagementThread.QRG_QUESTION_TEXTS));
}
@ParameterizedTest
@ValueSource(strings = {
"my qrg is 144.300",
"qrg",
"what is your frequency",
"pse qrg",
""
})
void ignoresTextsWithoutAConfiguredQuestion(String text) {
assertFalse(MessageBusManagementThread.matchesQrgQuestion(
text, MessageBusManagementThread.QRG_QUESTION_TEXTS));
}
@Test
void matchesQrgQuestionHandlesNullInputs() {
assertFalse(MessageBusManagementThread.matchesQrgQuestion(
null, MessageBusManagementThread.QRG_QUESTION_TEXTS));
assertFalse(MessageBusManagementThread.matchesQrgQuestion("ur qrg?", null));
assertFalse(MessageBusManagementThread.matchesQrgQuestion("ur qrg?", Set.of()));
}
@Test
void questionTextsAreTheEstablishedFive() {
assertEquals(
Set.of("ur qrg?", "your qrg?", "qrg?", "freq?", "pse qrg"),
MessageBusManagementThread.QRG_QUESTION_TEXTS);
}
// --- isAutoMessage ------------------------------------------------------
@Test
void detectsTheMarkerAnywhereInTheText() {
assertTrue(MessageBusManagementThread.isAutoMessage(MARKER));
assertTrue(MessageBusManagementThread.isAutoMessage("/CQ DL1ABC " + MARKER + " QRG is: 144.300"));
assertTrue(MessageBusManagementThread.isAutoMessage("ur qrg? " + MARKER));
}
@Test
void plainTextsAreNotAutoMessages() {
assertFalse(MessageBusManagementThread.isAutoMessage(null));
assertFalse(MessageBusManagementThread.isAutoMessage(""));
assertFalse(MessageBusManagementThread.isAutoMessage("ur qrg?"));
// The marker is matched literally, not case-insensitively.
assertFalse(MessageBusManagementThread.isAutoMessage(MARKER.toLowerCase()));
}
// --- selectAutoAnswerQrg ------------------------------------------------
@Test
void secondCategoryGetsTheSecondQrg() {
assertEquals("1296.200", MessageBusManagementThread.selectAutoAnswerQrg(
MICROWAVE, MICROWAVE, "144.300", "1296.200"));
// Categories are compared by number, not by identity.
assertEquals("1296.200", MessageBusManagementThread.selectAutoAnswerQrg(
new ChatCategory(ChatCategory.MICROWAVE), MICROWAVE, "144.300", "1296.200"));
}
@Test
void everyOtherCategoryFallsBackToTheFirstQrg() {
assertEquals("144.300", MessageBusManagementThread.selectAutoAnswerQrg(
VUHF, MICROWAVE, "144.300", "1296.200"));
assertEquals("144.300", MessageBusManagementThread.selectAutoAnswerQrg(
null, MICROWAVE, "144.300", "1296.200"));
assertEquals("144.300", MessageBusManagementThread.selectAutoAnswerQrg(
MICROWAVE, null, "144.300", "1296.200"));
assertNull(MessageBusManagementThread.selectAutoAnswerQrg(
VUHF, MICROWAVE, null, "1296.200"));
}
// --- buildAutoAnswerMessageText ----------------------------------------
@Test
void buildsTheQrgReply() {
assertEquals(
"/CQ DL1ABC " + MARKER + " QRG is: 144.300",
MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, true, " 144.300 ", false, null));
}
@Test
void qrgRequestWinsOverTheGenericReply() {
assertEquals(
"/CQ DL1ABC " + MARKER + " QRG is: 144.300",
MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, true, "144.300", true, "not qrv"));
}
@Test
void qrgRequestWithoutQrgYieldsNoReply() {
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, true, null, true, "not qrv"));
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, true, " ", true, "not qrv"));
// The category is used for diagnostics only; a null category must not fail.
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", null, true, "", false, null));
}
@Test
void buildsTheGenericReplyTrimmed() {
assertEquals(
"/CQ DL1ABC " + MARKER + " not qrv, back at 18z",
MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, false, "144.300", true, " not qrv, back at 18z "));
}
@Test
void genericReplyNeedsTextAndTheSwitch() {
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, false, "144.300", true, null));
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, false, "144.300", true, " "));
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, false, "144.300", false, "not qrv"));
}
@Test
void missingSenderCallSignYieldsNoReply() {
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
null, VUHF, true, "144.300", true, "not qrv"));
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
" ", VUHF, true, "144.300", true, "not qrv"));
}
@Test
void senderCallSignIsTrimmedButNotUpperCased() {
assertEquals(
"/CQ dl1abc " + MARKER + " QRG is: 144.300",
MessageBusManagementThread.buildAutoAnswerMessageText(
" dl1abc ", VUHF, true, "144.300", false, null));
}
@Test
void replyContainingTheFieldSeparatorIsRejected() {
// On4KstProtocol.messageText refuses '|'; the reply is dropped, not repaired.
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, true, "144.300|432.200", false, null));
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, false, null, true, "not qrv | back later"));
}
@Test
void replyContainingALineBreakIsRejected() {
assertNull(MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, false, null, true, "not qrv\nback later"));
}
// --- autoAnswerCooldownKey ---------------------------------------------
@Test
void cooldownKeyIsUpperCaseCallAndMessageCategory() {
ChatMessage incoming = incoming("dl1abc", VUHF, MICROWAVE);
assertEquals("DL1ABC|2", MessageBusManagementThread.autoAnswerCooldownKey(incoming));
}
@Test
void cooldownKeyFallsBackToTheSenderCategory() {
ChatMessage incoming = incoming("DL1ABC", null, MICROWAVE);
assertEquals("DL1ABC|3", MessageBusManagementThread.autoAnswerCooldownKey(incoming));
}
@Test
void cooldownKeyKeepsSuffixVariantsApart() {
// Cooldown identity must not collapse DL1ABC and DL1ABC-2 (AGENTS.md).
assertEquals("DL1ABC-2|2", MessageBusManagementThread.autoAnswerCooldownKey(
incoming("DL1ABC-2", VUHF, null)));
assertEquals("DL1ABC/P|2", MessageBusManagementThread.autoAnswerCooldownKey(
incoming("DL1ABC/P", VUHF, null)));
}
@Test
void cooldownKeyHasSafeDefaults() {
assertEquals("UNKNOWN|0", MessageBusManagementThread.autoAnswerCooldownKey(null));
assertEquals("UNKNOWN|0", MessageBusManagementThread.autoAnswerCooldownKey(new ChatMessage()));
ChatMessage withoutCall = new ChatMessage();
withoutCall.setSender(new ChatMember());
withoutCall.setChatCategory(VUHF);
assertEquals("UNKNOWN|2", MessageBusManagementThread.autoAnswerCooldownKey(withoutCall));
}
// --- isCooldownElapsed -------------------------------------------------
@Test
void cooldownIsElapsedWithoutAnEarlierReply() {
assertTrue(MessageBusManagementThread.isCooldownElapsed(null, 0L));
}
@Test
void cooldownBoundaryIsInclusive() {
long last = 1_000_000L;
long cooldown = MessageBusManagementThread.AUTOANSWER_COOLDOWN_MS;
assertEquals(120_000L, cooldown);
assertFalse(MessageBusManagementThread.isCooldownElapsed(last, last));
assertFalse(MessageBusManagementThread.isCooldownElapsed(last, last + cooldown - 1));
assertTrue(MessageBusManagementThread.isCooldownElapsed(last, last + cooldown));
assertTrue(MessageBusManagementThread.isCooldownElapsed(last, last + cooldown + 1));
}
/**
* ODDITY (pinned, not fixed): a clock that runs backwards, for example after an
* NTP correction, does not re-open the cooldown; the difference is simply
* negative and therefore below the threshold until the wall clock catches up.
*/
@Test
void clockRunningBackwardsKeepsTheCooldownClosed() {
assertFalse(MessageBusManagementThread.isCooldownElapsed(1_000_000L, 500_000L));
}
// -----------------------------------------------------------------------
private static ChatMessage incoming(
String senderCall, ChatCategory messageCategory, ChatCategory senderCategory) {
ChatMember sender = new ChatMember();
sender.setCallSign(senderCall);
sender.setChatCategory(senderCategory);
ChatMessage incoming = new ChatMessage();
incoming.setSender(sender);
incoming.setChatCategory(messageCategory);
return incoming;
}
}
@@ -0,0 +1,384 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import kst4contest.ApplicationConstants;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
import kst4contest.utils.PlayAudioUtils;
/**
* Characterises the automatic reply to private ON4KST chat messages by running
* real {@code CH} frames through a started {@link MessageBusManagementThread}.
*
* <p>The thread reads from its receive queue until it takes the poison pill, so
* every test enqueues its frames followed by the pill and joins the thread. The
* transmit bus is a real queue: an automatic reply is observable as exactly one
* element on it.</p>
*
* <p>Frame layout, as split on {@code |} by the thread:
* {@code CH|category|epoch|senderCall|senderName|0|text|receiverCall|}; a
* receiver of {@code 0} marks a public message.</p>
*/
class MessageBusManagementThreadAutoAnswerTest {
private static final String MY_CALL = "DN9APW";
private static final String REMOTE_CALL = "DL1ABC";
private static final String FIRST_QRG = "144.300";
private static final String SECOND_QRG = "1296.200";
@TempDir
Path tempDir;
private ChatController controller;
private ChatPreferences prefs;
private LinkedBlockingQueue<ChatMessage> txBus;
private LinkedBlockingQueue<ChatMessage> rxQueue;
private ChatCategory mainCategory;
private ChatCategory secondCategory;
@BeforeEach
void setUp() {
prefs = new ChatPreferences(tempDir.resolve("preferences.xml"));
prefs.setStn_loginCallSign(MY_CALL);
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true);
prefs.setMessageHandling_autoAnswerEnabled(false);
prefs.setMYQRGFirstCat(FIRST_QRG);
prefs.setMYQRGSecondCat(SECOND_QRG);
mainCategory = new ChatCategory(ChatCategory.VUHF);
secondCategory = new ChatCategory(ChatCategory.MICROWAVE);
txBus = new LinkedBlockingQueue<>();
rxQueue = new LinkedBlockingQueue<>();
controller = mock(ChatController.class);
when(controller.getChatPreferences()).thenReturn(prefs);
when(controller.getMessageTXBus()).thenReturn(txBus);
when(controller.getChatCategoryMain()).thenReturn(mainCategory);
when(controller.getChatCategorySecondChat()).thenReturn(secondCategory);
when(controller.getPlayAudioUtils()).thenReturn(mock(PlayAudioUtils.class));
when(controller.getStationMetricsService())
.thenReturn(mock(StationMetricsService.class));
when(controller.getScoreService()).thenReturn(mock(ScoreService.class));
when(controller.getDxClusterServer())
.thenReturn(mock(DXClusterThreadPooledServer.class));
when(controller.findActiveChatMember(any(ChatMember.class)))
.thenAnswer(invocation -> activeMemberFor(invocation.getArgument(0)));
}
@Test
@Timeout(10)
void answersQrgQuestionAddressedToMeExactlyOnce() throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
List<ChatMessage> replies = new ArrayList<>(txBus);
assertEquals(1, replies.size(), "exactly one automatic reply expected");
ChatMessage reply = replies.get(0);
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " QRG is: " + FIRST_QRG,
reply.getMessageText());
assertEquals(MY_CALL, reply.getSender().getCallSign());
assertEquals(REMOTE_CALL, reply.getReceiver().getCallSign());
assertSame(mainCategory, reply.getChatCategory());
// The incoming private message itself is still published to the UI.
ArgumentCaptor<ChatMessage> published = ArgumentCaptor.forClass(ChatMessage.class);
verify(controller, times(1)).publishChatMessage(published.capture());
assertEquals("ur qrg?", published.getValue().getMessageText());
assertEquals(REMOTE_CALL, published.getValue().getSender().getCallSign());
}
@Test
@Timeout(10)
void matchesQuestionTextCaseInsensitivelyInsideLongerMessage()
throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "GM Hans, PSE QRG for a sked", MY_CALL));
assertEquals(1, txBus.size());
}
@Test
@Timeout(10)
void secondQuestionFromSameStationWithinCooldownIsNotAnswered()
throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL),
chatFrame(2, REMOTE_CALL, "qrg?", MY_CALL));
assertEquals(1, txBus.size(), "cooldown must suppress the second reply");
verify(controller, times(2)).publishChatMessage(any(ChatMessage.class));
}
@Test
@Timeout(10)
void cooldownIsKeyedByStationAndCategory() throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL),
chatFrame(2, "DL2XYZ", "ur qrg?", MY_CALL),
chatFrame(3, REMOTE_CALL, "ur qrg?", MY_CALL));
List<ChatMessage> replies = new ArrayList<>(txBus);
assertEquals(3, replies.size(),
"another station and another category each get their own reply");
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " QRG is: " + SECOND_QRG,
replies.get(2).getMessageText(),
"the second-category question is answered with the second QRG");
assertSame(secondCategory, replies.get(2).getChatCategory());
}
@Test
@Timeout(10)
void questionCarryingTheAutoAnswerMarkerIsNeverAnswered()
throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL,
ApplicationConstants.AUTOANSWER_PREFIX + " QRG is: 432.200 ur qrg?",
MY_CALL));
assertTrue(txBus.isEmpty(), "automatic messages must not be answered");
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
@Test
@Timeout(10)
void publicQuestionIsNotAnswered() throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", "0"));
assertTrue(txBus.isEmpty());
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
@Test
@Timeout(10)
void questionToAnotherStationIsNotAnswered() throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", "DL2XYZ"));
assertTrue(txBus.isEmpty());
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
@Test
@Timeout(10)
void disabledQrgAutoAnswerStaysSilent() throws InterruptedException {
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(false);
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
assertTrue(txBus.isEmpty());
}
@Test
@Timeout(10)
void emptyQrgIsNotAnsweredAndDoesNotConsumeTheCooldown()
throws InterruptedException {
prefs.setMYQRGFirstCat("");
MessageBusManagementThread thread = newThread();
CountDownLatch markerProcessed = new CountDownLatch(1);
doAnswer(invocation -> {
markerProcessed.countDown();
return null;
}).when(controller).onOn4KstInitialUserListCompleted(anyLong(), any());
thread.start();
rxQueue.add(chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
// The thread handles frames strictly in order: once the marker frame has
// been processed, the question above has been decided without a QRG.
rxQueue.add(message("UE|2|22562|"));
assertTrue(markerProcessed.await(5, TimeUnit.SECONDS));
assertTrue(txBus.isEmpty(), "no reply without a QRG");
prefs.setMYQRGFirstCat(FIRST_QRG);
rxQueue.add(chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
rxQueue.add(poisonPill());
thread.join();
assertEquals(1, txBus.size(),
"the rejected reply must not have started the cooldown");
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " QRG is: " + FIRST_QRG,
txBus.peek().getMessageText());
}
@Test
@Timeout(10)
void genericAutoAnswerRepliesToOtherPrivateMessages() throws InterruptedException {
prefs.setMessageHandling_autoAnswerEnabled(true);
prefs.setMessageHandling_autoAnswerTextMainCat(" not qrv, back at 18z ");
runThrough(
chatFrame(2, REMOTE_CALL, "hi, sked on 144?", MY_CALL));
assertEquals(1, txBus.size());
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " not qrv, back at 18z",
txBus.peek().getMessageText(),
"the configured text is trimmed");
}
@Test
@Timeout(10)
void qrgQuestionWinsOverGenericAnswer() throws InterruptedException {
prefs.setMessageHandling_autoAnswerEnabled(true);
prefs.setMessageHandling_autoAnswerTextMainCat("not qrv");
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
assertEquals(1, txBus.size());
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " QRG is: " + FIRST_QRG,
txBus.peek().getMessageText());
}
@Test
@Timeout(10)
void genericAnswerWithBlankTextIsRejectedWithoutConsumingCooldown()
throws InterruptedException {
prefs.setMessageHandling_autoAnswerEnabled(true);
prefs.setMessageHandling_autoAnswerTextMainCat(" ");
runThrough(
chatFrame(2, REMOTE_CALL, "hi", MY_CALL),
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
assertEquals(1, txBus.size(),
"the blank generic reply is dropped; the QRG reply still goes out");
assertTrue(txBus.peek().getMessageText().endsWith("QRG is: " + FIRST_QRG));
}
@Test
@Timeout(10)
void versionRequestIsAnsweredOutsideTheCooldown() throws InterruptedException {
runThrough(
chatFrame(2, REMOTE_CALL, "//ver", MY_CALL));
assertEquals(1, txBus.size());
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION
+ " by DO5AMF",
txBus.peek().getMessageText());
}
/**
* ODDITY (pinned, not fixed): the receiver callsign of a CH frame is upper-cased
* by {@link ChatMember#setCallSign(String)}, but it is compared with
* {@code equals} against the login callsign exactly as configured. With a
* lower-case login callsign a private message is therefore not recognised as
* addressed to the local station, and no automatic reply is sent. The
* "sent by me" branch directly below compares both sides upper-cased.
*/
@Test
@Timeout(10)
void lowerCaseLoginCallSignDefeatsTheToMeDetection() throws InterruptedException {
prefs.setStn_loginCallSign(MY_CALL.toLowerCase());
runThrough(
chatFrame(2, REMOTE_CALL, "ur qrg?", MY_CALL));
assertTrue(txBus.isEmpty());
verify(controller, never()).getPlayAudioUtils();
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
@Test
@Timeout(10)
void autoMarkerInMyOwnEchoIsNotAnswered() throws InterruptedException {
// The server echoes our own directed message; the sender equals the login.
runThrough(
chatFrame(2, MY_CALL, "ur qrg?", REMOTE_CALL));
assertTrue(txBus.isEmpty());
ArgumentCaptor<ChatMessage> published = ArgumentCaptor.forClass(ChatMessage.class);
verify(controller, times(1)).publishChatMessage(published.capture());
assertEquals("(>" + REMOTE_CALL + ")ur qrg?", published.getValue().getMessageText());
}
// ----------------------------------------------------------------------
private void runThrough(ChatMessage... frames) throws InterruptedException {
MessageBusManagementThread thread = newThread();
for (ChatMessage frame : frames) {
rxQueue.add(frame);
}
rxQueue.add(poisonPill());
thread.start();
thread.join();
}
private MessageBusManagementThread newThread() {
return new MessageBusManagementThread(
controller,
mock(ThreadStatusCallback.class),
7L,
rxQueue,
ignored -> true);
}
private static ChatMessage chatFrame(
int category, String senderCall, String text, String receiverCall) {
return message("CH|" + category + "|1700000000|" + senderCall + "|Hans|0|"
+ text + "|" + receiverCall + "|");
}
private static ChatMessage message(String text) {
ChatMessage message = new ChatMessage();
message.setMessageText(text);
return message;
}
private static ChatMessage poisonPill() {
ChatMessage message = message(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
message.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
return message;
}
/** Mimics the active-member lookup: every remote station is known with a locator. */
private static ChatMember activeMemberFor(ChatMember lookup) {
ChatMember member = new ChatMember();
member.setCallSign(lookup.getCallSign());
member.setChatCategory(lookup.getChatCategory());
member.setQra("JO31AA");
member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
return member;
}
}
@@ -0,0 +1,223 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import kst4contest.controller.MessageBusManagementThread.DetectedChatFrequency;
import kst4contest.model.Band;
/**
* Unit tests for the pure frequency detection extracted from
* {@link MessageBusManagementThread#smartFrequencyExtraction}. No mocks, no thread,
* no preferences; the clock is an explicit parameter.
*/
class MessageBusManagementThreadDetectFrequenciesTest {
private static final long NOW = 1_000_000_000_000L;
private static final long THIRTY_MINUTES = 1_800_000L;
private static final double EPSILON = 1e-9;
private static List<DetectedChatFrequency> detect(String text) {
return MessageBusManagementThread.detectFrequencies(text, Map.of(), null, NOW);
}
private static List<DetectedChatFrequency> detect(
String text, Map<Band, Long> context, String fallbackPrefix) {
return MessageBusManagementThread.detectFrequencies(text, context, fallbackPrefix, NOW);
}
private static void assertSingle(
List<DetectedChatFrequency> result, Band band, double frequencyMHz) {
assertEquals(1, result.size(), () -> "detections: " + result);
assertEquals(band, result.get(0).band());
assertEquals(frequencyMHz, result.get(0).frequencyMHz(), EPSILON);
}
// --- complete frequencies -----------------------------------------------
@Test
void detectsDottedFullFrequency() {
List<DetectedChatFrequency> result = detect("pse qsy 144.300");
assertSingle(result, Band.B_144, 144.300);
assertFalse(result.get(0).derivedFromShortForm());
assertEquals("144.300", result.get(0).rawMatch());
}
@Test
void detectsCommaAsDecimalSeparator() {
List<DetectedChatFrequency> result = detect("qrg 432,210");
assertSingle(result, Band.B_432, 432.210);
assertEquals("432.210", result.get(0).rawMatch());
}
@Test
void detectsCompactDigitOnlyFrequency() {
assertSingle(detect("pse try 10368100"), Band.B_10G, 10368.100);
}
@Test
void detectsKstStyleSecondDecimalSeparator() {
assertSingle(detect("cq on 144.300.03"), Band.B_144, 144.30003);
}
@Test
void fullFrequencyIgnoresBandContextAndFallback() {
Map<Band, Long> context = Map.of(Band.B_432, NOW);
assertSingle(detect("144.300", context, "1296"), Band.B_144, 144.300);
}
@Test
void ignoresFullFrequencyOutsideEverySupportedBand() {
assertTrue(detect("try 146.500").isEmpty());
assertTrue(detect("cq 28.500").isEmpty());
assertSingle(detect("146.500 or 433.900"), Band.B_432, 433.900);
}
@Test
void reportsAllFrequenciesInMessageOrder() {
List<DetectedChatFrequency> result = detect("144.300 or 432.200");
assertEquals(2, result.size());
assertEquals(Band.B_144, result.get(0).band());
assertEquals(Band.B_432, result.get(1).band());
}
/*
* Pinned oddity: any decimal number between 50.0 and 54.0 (or 70.0 and 70.5)
* is read as a frequency, so a distance such as "52,3 km" becomes 52.3 MHz.
*/
@Test
void pinsThatAPlainDecimalInsideTheSixMetreRangeCountsAsFrequency() {
assertSingle(detect("qrb 52,3 km"), Band.B_50, 52.3);
}
// --- relative and bare values -------------------------------------------
@Test
void relativeValueWithoutContextUsesTheConfiguredFallback() {
List<DetectedChatFrequency> result = detect("pse .210", Map.of(), "432");
assertSingle(result, Band.B_432, 432.210);
assertTrue(result.get(0).derivedFromShortForm());
assertEquals("210", result.get(0).rawMatch());
}
@ParameterizedTest
@ValueSource(strings = {"", "28", "abc"})
void unknownFallbackPrefixFallsBackTo144(String prefix) {
assertSingle(detect("pse .210", Map.of(), prefix), Band.B_144, 144.210);
}
@Test
void missingFallbackPrefixFallsBackTo144() {
assertSingle(detect("pse .210", null, null), Band.B_144, 144.210);
}
@Test
void relativeValueWithCommaIsAccepted() {
assertSingle(detect("pse ,210"), Band.B_144, 144.210);
}
@Test
void bareValueNeedsFrequencyContext() {
assertTrue(detect("ur 599 tnx").isEmpty());
assertTrue(detect("144 tonight").isEmpty());
assertSingle(detect("qrg 210"), Band.B_144, 144.210);
assertSingle(detect("210 MHz"), Band.B_144, 144.210);
}
@Test
void relativeValueUsesFreshSenderBandBeforeFallback() {
Map<Band, Long> context = Map.of(Band.B_1296, NOW - 60_000L);
assertSingle(detect("pse .210", context, "432"), Band.B_1296, 1296.210);
}
@Test
void newestFreshBandWins() {
Map<Band, Long> context = new HashMap<>();
context.put(Band.B_144, NOW - 120_000L);
context.put(Band.B_432, NOW - 60_000L);
context.put(Band.B_1296, NOW - 180_000L);
assertSingle(detect("pse .210", context, null), Band.B_432, 432.210);
}
@Test
void contextExactlyThirtyMinutesOldIsStillUsed() {
Map<Band, Long> context = Map.of(Band.B_432, NOW - THIRTY_MINUTES);
assertSingle(detect("pse .210", context, "144"), Band.B_432, 432.210);
}
@Test
void contextOlderThanThirtyMinutesIsIgnored() {
Map<Band, Long> context = Map.of(Band.B_432, NOW - THIRTY_MINUTES - 1L);
assertSingle(detect("pse .210", context, "144"), Band.B_144, 144.210);
}
@Test
void contextBandIsSkippedWhenTheCompletedValueLeavesTheBand() {
// 70.900 is outside 70.000-70.500, so the older 432 context is used.
Map<Band, Long> context = new EnumMap<>(Band.class);
context.put(Band.B_70, NOW - 1_000L);
context.put(Band.B_432, NOW - 5_000L);
assertSingle(detect("pse .900", context, "144"), Band.B_432, 432.900);
}
@Test
void relativeValueOutsideTheFallbackBandIsDropped() {
// 70.900 lies outside the 70 MHz band and nothing else is tried.
assertTrue(detect("pse .900", Map.of(), "70").isEmpty());
}
@Test
void earlierDetectionInTheSameMessageBecomesContextForALaterRelativeValue() {
Map<Band, Long> context = Map.of(Band.B_432, NOW - 60_000L);
List<DetectedChatFrequency> result =
detect("10368.100 then .150", context, "144");
assertEquals(2, result.size());
assertEquals(Band.B_10G, result.get(1).band());
assertEquals(10368.150, result.get(1).frequencyMHz(), EPSILON);
}
@Test
void doesNotModifyTheGivenContext() {
Map<Band, Long> context = new EnumMap<>(Band.class);
context.put(Band.B_432, NOW - 60_000L);
detect("10368.100 then .150", context, "144");
assertEquals(Map.of(Band.B_432, NOW - 60_000L), context);
}
// --- edge cases ---------------------------------------------------------
@Test
void nullOrTextWithoutNumbersYieldsNothing() {
assertTrue(MessageBusManagementThread.detectFrequencies(null, null, null, NOW).isEmpty());
assertTrue(detect("gm all, tnx for qso").isEmpty());
}
@Test
void callsignDigitsAndLocatorsAreNotFrequencies() {
assertTrue(detect("DL144ABC in JO62QM").isEmpty());
}
}
@@ -0,0 +1,162 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import kst4contest.controller.MessageBusManagementThread.DirectionalOpportunity;
import kst4contest.controller.MessageBusManagementThread.DxClusterSpotDecision;
import kst4contest.model.ChatMember;
import kst4contest.observe.SimpleValue;
/**
* Unit tests for the directional-opportunity and DX Cluster spot decisions
* extracted from the message branch for traffic between two other stations.
* No mocks, no thread, no preferences.
*/
class MessageBusManagementThreadDirectionalSpotDecisionTest {
// Local station between a western sender and an eastern receiver, all on one latitude.
private static final String OWN = "JO50LL";
private static final String WEST = "JO30LL";
private static final String EAST = "JO70LL";
private static final String FAR_WEST = "IO70LL";
private static final double MAX_QRB_KM = 900.0;
private static final double BEAM_WIDTH_DEG = 50.0;
// --- classifyDirectionalOpportunity -------------------------------------
@Test
void senderBeamingAcrossTheLocalStationIsAnOpportunity() {
assertEquals(DirectionalOpportunity.IN_ANGLE_AND_RANGE,
MessageBusManagementThread.classifyDirectionalOpportunity(
OWN, WEST, EAST, MAX_QRB_KM, BEAM_WIDTH_DEG));
}
@Test
void senderBeamingAwayFromTheLocalStationIsNoOpportunity() {
assertEquals(DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE,
MessageBusManagementThread.classifyDirectionalOpportunity(
OWN, WEST, FAR_WEST, MAX_QRB_KM, BEAM_WIDTH_DEG));
}
@Test
void senderBeyondTheMaximumDistanceIsNoOpportunity() {
assertEquals(DirectionalOpportunity.OUT_OF_ANGLE_OR_RANGE,
MessageBusManagementThread.classifyDirectionalOpportunity(
OWN, WEST, EAST, 50.0, BEAM_WIDTH_DEG));
}
@ParameterizedTest
@CsvSource(value = {
"NULL, JO70LL",
"'', JO70LL",
"' ', JO70LL",
"JO30LL, NULL",
"JO30LL, ''",
"NULL, NULL"
}, nullValues = "NULL")
void missingOrBlankLocatorSkipsTheGeometry(String senderLocator, String receiverLocator) {
// The own locator is invalid on purpose: it must not be evaluated at all.
assertEquals(DirectionalOpportunity.MISSING_LOCATOR,
MessageBusManagementThread.classifyDirectionalOpportunity(
null, senderLocator, receiverLocator, MAX_QRB_KM, BEAM_WIDTH_DEG));
}
// --- shouldPlayDirectionalTick ------------------------------------------
@Test
void tickOnlyOnTheTransitionIntoAnOpportunityAndOnlyWithSounds() {
assertTrue(MessageBusManagementThread.shouldPlayDirectionalTick(true, false));
assertFalse(MessageBusManagementThread.shouldPlayDirectionalTick(true, true));
assertFalse(MessageBusManagementThread.shouldPlayDirectionalTick(false, false));
assertFalse(MessageBusManagementThread.shouldPlayDirectionalTick(false, true));
}
// --- decideDxClusterSpot ------------------------------------------------
@Test
void knownFrequencyAndNotWorkedIsSpotted() {
assertEquals(DxClusterSpotDecision.SPOT,
MessageBusManagementThread.decideDxClusterSpot(senderOn("144.300")));
}
@Test
void workedOnTheBandOfTheFrequencyIsSuppressed() {
ChatMember sender = senderOn("144.300");
sender.setWorked144(true);
assertEquals(DxClusterSpotDecision.ALREADY_WORKED,
MessageBusManagementThread.decideDxClusterSpot(sender));
}
@Test
void workedOnlyOnAnotherBandIsSpotted() {
ChatMember sender = senderOn("432.200");
sender.setWorked144(true);
assertEquals(DxClusterSpotDecision.SPOT,
MessageBusManagementThread.decideDxClusterSpot(sender));
}
@Test
void missingFrequencyIsNeitherSpottedNorReportedAsWorked() {
ChatMember noWrapper = new ChatMember();
noWrapper.setCallSign("DL1ABC");
noWrapper.setFrequency(null);
noWrapper.setWorked(true);
ChatMember noValue = new ChatMember();
noValue.setCallSign("DL1ABC");
noValue.setFrequency(new SimpleValue<>());
noValue.setWorked(true);
assertEquals(DxClusterSpotDecision.NO_FREQUENCY,
MessageBusManagementThread.decideDxClusterSpot(noWrapper));
assertEquals(DxClusterSpotDecision.NO_FREQUENCY,
MessageBusManagementThread.decideDxClusterSpot(noValue));
assertEquals(DxClusterSpotDecision.NO_FREQUENCY,
MessageBusManagementThread.decideDxClusterSpot(senderOn(" ")));
assertEquals(DxClusterSpotDecision.NO_FREQUENCY,
MessageBusManagementThread.decideDxClusterSpot(null));
}
/*
* Pinned oddity: the frequency text is only checked for being non-blank. A value
* that names no supported band is spotted, even for a station flagged as worked.
*/
@Test
void pinsThatAnUnparseableFrequencyIsStillSpotted() {
ChatMember sender = senderOn("999.9");
sender.setWorked144(true);
assertEquals(DxClusterSpotDecision.SPOT,
MessageBusManagementThread.decideDxClusterSpot(sender));
}
// --- buildDxClusterSpotEntry --------------------------------------------
@Test
void spotEntryCarriesCallsignFrequencyAndCommentInTheLocatorField() {
ChatMember sender = senderOn("144.300");
sender.setQra("jo51hk");
ChatMember entry = MessageBusManagementThread.buildDxClusterSpotEntry(sender);
assertEquals("DL1ABC", entry.getCallSign());
assertSame(sender.getFrequency(), entry.getFrequency());
assertEquals("JO51HK", entry.getQra());
}
private static ChatMember senderOn(String frequencyMHz) {
ChatMember sender = new ChatMember();
sender.setCallSign("DL1ABC");
sender.setFrequency(new SimpleValue<>(frequencyMHz));
return sender;
}
}
@@ -0,0 +1,104 @@
package kst4contest.controller;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Covers the context rule that decides whether a bare three-digit value such as
* {@code 210} is read as a frequency. Restored from the test file that sat under
* {@code src/main} before #62 and was therefore never executed.
*/
class MessageBusManagementThreadFrequencyContextTest {
private static final Pattern THREE_DIGIT_VALUE =
Pattern.compile("\\b\\d{3}\\b");
@ParameterizedTest
@ValueSource(strings = {
"qrg 210",
"QRG: 210",
"freq is 210",
"frequency = 210",
"on 210",
"pse 210",
"at 210",
"210 MHz",
"210 qrg",
"210 freq"
})
void acceptsBareThreeDigitValueWithFrequencyContext(String messageText) {
Matcher matcher = findThreeDigitValue(messageText);
assertTrue(
MessageBusManagementThread.hasExplicitBareFrequencyContext(
messageText,
matcher.start(),
matcher.end()
)
);
}
@ParameterizedTest
@ValueSource(strings = {
"599",
"144",
"serial 210",
"score 210",
"worked 210 stations",
"210 stations worked"
})
void rejectsBareThreeDigitValueWithoutFrequencyContext(String messageText) {
Matcher matcher = findThreeDigitValue(messageText);
assertFalse(
MessageBusManagementThread.hasExplicitBareFrequencyContext(
messageText,
matcher.start(),
matcher.end()
)
);
}
@Test
void contextWindowIsLimitedToNearbyText() {
// "qrg" is present but more than 32 characters before the value.
String messageText =
"qrg will follow later in this long sentence about nothing 210";
Matcher matcher = findThreeDigitValue(messageText);
assertFalse(
MessageBusManagementThread.hasExplicitBareFrequencyContext(
messageText,
matcher.start(),
matcher.end()
)
);
}
@Test
void rejectsInvalidIndices() {
assertFalse(MessageBusManagementThread
.hasExplicitBareFrequencyContext(null, 0, 3));
assertFalse(MessageBusManagementThread
.hasExplicitBareFrequencyContext("qrg 210", -1, 3));
assertFalse(MessageBusManagementThread
.hasExplicitBareFrequencyContext("qrg 210", 5, 4));
assertFalse(MessageBusManagementThread
.hasExplicitBareFrequencyContext("qrg 210", 4, 8));
}
private Matcher findThreeDigitValue(String messageText) {
Matcher matcher = THREE_DIGIT_VALUE.matcher(messageText);
assertTrue(matcher.find(), "Test message must contain a three-digit value");
assertNotNull(matcher.group());
return matcher;
}
}
@@ -0,0 +1,40 @@
package kst4contest.model;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Covers the fallback-prefix lookup shared by the smart frequency parser and the
* preferences UI. Restored from the frequency-context test file that sat under
* {@code src/main} before #62 and was therefore never executed.
*/
class BandFromPrefixTest {
@Test
void resolvesOnlySupportedFallbackPrefixes() {
assertEquals(Band.B_144, Band.fromPrefix("144"));
assertEquals(Band.B_432, Band.fromPrefix(" 432 "));
assertEquals(Band.B_10G, Band.fromPrefix("10368"));
assertNull(Band.fromPrefix("999"));
assertNull(Band.fromPrefix(null));
}
@Test
void rejectsPrefixesThatAreNotSpelledExactly() {
// The lookup is a trimmed string comparison, not a numeric one.
assertNull(Band.fromPrefix(""));
assertNull(Band.fromPrefix("144.0"));
assertNull(Band.fromPrefix("0144"));
assertNull(Band.fromPrefix("10G"));
}
@ParameterizedTest
@EnumSource(Band.class)
void everyBandResolvesFromItsOwnPrefix(Band band) {
assertEquals(band, Band.fromPrefix(band.getPrefix()));
}
}