Harden beacon sessions and mark automatic messages

This commit is contained in:
Marc Froehlich committed 2026-11-14 19:18:25 +01:00
1 parent 19bcdb428d
commit f13fe54fde
63 files changed
+1905 -252

No files matched your search

@@ -0,0 +1,15 @@
package kst4contest.controller;
/** Adds the fixed marker used by every user-visible automatic chat message. */
final class AutomaticChatMessageFormatter {
/** Exact marker appended to every user-visible automatic chat message. */
/* default */ static final String SUFFIX = " *a*";
private AutomaticChatMessageFormatter() {
}
/* default */ static String appendSuffix(final String messageText) {
return messageText + SUFFIX;
}
}
@@ -0,0 +1,66 @@
package kst4contest.controller;
import java.util.OptionalLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import kst4contest.model.Band;
/** Parses complete beacon-reference frequencies without floating-point comparisons. */
final class BeaconFrequencyParser {
private static final Pattern GROUPED = Pattern.compile(
"^(\\d{2,5})[.,](\\d{1,3})(?:[.,](\\d{1,3}))?$"
);
private static final Pattern COMPACT = Pattern.compile("^\\d{5,8}$");
private BeaconFrequencyParser() {
}
static OptionalLong parseHz(String value) {
if (value == null) {
return OptionalLong.empty();
}
String normalized = value.trim();
if (normalized.isEmpty()) {
return OptionalLong.empty();
}
try {
long frequencyHz;
Matcher grouped = GROUPED.matcher(normalized);
if (grouped.matches()) {
long megahertz = Long.parseLong(grouped.group(1));
long kilohertz = rightPadToThree(grouped.group(2));
long hertz = grouped.group(3) == null
? 0L
: rightPadToThree(grouped.group(3));
frequencyHz = Math.addExact(
Math.multiplyExact(megahertz, 1_000_000L),
Math.addExact(Math.multiplyExact(kilohertz, 1_000L), hertz)
);
} else if (COMPACT.matcher(normalized).matches()) {
long compact = Long.parseLong(normalized);
frequencyHz = Math.multiplyExact(compact, 1_000L);
} else {
return OptionalLong.empty();
}
if (frequencyHz <= 0L
|| Band.fromFrequency(frequencyHz / 1_000_000.0) == null) {
return OptionalLong.empty();
}
return OptionalLong.of(frequencyHz);
} catch (ArithmeticException | NumberFormatException ignored) {
return OptionalLong.empty();
}
}
private static long rightPadToThree(String digits) {
long value = Long.parseLong(digits);
for (int index = digits.length(); index < 3; index++) {
value *= 10L;
}
return value;
}
}
@@ -0,0 +1,225 @@
package kst4contest.controller;
import java.util.OptionalLong;
import kst4contest.model.BeaconStatusSnapshot;
import kst4contest.model.BeaconStatusSnapshot.AggregateState;
import kst4contest.model.BeaconStatusSnapshot.CategoryStatus;
import kst4contest.model.BeaconStatusSnapshot.InactiveReason;
import kst4contest.model.ChatPreferences;
/** Owns session-only beacon activation, QRG references, reasons, and send failures. */
final class BeaconSessionState {
static final long MAX_AUTOMATIC_QRG_DELTA_HZ = 5_000L;
enum Channel {
MAIN,
SECOND
}
private final ChannelState main = new ChannelState();
private final ChannelState second = new ChannelState();
void resetSession(ChatPreferences preferences) {
synchronized (this) {
main.reset();
second.reset();
}
project(preferences);
}
boolean setEnabled(Channel channel, boolean enabled, ChatPreferences preferences) {
boolean accepted;
synchronized (this) {
ChannelState target = state(channel);
if (!enabled) {
target.disable(InactiveReason.USER_DISABLED);
accepted = true;
} else {
InactiveReason invalidReason = missingRequiredQrg(channel, preferences);
if (invalidReason != null) {
target.disable(invalidReason);
accepted = false;
} else {
target.enabled = true;
target.failed = false;
OptionalLong reference = ownQrg(channel, preferences);
target.referenceHz = reference.isPresent() ? reference.getAsLong() : null;
accepted = true;
}
}
}
project(preferences);
return accepted;
}
void onManualQrgChanged(Channel channel, String value, ChatPreferences preferences) {
if (channel == Channel.MAIN) {
preferences.getMYQRGFirstCat().set(value);
} else {
preferences.getMYQRGSecondCat().set(value);
}
OptionalLong parsed = BeaconFrequencyParser.parseHz(value);
synchronized (this) {
ChannelState target = state(channel);
if (target.enabled) {
target.referenceHz = parsed.isPresent() ? parsed.getAsLong() : null;
}
revalidateEnabled(preferences);
}
project(preferences);
}
void onExternalQrgChanged(Channel channel, long frequencyHz, ChatPreferences preferences) {
boolean valid = frequencyHz > 0L && ownQrg(channel, preferences).isPresent();
synchronized (this) {
ChannelState target = state(channel);
if (target.enabled) {
if (!valid) {
target.referenceHz = null;
} else if (target.referenceHz == null) {
target.referenceHz = frequencyHz;
} else if (absoluteDifference(target.referenceHz, frequencyHz)
> MAX_AUTOMATIC_QRG_DELTA_HZ) {
target.disable(InactiveReason.AUTOMATIC_QRG_CHANGE);
}
}
revalidateEnabled(preferences);
}
project(preferences);
}
void onTemplateChanged(Channel channel, ChatPreferences preferences) {
synchronized (this) {
ChannelState target = state(channel);
if (target.enabled) {
InactiveReason reason = missingRequiredQrg(channel, preferences);
if (reason != null) {
target.disable(reason);
}
}
}
project(preferences);
}
synchronized boolean isEnabled(Channel channel) {
return state(channel).enabled;
}
synchronized void recordSendFailure(Channel channel, boolean failed) {
ChannelState target = state(channel);
target.failed = target.enabled && failed;
}
synchronized BeaconStatusSnapshot snapshot(ChatPreferences preferences) {
boolean secondAvailable = preferences.isLoginToSecondChatEnabled();
boolean mainEnabled = main.enabled;
boolean secondEnabled = secondAvailable && second.enabled;
boolean failed = (mainEnabled && main.failed) || (secondEnabled && second.failed);
int available = secondAvailable ? 2 : 1;
int enabled = (mainEnabled ? 1 : 0) + (secondEnabled ? 1 : 0);
AggregateState aggregate;
if (failed) {
aggregate = AggregateState.FAILED;
} else if (enabled == 0) {
aggregate = AggregateState.OFF;
} else if (enabled == available) {
aggregate = AggregateState.ON;
} else {
aggregate = AggregateState.PARTIAL;
}
return new BeaconStatusSnapshot(
aggregate,
new CategoryStatus(true, mainEnabled, main.failed, main.reason),
new CategoryStatus(secondAvailable, secondEnabled, second.failed, second.reason)
);
}
private void revalidateEnabled(ChatPreferences preferences) {
revalidate(Channel.MAIN, preferences);
revalidate(Channel.SECOND, preferences);
}
private void revalidate(Channel channel, ChatPreferences preferences) {
ChannelState target = state(channel);
if (!target.enabled) {
return;
}
InactiveReason reason = missingRequiredQrg(channel, preferences);
if (reason != null) {
target.disable(reason);
}
}
private static InactiveReason missingRequiredQrg(
Channel channel,
ChatPreferences preferences
) {
String template = channel == Channel.MAIN
? preferences.getBcn_beaconTextMainCat()
: preferences.getBcn_beaconTextSecondCat();
String safeTemplate = template == null ? "" : template;
if ((safeTemplate.contains("MYQRG") || safeTemplate.contains("MYQRGSHORT"))
&& BeaconFrequencyParser.parseHz(preferences.getMYQRGFirstCat().get()).isEmpty()) {
return InactiveReason.PRIMARY_QRG_UNAVAILABLE;
}
if (safeTemplate.contains("SECONDQRG")
&& BeaconFrequencyParser.parseHz(preferences.getMYQRGSecondCat().get()).isEmpty()) {
return InactiveReason.SECONDARY_QRG_UNAVAILABLE;
}
return null;
}
private static OptionalLong ownQrg(Channel channel, ChatPreferences preferences) {
String value = channel == Channel.MAIN
? preferences.getMYQRGFirstCat().get()
: preferences.getMYQRGSecondCat().get();
return BeaconFrequencyParser.parseHz(value);
}
private static long absoluteDifference(long first, long second) {
return first >= second ? first - second : second - first;
}
private void project(ChatPreferences preferences) {
boolean mainEnabled;
boolean secondEnabled;
synchronized (this) {
mainEnabled = main.enabled;
secondEnabled = second.enabled;
}
if (preferences.isBcn_beaconsEnabledMainCat() != mainEnabled) {
preferences.setBcn_beaconsEnabledMainCat(mainEnabled);
}
if (preferences.isBcn_beaconsEnabledSecondCat() != secondEnabled) {
preferences.setBcn_beaconsEnabledSecondCat(secondEnabled);
}
}
private ChannelState state(Channel channel) {
return channel == Channel.MAIN ? main : second;
}
private static final class ChannelState {
private boolean enabled;
private boolean failed;
private Long referenceHz;
private InactiveReason reason = InactiveReason.NOT_ACTIVATED_THIS_SESSION;
private void reset() {
enabled = false;
failed = false;
referenceHz = null;
reason = InactiveReason.NOT_ACTIVATED_THIS_SESSION;
}
private void disable(InactiveReason inactiveReason) {
enabled = false;
failed = false;
referenceHz = null;
reason = inactiveReason;
}
}
}
@@ -1,25 +1,18 @@
package kst4contest.controller;
import kst4contest.model.BeaconStatusSnapshot;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
/** Keeps category failures and builds the single status shown for both beacons. */
/** Builds the single status shown for both beacons from their session state. */
final class BeaconStatus {
static final String SOURCE_NAME = ChatController.BEACON_STATUS_SOURCE;
private boolean mainInvalid;
private boolean secondInvalid;
private String activity = "Beacon configuration loaded.";
/** Records the result of one complete timer run. */
synchronized void recordRun(
boolean mainInvalid,
boolean secondInvalid,
String activity
) {
this.mainInvalid = mainInvalid;
this.secondInvalid = secondInvalid;
synchronized void recordRun(String activity) {
this.activity = activity;
}
@@ -29,47 +22,33 @@ final class BeaconStatus {
}
/** Builds a fresh aggregate from the current preferences and remembered failures. */
synchronized ThreadStateMessage message(ChatPreferences preferences) {
boolean secondAvailable = preferences.isLoginToSecondChatEnabled();
boolean mainEnabled = preferences.isBcn_beaconsEnabledMainCat();
boolean secondEnabled = secondAvailable
&& preferences.isBcn_beaconsEnabledSecondCat();
int available = secondAvailable ? 2 : 1;
int enabled = (mainEnabled ? 1 : 0) + (secondEnabled ? 1 : 0);
boolean critical = (mainInvalid && mainEnabled)
|| (secondInvalid && secondEnabled);
synchronized ThreadStateMessage message(
ChatPreferences preferences,
BeaconSessionState sessionState
) {
BeaconStatusSnapshot snapshot = sessionState.snapshot(preferences);
boolean critical = snapshot.getAggregateState()
== BeaconStatusSnapshot.AggregateState.FAILED;
boolean running = snapshot.getAggregateState()
!= BeaconStatusSnapshot.AggregateState.OFF;
String description;
if (critical) {
description = "FAILED";
} else if (enabled == 0) {
description = "off";
} else if (enabled == available) {
description = "on";
} else {
description = enabled + "/" + available;
switch (snapshot.getAggregateState()) {
case FAILED -> description = "FAILED";
case OFF -> description = "off";
case ON -> description = "on";
case PARTIAL -> description = "1/2";
default -> throw new IllegalStateException("Unknown beacon status");
}
String details = "Main category beacon: " + onOff(mainEnabled)
+ "; second category beacon: "
+ (secondAvailable
? onOff(secondEnabled)
: "unavailable");
String information = activity == null || activity.isBlank()
? details
: activity + "\n" + details;
ThreadStateMessage message = new ThreadStateMessage(
SOURCE_NAME,
enabled > 0,
information,
running,
activity,
critical
);
message.setRunningInformationTextDescription(description);
message.setBeaconStatusSnapshot(snapshot);
return message;
}
private static String onOff(boolean enabled) {
return enabled ? "on" : "off";
}
}
@@ -22,6 +22,7 @@ public class BeaconTask extends TimerTask {
private final ChatController chatController;
private final ThreadStatusCallback callbackToController;
private final BeaconStatus beaconStatus;
private final BeaconSessionState beaconSessionState;
/**
* Creates one execution of the shared beacon timer.
@@ -36,6 +37,7 @@ public class BeaconTask extends TimerTask {
this.chatController = chatController;
this.callbackToController = callbackToController;
this.beaconStatus = chatController.beaconStatusState();
this.beaconSessionState = chatController.beaconSessionState();
}
@Override
@@ -45,23 +47,34 @@ public class BeaconTask extends TimerTask {
Attempt second = sendSecondCategoryBeacon();
String activity = "Main category: " + main.description
+ "; second category: " + second.description;
beaconStatus.recordRun(
main == Attempt.INVALID,
second == Attempt.INVALID,
activity
);
recordSendResult(BeaconSessionState.Channel.MAIN, main);
recordSendResult(BeaconSessionState.Channel.SECOND, second);
beaconStatus.recordRun(activity);
callbackToController.onThreadStatus(
BeaconStatus.SOURCE_NAME,
beaconStatus.message(chatController.getChatPreferences())
beaconStatus.message(
chatController.getChatPreferences(),
beaconSessionState
)
);
}
private void recordSendResult(
BeaconSessionState.Channel channel,
Attempt attempt
) {
if (attempt == Attempt.INVALID) {
beaconSessionState.recordSendFailure(channel, true);
} else if (attempt == Attempt.SENT) {
beaconSessionState.recordSendFailure(channel, false);
}
}
/**
* Sends the main-category beacon if it is currently enabled.
*/
private Attempt sendMainCategoryBeacon() {
if (!chatController.getChatPreferences()
.isBcn_beaconsEnabledMainCat()) {
if (!beaconSessionState.isEnabled(BeaconSessionState.Channel.MAIN)) {
return Attempt.OFF;
}
@@ -95,7 +108,7 @@ public class BeaconTask extends TimerTask {
if (!chatController.getChatPreferences().isLoginToSecondChatEnabled()) {
return Attempt.UNAVAILABLE;
}
if (!chatController.getChatPreferences().isBcn_beaconsEnabledSecondCat()) {
if (!beaconSessionState.isEnabled(BeaconSessionState.Channel.SECOND)) {
return Attempt.OFF;
}
@@ -65,9 +65,13 @@ public class ChatController implements ThreadStatusCallback {
public static final String BEACON_STATUS_SOURCE = "MyBeacon";
public static final int MIN_BEACON_INTERVAL_MINUTES = 1;
public static final int MAX_BEACON_TEXT_LENGTH = 120;
public static final int MIN_BEACON_INTERVAL_MINUTES = 5;
public static final int MAX_BEACON_PAYLOAD_LENGTH = 120;
public static final int MAX_BEACON_TEXT_LENGTH =
MAX_BEACON_PAYLOAD_LENGTH
+ AutomaticChatMessageFormatter.SUFFIX.length();
private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L;
private final BeaconSessionState beaconSessionState = new BeaconSessionState();
private final BeaconStatus beaconStatus = new BeaconStatus();
private Consumer<Double> viewRotorCallback;
@@ -3036,6 +3040,7 @@ private SimpleRoster<String>
chatPreferences = new ChatPreferences(preferencesRelativeFileName);
chatPreferences.readPreferencesFromXmlFile();
beaconSessionState.resetSession(chatPreferences);
Path historyFile = Path.of(ApplicationFileUtils.getDebugFilePath(
ApplicationConstants.APPLICATION_NAME,
MessageHistoryRecorder.CURRENT_FILE_NAME));
@@ -3288,6 +3293,7 @@ private SimpleRoster<String>
public void setChatPreferences(ChatPreferences chatPreferences) {
this.chatPreferences = chatPreferences;
beaconSessionState.resetSession(chatPreferences);
}
public ChatMember getownChatMemberObject() {
@@ -3385,13 +3391,21 @@ private SimpleRoster<String>
/** Applies the main-category beacon switch to the running session immediately. */
public void setMainBeaconEnabled(boolean enabled) {
getChatPreferences().setBcn_beaconsEnabledMainCat(enabled);
beaconSessionState.setEnabled(
BeaconSessionState.Channel.MAIN,
enabled,
getChatPreferences()
);
publishBeaconStatus("Beacon configuration changed.");
}
/** Applies the second-category beacon switch to the running session immediately. */
public void setSecondBeaconEnabled(boolean enabled) {
getChatPreferences().setBcn_beaconsEnabledSecondCat(enabled);
beaconSessionState.setEnabled(
BeaconSessionState.Channel.SECOND,
enabled,
getChatPreferences()
);
publishBeaconStatus("Beacon configuration changed.");
}
@@ -3405,18 +3419,66 @@ private SimpleRoster<String>
public void toggleUsableBeacons() {
ChatPreferences preferences = getChatPreferences();
boolean secondAvailable = preferences.isLoginToSecondChatEnabled();
boolean anyEnabled = preferences.isBcn_beaconsEnabledMainCat()
boolean anyEnabled = beaconSessionState.isEnabled(BeaconSessionState.Channel.MAIN)
|| (secondAvailable
&& preferences.isBcn_beaconsEnabledSecondCat());
&& beaconSessionState.isEnabled(BeaconSessionState.Channel.SECOND));
boolean enable = !anyEnabled;
preferences.setBcn_beaconsEnabledMainCat(enable);
beaconSessionState.setEnabled(
BeaconSessionState.Channel.MAIN,
enable,
preferences
);
if (secondAvailable) {
preferences.setBcn_beaconsEnabledSecondCat(enable);
beaconSessionState.setEnabled(
BeaconSessionState.Channel.SECOND,
enable,
preferences
);
}
publishBeaconStatus("Beacon status toggled from the main window.");
}
/** Applies a deliberate operator QRG entry and rebases the affected beacon. */
public void applyManualQrg(RadioSlot slot, String value) {
BeaconSessionState.Channel channel = slot == RadioSlot.FIRST
? BeaconSessionState.Channel.MAIN
: BeaconSessionState.Channel.SECOND;
beaconSessionState.onManualQrgChanged(channel, value, getChatPreferences());
publishBeaconStatus("QRG changed by the operator.");
}
/** Applies a logger QRG report and enforces the automatic-QSY safety boundary. */
public void applyExternalQrg(RadioSlot slot, String displayValue, long frequencyHz) {
if (frequencyHz <= 0L) {
return;
}
slot.qrgIn(getChatPreferences()).set(displayValue);
BeaconSessionState.Channel channel = slot == RadioSlot.FIRST
? BeaconSessionState.Channel.MAIN
: BeaconSessionState.Channel.SECOND;
beaconSessionState.onExternalQrgChanged(channel, frequencyHz, getChatPreferences());
publishBeaconStatus("QRG updated by logging software.");
}
/** Revalidates the applied main-category template. */
public void onMainBeaconTemplateApplied() {
beaconSessionState.onTemplateChanged(
BeaconSessionState.Channel.MAIN,
getChatPreferences()
);
publishBeaconStatus("Beacon template changed.");
}
/** Revalidates the applied second-category template. */
public void onSecondBeaconTemplateApplied() {
beaconSessionState.onTemplateChanged(
BeaconSessionState.Channel.SECOND,
getChatPreferences()
);
publishBeaconStatus("Beacon template changed.");
}
/** Publishes the loaded state without changing either category switch. */
public void refreshBeaconStatus() {
publishBeaconStatus("Beacon configuration loaded.");
@@ -3427,9 +3489,13 @@ private SimpleRoster<String>
return beaconStatus;
}
BeaconSessionState beaconSessionState() {
return beaconSessionState;
}
/** Builds the current aggregate when the UI is ready to display it. */
public ThreadStateMessage currentBeaconStatus() {
return beaconStatus.message(getChatPreferences());
return beaconStatus.message(getChatPreferences(), beaconSessionState);
}
/** Publishes the aggregate state used by the single beacon status chip. */
@@ -3454,7 +3520,7 @@ private SimpleRoster<String>
* @param configuredText configured beacon template
* @throws IllegalArgumentException if the template contains invalid protocol
* content or currently resolves to more than
* {@value MAX_BEACON_TEXT_LENGTH} characters
* {@value MAX_BEACON_PAYLOAD_LENGTH} characters
*/
public void validateBeaconTemplate(String configuredText) {
String normalizedTemplate =
@@ -3473,7 +3539,10 @@ private SimpleRoster<String>
return;
}
validateResolvedBeaconText(resolvedText);
validateBeaconText(
resolvedText,
MAX_BEACON_PAYLOAD_LENGTH
);
}
/**
@@ -3494,22 +3563,29 @@ private SimpleRoster<String>
new MessageVariableResolver(chatPreferences)
.resolveGlobalVariables(configuredText);
return validateResolvedBeaconText(resolvedText);
String normalizedPayload = validateBeaconText(
resolvedText,
MAX_BEACON_PAYLOAD_LENGTH
);
return validateBeaconText(
AutomaticChatMessageFormatter.appendSuffix(normalizedPayload),
MAX_BEACON_TEXT_LENGTH
);
}
/**
* Applies the message-text and length rules to a fully resolved beacon.
* Applies the message-text and length rules to a beacon processing stage.
*/
private String validateResolvedBeaconText(String resolvedText) {
private String validateBeaconText(String resolvedText, int maximumLength) {
String normalizedText =
On4KstProtocol.messageText(resolvedText);
if (normalizedText.length() > MAX_BEACON_TEXT_LENGTH) {
if (normalizedText.length() > maximumLength) {
throw new IllegalArgumentException(
"The resolved beacon message contains "
+ normalizedText.length()
+ " characters; maximum is "
+ MAX_BEACON_TEXT_LENGTH
+ maximumLength
+ "."
);
}
@@ -872,10 +872,14 @@ public class MessageBusManagementThread extends Thread {
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");
versionInfo.setMessageText(
AutomaticChatMessageFormatter.appendSuffix(
"/CQ " + newMessageArrived.getSender().getCallSign()
+ " " + AUTOANSWER_PREFIX
+ " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION
+ " by DO5AMF"
)
);
this.client.getMessageTXBus().add(versionInfo);
}
@@ -2070,7 +2074,9 @@ public class MessageBusManagementThread extends Thread {
+ payload;
try {
return On4KstProtocol.messageText(messageText);
return On4KstProtocol.messageText(
AutomaticChatMessageFormatter.appendSuffix(messageText)
);
} catch (IllegalArgumentException invalidMessage) {
LOGGER.warning(
@@ -535,6 +535,7 @@ public class ReadUDPByWintestThread extends Thread {
* interpreted as frequencies.
*/
String formattedPassQrg = null;
Long passFrequencyRawValue = null;
if (parts.size() > 11) {
try {
@@ -544,6 +545,7 @@ public class ReadUDPByWintestThread extends Thread {
passFrequencyRaw / 10.0;
if (passFrequencyKHz > 100.0) {
passFrequencyRawValue = passFrequencyRaw;
formattedPassQrg =
helper_formatWinTestFrequency(
passFrequencyRaw
@@ -566,10 +568,16 @@ public class ReadUDPByWintestThread extends Thread {
usePassQrg && formattedPassQrg != null
? formattedPassQrg
: formattedMainQrg;
final long selectedFrequencyRaw =
usePassQrg && passFrequencyRawValue != null
? passFrequencyRawValue
: mainFrequencyRaw;
final long selectedFrequencyHz = Math.multiplyExact(selectedFrequencyRaw, 100L);
if (prefs.isLogsynch_wintestQrgSyncEnabled()) {
RadioSlot target = slot.get();
client.uiDispatcher().runOnUi(() -> target.qrgIn(prefs).set(qrgToSet));
client.uiDispatcher().runOnUi(() ->
client.applyExternalQrg(target, qrgToSet, selectedFrequencyHz));
}
System.out.println(
@@ -417,7 +417,19 @@ public class ReadUDPbyUCXMessageThread extends Thread {
}
String qrg = freqNode.getTextContent();
qrg = qrg == null ? "" : qrg.trim();
String mode = modeNode.getTextContent();
final long frequencyHz;
try {
frequencyHz = Math.multiplyExact(Long.parseLong(qrg), 10L);
} catch (NumberFormatException | ArithmeticException invalidFrequency) {
System.out.println("[ReadUDPFromUCX, warning]: RadioInfo with invalid Freq ignored");
continue;
}
if (frequencyHz <= 0L) {
System.out.println("[ReadUDPFromUCX, warning]: RadioInfo with non-positive Freq ignored");
continue;
}
// System.out.println("QRG Length: " + qrg.length() + " // " + qrg);
@@ -464,7 +476,7 @@ public class ReadUDPbyUCXMessageThread extends Thread {
final String finalFormattedQRG = formattedQRG;
final RadioSlot target = slot.get();
helper_runOnFxThread(() ->
target.qrgIn(this.client.getChatPreferences()).set(finalFormattedQRG)
this.client.applyExternalQrg(target, finalFormattedQRG, frequencyHz)
);
// System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG);
@@ -0,0 +1,83 @@
package kst4contest.model;
import java.util.Objects;
/** Immutable, display-neutral status of both beacon categories. */
public final class BeaconStatusSnapshot {
public enum AggregateState {
OFF,
PARTIAL,
ON,
FAILED
}
public enum InactiveReason {
NOT_ACTIVATED_THIS_SESSION,
USER_DISABLED,
PRIMARY_QRG_UNAVAILABLE,
SECONDARY_QRG_UNAVAILABLE,
AUTOMATIC_QRG_CHANGE
}
/** Immutable status of one beacon category. */
public static final class CategoryStatus {
private final boolean available;
private final boolean enabled;
private final boolean failed;
private final InactiveReason inactiveReason;
public CategoryStatus(
boolean available,
boolean enabled,
boolean failed,
InactiveReason inactiveReason
) {
this.available = available;
this.enabled = enabled;
this.failed = failed;
this.inactiveReason = Objects.requireNonNull(inactiveReason);
}
public boolean isAvailable() {
return available;
}
public boolean isEnabled() {
return enabled;
}
public boolean isFailed() {
return failed;
}
public InactiveReason getInactiveReason() {
return inactiveReason;
}
}
private final AggregateState aggregateState;
private final CategoryStatus main;
private final CategoryStatus second;
public BeaconStatusSnapshot(
AggregateState aggregateState,
CategoryStatus main,
CategoryStatus second
) {
this.aggregateState = Objects.requireNonNull(aggregateState);
this.main = Objects.requireNonNull(main);
this.second = Objects.requireNonNull(second);
}
public AggregateState getAggregateState() {
return aggregateState;
}
public CategoryStatus getMain() {
return main;
}
public CategoryStatus getSecond() {
return second;
}
}
@@ -366,7 +366,8 @@ public class ChatPreferences {
* Beacon prefs
*/
MutableValue<Boolean> bcn_beaconsEnabledMainCat = new SimpleValue<>(true);
private static final int MIN_BEACON_INTERVAL_MINUTES = 5;
MutableValue<Boolean> bcn_beaconsEnabledMainCat = new SimpleValue<>(false);
MutableValue<Boolean> bcn_beaconsEnabledSecondCat = new SimpleValue<>(false);
int bcn_beaconIntervalInMinutesMainCat = 20;
int bcn_beaconIntervalInMinutesSecondCat = 20;
@@ -2198,11 +2199,11 @@ public class ChatPreferences {
beaconCQ.appendChild(bcn_beaconIntervalInMinutesMainCat);
Element beaconCQEnabled = doc.createElement("beaconCQEnabled");
beaconCQEnabled.setTextContent(this.isBcn_beaconsEnabledMainCat()+"");
beaconCQEnabled.setTextContent("false");
beaconCQ.appendChild(beaconCQEnabled);
Element bcn_beaconsEnabledMainCat = doc.createElement("bcn_beaconsEnabledMainCat");
bcn_beaconsEnabledMainCat.setTextContent(this.isBcn_beaconsEnabledMainCat()+"");
bcn_beaconsEnabledMainCat.setTextContent("false");
beaconCQ.appendChild(bcn_beaconsEnabledMainCat);
@@ -2223,11 +2224,11 @@ public class ChatPreferences {
beaconCQ.appendChild(bcn_beaconIntervalInMinutesSecondCat);
Element beaconCQEnabledSecondCat = doc.createElement("beaconCQEnabledSecondCat");
beaconCQEnabledSecondCat.setTextContent(this.isBcn_beaconsEnabledSecondCat()+"");
beaconCQEnabledSecondCat.setTextContent("false");
beaconCQ.appendChild(beaconCQEnabledSecondCat);
Element bcn_beaconsEnabledSecondCat = doc.createElement("bcn_beaconsEnabledSecondCat");
bcn_beaconsEnabledSecondCat.setTextContent(this.isBcn_beaconsEnabledSecondCat()+"");
bcn_beaconsEnabledSecondCat.setTextContent("false");
beaconCQ.appendChild(bcn_beaconsEnabledSecondCat);
/**
@@ -3178,12 +3179,18 @@ public class ChatPreferences {
Element beaconCQEl = getFirstElement(doc, "beaconCQ");
if (beaconCQEl != null) {
setBcn_beaconsEnabledMainCat(getBoolean(beaconCQEl, isBcn_beaconsEnabledMainCat(), "bcn_beaconsEnabledMainCat", "beaconCQEnabled"));
bcn_beaconIntervalInMinutesMainCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesMainCat, "bcn_beaconIntervalInMinutesMainCat", "beaconCQIntervalMinutes");
setBcn_beaconsEnabledMainCat(false);
bcn_beaconIntervalInMinutesMainCat = Math.max(
MIN_BEACON_INTERVAL_MINUTES,
getInt(beaconCQEl, bcn_beaconIntervalInMinutesMainCat, "bcn_beaconIntervalInMinutesMainCat", "beaconCQIntervalMinutes")
);
bcn_beaconTextMainCat = getText(beaconCQEl, bcn_beaconTextMainCat, "bcn_beaconTextMainCat", "beaconCQText");
setBcn_beaconsEnabledSecondCat(getBoolean(beaconCQEl, isBcn_beaconsEnabledSecondCat(), "bcn_beaconsEnabledSecondCat", "beaconCQEnabledSecondCat"));
bcn_beaconIntervalInMinutesSecondCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesSecondCat, "bcn_beaconIntervalInMinutesSecondCat", "beaconCQIntervalMinutesSecondCat");
setBcn_beaconsEnabledSecondCat(false);
bcn_beaconIntervalInMinutesSecondCat = Math.max(
MIN_BEACON_INTERVAL_MINUTES,
getInt(beaconCQEl, bcn_beaconIntervalInMinutesSecondCat, "bcn_beaconIntervalInMinutesSecondCat", "beaconCQIntervalMinutesSecondCat")
);
bcn_beaconTextSecondCat = getText(beaconCQEl, bcn_beaconTextSecondCat, "bcn_beaconTextSecondCat", "beaconCQTextSecondText");
System.out.println("[ChatPreferences, info]: beaconCQ main='" + bcn_beaconTextMainCat + "', second='" + bcn_beaconTextSecondCat + "'");
@@ -14,6 +14,7 @@ public class ThreadStateMessage {
boolean criticalState;
String criticalStateFurtherInfo;
BeaconStatusSnapshot beaconStatusSnapshot;
@@ -99,5 +100,13 @@ public class ThreadStateMessage {
public void setCriticalStateFurtherInfo(String criticalStateFurtherInfo) {
this.criticalStateFurtherInfo = criticalStateFurtherInfo;
}
public BeaconStatusSnapshot getBeaconStatusSnapshot() {
return beaconStatusSnapshot;
}
public void setBeaconStatusSnapshot(BeaconStatusSnapshot beaconStatusSnapshot) {
this.beaconStatusSnapshot = beaconStatusSnapshot;
}
}
@@ -112,10 +112,10 @@
</textSnippets>
<beaconCQ>
<beaconCQText>_.~'"´"'~.__.~'´ CQ CQ CQ NAC de MYCALL, pse try 144.317 for JO54 `'~.__.~'"`"'~._</beaconCQText>
<beaconCQIntervalMinutes>1</beaconCQIntervalMinutes>
<beaconCQIntervalMinutes>5</beaconCQIntervalMinutes>
<beaconCQEnabled>false</beaconCQEnabled>
<beaconCQTextSecondText>_.~'"´"'~.__.~'´ CQ CQ CQ de MYCALL, pse sked or SECONDQRG `'~.__.~'"`"'~._</beaconCQTextSecondText>
<beaconCQIntervalMinutesSecondCat>3</beaconCQIntervalMinutesSecondCat>
<beaconCQIntervalMinutesSecondCat>5</beaconCQIntervalMinutesSecondCat>
<beaconCQEnabledSecondCat>false</beaconCQEnabledSecondCat>
</beaconCQ>
<beaconUnworkedstations>
@@ -0,0 +1,27 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
/** Verifies the fixed suffix contract for automatic chat messages. */
class AutomaticChatMessageFormatterTest {
@Test
void appendsTheExactAutomaticChatSuffix() {
assertEquals(
"CQ test *a*",
AutomaticChatMessageFormatter.appendSuffix("CQ test"),
"the suffix must include its leading space"
);
}
@Test
void appendsTheSuffixEvenWhenTheTextAlreadyEndsWithIt() {
assertEquals(
"CQ test *a* *a*",
AutomaticChatMessageFormatter.appendSuffix("CQ test *a*"),
"an existing suffix must not be deduplicated"
);
}
}
@@ -36,8 +36,7 @@ class BeaconControlTest {
@Test
void toggleWithSecondChatDisabledChangesOnlyTheMainBeacon() {
preferences.setLoginToSecondChatEnabled(false);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(true);
controller.setSecondBeaconEnabled(true);
controller.toggleUsableBeacons();
@@ -56,8 +55,6 @@ class BeaconControlTest {
@Test
void toggleWithSecondChatEnabledSwitchesBothUsableBeacons() {
preferences.setLoginToSecondChatEnabled(true);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(false);
controller.toggleUsableBeacons();
@@ -65,7 +62,7 @@ class BeaconControlTest {
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("on");
preferences.setBcn_beaconsEnabledSecondCat(false);
controller.setSecondBeaconEnabled(false);
controller.toggleUsableBeacons();
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
@@ -76,8 +73,6 @@ class BeaconControlTest {
@Test
void anIndividualCheckboxPublishesThePartialStateImmediately() {
preferences.setLoginToSecondChatEnabled(true);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(false);
controller.setMainBeaconEnabled(true);
@@ -86,6 +81,47 @@ class BeaconControlTest {
assertLastDescription("1/2");
}
@Test
void activationRejectsOnlyTheTemplateWhoseRequiredQrgIsMissing() {
preferences.setLoginToSecondChatEnabled(true);
preferences.setMYQRGSecondCat("1296.200");
preferences.setBcn_beaconTextMainCat("CQ SECONDQRG");
preferences.setBcn_beaconTextSecondCat("CQ MYQRG");
controller.toggleUsableBeacons();
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
assertFalse(preferences.isBcn_beaconsEnabledSecondCat());
}
@Test
void manualQrgRebasesBeforeAnAutomaticMoveIsCompared() {
preferences.setBcn_beaconTextMainCat("CQ");
controller.setMainBeaconEnabled(true);
controller.applyManualQrg(RadioSlot.FIRST, "144.300");
controller.applyExternalQrg(RadioSlot.FIRST, "144.305", 144_305_000L);
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
controller.applyManualQrg(RadioSlot.FIRST, "144.310");
controller.applyExternalQrg(RadioSlot.FIRST, "144.315.01", 144_315_010L);
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
}
@Test
void nonPositiveExternalQrgCannotOverwriteTheDisplayOrClearTheReference() {
preferences.setBcn_beaconTextMainCat("CQ");
controller.applyManualQrg(RadioSlot.FIRST, "144.300");
controller.setMainBeaconEnabled(true);
controller.applyExternalQrg(RadioSlot.FIRST, "0", 0L);
assertEquals("144.300", preferences.getMYQRGFirstCat().get());
controller.applyExternalQrg(RadioSlot.FIRST, "144.306", 144_306_000L);
assertFalse(preferences.isBcn_beaconsEnabledMainCat(),
"the rejected packet must not clear the 144.300 reference");
}
private void assertLastDescription(String expected) {
ArgumentCaptor<String> key = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<ThreadStateMessage> message = ArgumentCaptor.forClass(ThreadStateMessage.class);
@@ -0,0 +1,31 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class BeaconFrequencyParserTest {
@Test
void parsesEveryCompleteQrgFormWithoutLosingHertz() {
assertHz("144.300", 144_300_000L);
assertHz("144,300", 144_300_000L);
assertHz("144.300.03", 144_300_030L);
assertHz("1296.123.000", 1_296_123_000L);
assertHz("144300", 144_300_000L);
assertHz("10368100", 10_368_100_000L);
}
@Test
void rejectsIncompleteMalformedAndUnsupportedValues() {
for (String value : new String[] {null, "", " ", "210", "144 MHz", "0", "-144.300",
"999999999999999999999999999999"}) {
assertTrue(BeaconFrequencyParser.parseHz(value).isEmpty(), value);
}
}
private static void assertHz(String value, long expected) {
assertEquals(expected, BeaconFrequencyParser.parseHz(value).orElseThrow());
}
}
@@ -0,0 +1,136 @@
package kst4contest.controller;
import static kst4contest.controller.BeaconSessionState.Channel.MAIN;
import static kst4contest.controller.BeaconSessionState.Channel.SECOND;
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.nio.file.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import kst4contest.model.BeaconStatusSnapshot;
import kst4contest.model.BeaconStatusSnapshot.AggregateState;
import kst4contest.model.BeaconStatusSnapshot.InactiveReason;
import kst4contest.model.ChatPreferences;
class BeaconSessionStateTest {
@TempDir Path tempDir;
private ChatPreferences preferences;
private BeaconSessionState state;
@BeforeEach
void setUp() {
preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
preferences.setLoginToSecondChatEnabled(true);
preferences.setBcn_beaconTextMainCat("CQ MAIN");
preferences.setBcn_beaconTextSecondCat("CQ SECOND");
preferences.setMYQRGFirstCat("");
preferences.setMYQRGSecondCat("");
state = new BeaconSessionState();
state.resetSession(preferences);
}
@Test
void resetStartsBothCategoriesOffWithSessionReason() {
BeaconStatusSnapshot snapshot = state.snapshot(preferences);
assertEquals(AggregateState.OFF, snapshot.getAggregateState());
assertEquals(InactiveReason.NOT_ACTIVATED_THIS_SESSION, snapshot.getMain().getInactiveReason());
assertEquals(InactiveReason.NOT_ACTIVATED_THIS_SESSION, snapshot.getSecond().getInactiveReason());
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
assertFalse(preferences.isBcn_beaconsEnabledSecondCat());
}
@Test
void enablesValidCategoryAndRejectsOtherCategoryWithMissingCrossTemplateQrg() {
preferences.setMYQRGSecondCat("1296.123");
preferences.setBcn_beaconTextMainCat("CQ SECONDQRG");
preferences.setBcn_beaconTextSecondCat("CQ MYQRG");
assertTrue(state.setEnabled(MAIN, true, preferences));
assertFalse(state.setEnabled(SECOND, true, preferences));
BeaconStatusSnapshot snapshot = state.snapshot(preferences);
assertEquals(AggregateState.PARTIAL, snapshot.getAggregateState());
assertTrue(snapshot.getMain().isEnabled());
assertEquals(InactiveReason.PRIMARY_QRG_UNAVAILABLE, snapshot.getSecond().getInactiveReason());
}
@Test
void manualQrgRebasesAndInvalidValueClearsReferenceWithoutPlaceholder() {
assertTrue(state.setEnabled(MAIN, true, preferences));
state.onManualQrgChanged(MAIN, "144.300", preferences);
state.onExternalQrgChanged(MAIN, 144_306_000L, preferences);
assertFalse(state.isEnabled(MAIN));
assertTrue(state.setEnabled(MAIN, true, preferences));
state.onManualQrgChanged(MAIN, "invalid", preferences);
assertTrue(state.isEnabled(MAIN));
state.onExternalQrgChanged(MAIN, 144_310_000L, preferences);
assertTrue(state.isEnabled(MAIN), "first valid report establishes the cleared reference");
}
@Test
void invalidManualQrgDisablesEveryActiveTemplateThatNeedsIt() {
preferences.setMYQRGFirstCat("144.300");
preferences.setBcn_beaconTextMainCat("CQ MYQRG");
preferences.setBcn_beaconTextSecondCat("CQ MYQRGSHORT");
assertTrue(state.setEnabled(MAIN, true, preferences));
assertTrue(state.setEnabled(SECOND, true, preferences));
state.onManualQrgChanged(MAIN, "", preferences);
assertFalse(state.isEnabled(MAIN));
assertFalse(state.isEnabled(SECOND));
assertEquals(InactiveReason.PRIMARY_QRG_UNAVAILABLE,
state.snapshot(preferences).getSecond().getInactiveReason());
}
@Test
void externalMoveUsesStrictFiveKhzBoundaryAndOnlyItsCategory() {
preferences.setMYQRGFirstCat("144.300");
preferences.setMYQRGSecondCat("1296.100");
assertTrue(state.setEnabled(MAIN, true, preferences));
assertTrue(state.setEnabled(SECOND, true, preferences));
state.onExternalQrgChanged(MAIN, 144_305_000L, preferences);
assertTrue(state.isEnabled(MAIN));
state.onExternalQrgChanged(MAIN, 144_305_001L, preferences);
assertFalse(state.isEnabled(MAIN));
assertTrue(state.isEnabled(SECOND));
assertEquals(InactiveReason.AUTOMATIC_QRG_CHANGE,
state.snapshot(preferences).getMain().getInactiveReason());
}
@Test
void invalidExternalQrgClearsTheReferenceWithoutAPlaceholder() {
assertTrue(state.setEnabled(MAIN, true, preferences));
preferences.setMYQRGFirstCat("7.100.00");
state.onExternalQrgChanged(MAIN, 7_100_000L, preferences);
assertTrue(state.isEnabled(MAIN));
preferences.setMYQRGFirstCat("144.300");
state.onExternalQrgChanged(MAIN, 144_300_000L, preferences);
assertTrue(state.isEnabled(MAIN), "the first valid report must establish the reference");
}
@Test
void failureOverridesAggregateAndA_successClearsOnlyThatCategory() {
assertTrue(state.setEnabled(MAIN, true, preferences));
assertTrue(state.setEnabled(SECOND, true, preferences));
state.recordSendFailure(MAIN, true);
state.recordSendFailure(SECOND, true);
assertEquals(AggregateState.FAILED, state.snapshot(preferences).getAggregateState());
state.recordSendFailure(MAIN, false);
BeaconStatusSnapshot snapshot = state.snapshot(preferences);
assertEquals(AggregateState.FAILED, snapshot.getAggregateState());
assertFalse(snapshot.getMain().isFailed());
assertTrue(snapshot.getSecond().isFailed());
}
}
@@ -65,8 +65,9 @@ class BeaconTaskTest {
@Test
void invalidEnabledBeaconIsNotQueuedAndReportsOneCriticalAggregateState() {
preferences.setBcn_beaconsEnabledMainCat(true);
controller.setMainBeaconEnabled(true);
preferences.setBcn_beaconTextMainCat("invalid|template");
statuses.clear();
task.run();
@@ -80,8 +81,9 @@ class BeaconTaskTest {
@Test
void changingTheOtherCheckboxDoesNotHideAnActiveCategoryFailure() {
preferences.setBcn_beaconsEnabledMainCat(true);
controller.setMainBeaconEnabled(true);
preferences.setBcn_beaconTextMainCat("invalid|template");
statuses.clear();
task.run();
statuses.clear();
@@ -93,6 +95,29 @@ class BeaconTaskTest {
assertTrue(status.isCriticalState());
}
@Test
void unavailableCategoryRetainsFailureUntilItsNextSuccessfulRun() {
controller.setSecondBeaconEnabled(true);
preferences.setBcn_beaconTextSecondCat("invalid|template");
task.run();
assertTrue(controller.currentBeaconStatus()
.getBeaconStatusSnapshot().getSecond().isFailed());
preferences.setLoginToSecondChatEnabled(false);
task.run();
assertTrue(controller.currentBeaconStatus()
.getBeaconStatusSnapshot().getSecond().isFailed(),
"an unavailable run did not succeed and must not clear the failure");
preferences.setLoginToSecondChatEnabled(true);
assertEquals("FAILED", controller.currentBeaconStatus()
.getRunningInformationTextDescription());
preferences.setBcn_beaconTextSecondCat("CQ SECOND");
task.run();
assertFalse(controller.currentBeaconStatus()
.getBeaconStatusSnapshot().getSecond().isFailed());
}
@Test
void everyRunUsesTheCurrentEnableFlagsAndReportsOneAggregateState() {
task.run();
@@ -100,16 +125,22 @@ class BeaconTaskTest {
assertAggregateStatus("off", false);
statuses.clear();
preferences.setBcn_beaconsEnabledMainCat(true);
controller.setMainBeaconEnabled(true);
statuses.clear();
task.run();
assertEquals(1, txBus.size());
assertEquals(ChatCategory.VUHF, txBus.peek().getChatCategory().getCategoryNumber());
assertEquals("CQ MAIN *a*", txBus.peek().getMessageText());
assertAggregateStatus("1/2", true);
statuses.clear();
preferences.setBcn_beaconsEnabledSecondCat(true);
controller.setSecondBeaconEnabled(true);
statuses.clear();
task.run();
assertEquals(3, txBus.size());
List<ChatMessage> sentMessages = new ArrayList<>(txBus);
assertEquals("CQ MAIN *a*", sentMessages.get(1).getMessageText());
assertEquals("CQ SECOND *a*", sentMessages.get(2).getMessageText());
assertAggregateStatus("on", true);
}
@@ -116,7 +116,7 @@ class MessageBusManagementThreadAutoAnswerDecisionTest {
@Test
void buildsTheQrgReply() {
assertEquals(
"/CQ DL1ABC " + MARKER + " QRG is: 144.300",
"/CQ DL1ABC " + MARKER + " QRG is: 144.300 *a*",
MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, true, " 144.300 ", false, null));
}
@@ -124,7 +124,7 @@ class MessageBusManagementThreadAutoAnswerDecisionTest {
@Test
void qrgRequestWinsOverTheGenericReply() {
assertEquals(
"/CQ DL1ABC " + MARKER + " QRG is: 144.300",
"/CQ DL1ABC " + MARKER + " QRG is: 144.300 *a*",
MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, true, "144.300", true, "not qrv"));
}
@@ -143,7 +143,7 @@ class MessageBusManagementThreadAutoAnswerDecisionTest {
@Test
void buildsTheGenericReplyTrimmed() {
assertEquals(
"/CQ DL1ABC " + MARKER + " not qrv, back at 18z",
"/CQ DL1ABC " + MARKER + " not qrv, back at 18z *a*",
MessageBusManagementThread.buildAutoAnswerMessageText(
"DL1ABC", VUHF, false, "144.300", true, " not qrv, back at 18z "));
}
@@ -169,7 +169,7 @@ class MessageBusManagementThreadAutoAnswerDecisionTest {
@Test
void senderCallSignIsTrimmedButNotUpperCased() {
assertEquals(
"/CQ dl1abc " + MARKER + " QRG is: 144.300",
"/CQ dl1abc " + MARKER + " QRG is: 144.300 *a*",
MessageBusManagementThread.buildAutoAnswerMessageText(
" dl1abc ", VUHF, true, "144.300", false, null));
}
@@ -104,7 +104,7 @@ class MessageBusManagementThreadAutoAnswerTest {
ChatMessage reply = replies.get(0);
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " QRG is: " + FIRST_QRG,
+ " QRG is: " + FIRST_QRG + " *a*",
reply.getMessageText());
assertEquals(MY_CALL, reply.getSender().getCallSign());
assertEquals(REMOTE_CALL, reply.getReceiver().getCallSign());
@@ -152,7 +152,7 @@ class MessageBusManagementThreadAutoAnswerTest {
"another station and another category each get their own reply");
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " QRG is: " + SECOND_QRG,
+ " QRG is: " + SECOND_QRG + " *a*",
replies.get(2).getMessageText(),
"the second-category question is answered with the second QRG");
assertSame(secondCategory, replies.get(2).getChatCategory());
@@ -232,7 +232,7 @@ class MessageBusManagementThreadAutoAnswerTest {
"the rejected reply must not have started the cooldown");
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " QRG is: " + FIRST_QRG,
+ " QRG is: " + FIRST_QRG + " *a*",
txBus.peek().getMessageText());
}
@@ -248,7 +248,7 @@ class MessageBusManagementThreadAutoAnswerTest {
assertEquals(1, txBus.size());
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " not qrv, back at 18z",
+ " not qrv, back at 18z *a*",
txBus.peek().getMessageText(),
"the configured text is trimmed");
}
@@ -265,7 +265,7 @@ class MessageBusManagementThreadAutoAnswerTest {
assertEquals(1, txBus.size());
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " QRG is: " + FIRST_QRG,
+ " QRG is: " + FIRST_QRG + " *a*",
txBus.peek().getMessageText());
}
@@ -282,7 +282,7 @@ class MessageBusManagementThreadAutoAnswerTest {
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));
assertTrue(txBus.peek().getMessageText().endsWith("QRG is: " + FIRST_QRG + " *a*"));
}
@Test
@@ -295,7 +295,7 @@ class MessageBusManagementThreadAutoAnswerTest {
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION
+ " by DO5AMF",
+ " by DO5AMF *a*",
txBus.peek().getMessageText());
}
@@ -134,7 +134,7 @@ class MessageBusManagementThreadChatFrameTest {
assertEquals(
"/CQ " + REMOTE_CALL + " " + ApplicationConstants.AUTOANSWER_PREFIX
+ " KST4Contest v" + ApplicationConstants.APPLICATION_CURRENT_VERSION
+ " by DO5AMF",
+ " by DO5AMF *a*",
txBus.peek().getMessageText());
verify(controller, times(1)).publishChatMessage(any(ChatMessage.class));
}
@@ -89,13 +89,32 @@ class On4KstProtocolTest {
);
assertEquals(
"calling cq at 144.300",
"calling cq at 144.300 *a*",
controller.resolveAndValidateBeaconText(
"calling cq at MYQRG"
)
);
}
@Test
void keepsThe120CharacterPayloadAllowanceWithA124CharacterFinalBeacon() {
ChatPreferences preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
ChatController controller = new ChatController();
controller.setChatPreferences(preferences);
String payload = "x".repeat(120);
assertDoesNotThrow(() -> controller.validateBeaconTemplate(payload));
String finalMessage = controller.resolveAndValidateBeaconText(payload);
assertEquals(124, finalMessage.length());
assertEquals(payload + " *a*", finalMessage);
assertThrows(
IllegalArgumentException.class,
() -> controller.validateBeaconTemplate("x".repeat(121))
);
}
@Test
void acceptsTemporarilyUnresolvedVariableOnlyBeaconTemplate() {
ChatPreferences preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
@@ -136,11 +155,8 @@ class On4KstProtocolTest {
assertThrows(
IllegalArgumentException.class,
() -> controller.validateBeaconTemplate(
"x".repeat(
ChatController.MAX_BEACON_TEXT_LENGTH
+ 1
)
"x".repeat(121)
)
);
}
}
}
@@ -4,7 +4,10 @@ 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 static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -105,6 +108,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
when(preferences.isTrxSynch_ucxLogUDPListenerEnabled()).thenReturn(true);
SimpleValue<String> qrg = new SimpleValue<>("");
when(preferences.getMYQRGFirstCat()).thenReturn(qrg);
stubExternalQrgWrites(qrg);
CountDownLatch firstQrg = new CountDownLatch(1);
CountDownLatch secondQrg = new CountDownLatch(1);
qrg.addListener(value -> {
@@ -230,6 +234,29 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
assertEquals("1296.200.00", markerQrg.get());
}
/** A malformed Freq is expected external input and must be ignored without the safety net. */
@Test
void malformedRadioInfoFrequencyIsSkippedWithoutAnUnexpectedError(@TempDir Path tempDir) throws Exception {
givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt"));
sendThenAwaitMarker("<RadioInfo><Freq>not-a-number</Freq><Mode>CW</Mode></RadioInfo>");
assertEquals("1296.200.00", markerQrg.get());
assertFalse(statusMessages.stream()
.anyMatch(message -> message.getRunningInformation().startsWith("packet dropped after error")));
}
@Test
void nonPositiveRadioInfoFrequencyIsIgnoredBeforeItReachesTheController(@TempDir Path tempDir)
throws Exception {
givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt"));
sendThenAwaitMarker("<RadioInfo><Freq>0</Freq><Mode>CW</Mode></RadioInfo>");
verify(controller, never()).applyExternalQrg(any(RadioSlot.class), eq("0"), eq(0L));
assertEquals("1296.200.00", markerQrg.get());
}
/**
* Any runtime exception while processing one packet drops that packet,
* reports a non-critical status and leaves the receive loop running.
@@ -275,6 +302,16 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
when(controller.uiDispatcher()).thenReturn(new DirectUiDispatcher());
when(preferences.isTrxSynch_ucxLogUDPListenerEnabled()).thenReturn(true);
when(preferences.getMYQRGFirstCat()).thenReturn(markerQrg);
stubExternalQrgWrites(markerQrg);
}
private void stubExternalQrgWrites(SimpleValue<String> firstQrg) {
doAnswer(invocation -> {
RadioSlot slot = invocation.getArgument(0);
String value = invocation.getArgument(1);
(slot == RadioSlot.FIRST ? firstQrg : secondQrg).set(value);
return null;
}).when(controller).applyExternalQrg(any(RadioSlot.class), anyString(), anyLong());
}
/**
@@ -5,12 +5,10 @@ import kst4contest.observe.DirectUiDispatcher;
import kst4contest.observe.SimpleValue;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** A Win-Test STATUS of the second station writes SECONDQRG when SO2R is on. */
@@ -18,18 +16,10 @@ class WinTestStatusSlotTest {
/* STATUS layout as parseStatus reads it: token 1 station, 5 mode, 7 frequency in 100 Hz. */
private static final String STATUS_STN2 = "STATUS: \"STN2\" \"\" 0 12 0 0 4322000 0";
private static final String STATUS_STN1 = "STATUS: \"STN1\" \"\" 0 12 0 0 1443000 0";
private final ChatController controller = mock(ChatController.class);
private final ChatPreferences prefs = mock(ChatPreferences.class);
private final List<String> first = new ArrayList<>();
private final List<String> second = new ArrayList<>();
private static SimpleValue<String> recording(List<String> writes) {
SimpleValue<String> value = new SimpleValue<>("");
value.addListener(writes::add);
return value;
}
private ReadUDPByWintestThread thread(boolean so2r) {
when(controller.getChatPreferences()).thenReturn(prefs);
when(controller.uiDispatcher()).thenReturn(new DirectUiDispatcher());
@@ -37,8 +27,6 @@ class WinTestStatusSlotTest {
when(prefs.isTrxSynch_so2rEnabled()).thenReturn(so2r);
when(prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()).thenReturn("STN1");
when(prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2()).thenReturn("STN2");
when(prefs.getMYQRGFirstCat()).thenReturn(recording(first));
when(prefs.getMYQRGSecondCat()).thenReturn(recording(second));
return new ReadUDPByWintestThread(controller, mock(ThreadStatusCallback.class));
}
@@ -46,15 +34,21 @@ class WinTestStatusSlotTest {
void withSo2rTheSecondStationWritesTheSecondQrg() {
thread(true).parseStatus(STATUS_STN2);
assertEquals(1, second.size());
assertTrue(first.isEmpty());
verify(controller).applyExternalQrg(RadioSlot.SECOND, "432.200.00", 432_200_000L);
}
@Test
void withoutSo2rTheSecondStationIsFilteredOutAsBefore() {
thread(false).parseStatus(STATUS_STN2);
assertTrue(first.isEmpty());
assertTrue(second.isEmpty());
org.mockito.Mockito.verify(controller, org.mockito.Mockito.never())
.applyExternalQrg(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyLong());
}
@Test
void withoutSo2rTheFirstStationUpdatesOnlyTheMainSlotWithExactHz() {
thread(false).parseStatus(STATUS_STN1);
verify(controller).applyExternalQrg(RadioSlot.FIRST, "144.300.00", 144_300_000L);
}
}
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
@@ -24,31 +25,60 @@ class ChatPreferencesBeaconStateTest {
preferences.bcnBeaconsEnabledMainCatProperty().addListener(mainChanges::add);
preferences.bcnBeaconsEnabledSecondCatProperty().addListener(secondChanges::add);
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(true);
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
assertEquals(List.of(false), mainChanges);
assertEquals(List.of(true, false), mainChanges);
assertEquals(List.of(true), secondChanges);
}
@Test
void explicitWritePersistsBeaconActivationButAnUnsavedChangeDoesNot() {
void activationIsSessionOnlyAndEveryFullWriteStoresFalse() throws Exception {
ChatPreferences preferences = preferences("persistence.xml");
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconsEnabledSecondCat(true);
assertTrue(preferences.writePreferencesToXmlFile());
String xml = Files.readString(tempDir.resolve("persistence.xml"));
assertTrue(xml.contains("<beaconCQEnabled>false</beaconCQEnabled>"));
assertTrue(xml.contains("<bcn_beaconsEnabledMainCat>false</bcn_beaconsEnabledMainCat>"));
assertTrue(xml.contains("<beaconCQEnabledSecondCat>false</beaconCQEnabledSecondCat>"));
assertTrue(xml.contains("<bcn_beaconsEnabledSecondCat>false</bcn_beaconsEnabledSecondCat>"));
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconsEnabledSecondCat(false);
preferences.setBcn_beaconsEnabledSecondCat(true);
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
ChatPreferences restored = preferences("persistence.xml");
restored.readPreferencesFromXmlFile();
assertFalse(restored.isBcn_beaconsEnabledMainCat());
assertTrue(restored.isBcn_beaconsEnabledSecondCat());
assertFalse(restored.isBcn_beaconsEnabledSecondCat());
}
@Test
void storedActivationIsIgnoredAndIntervalsBelowFiveAreNormalized() throws Exception {
Path file = tempDir.resolve("legacy.xml");
Files.writeString(file, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST><beaconCQ>
<beaconCQEnabled>true</beaconCQEnabled>
<beaconCQEnabledSecondCat>true</beaconCQEnabledSecondCat>
<beaconCQIntervalMinutes>1</beaconCQIntervalMinutes>
<beaconCQIntervalMinutesSecondCat>9</beaconCQIntervalMinutesSecondCat>
</beaconCQ></praktiKST>
""");
ChatPreferences restored = new ChatPreferences(file);
assertTrue(restored.readPreferencesFromXmlFile());
assertFalse(restored.isBcn_beaconsEnabledMainCat());
assertFalse(restored.isBcn_beaconsEnabledSecondCat());
assertEquals(5, restored.getBcn_beaconIntervalInMinutesMainCat());
assertEquals(9, restored.getBcn_beaconIntervalInMinutesSecondCat());
}
private ChatPreferences preferences(String fileName) {
@@ -64,6 +64,10 @@ class ChatPreferencesRoundTripTest {
Map.entry("setMessageHandling_autoAnswerEnabledSecondCat",
"Same deliberate mirroring as the second-category auto-answer text; the "
+ "main-category flag wins after every read"),
Map.entry("setBcn_beaconsEnabledMainCat",
"Session-only safety switch; every XML write stores false and every read starts false"),
Map.entry("setBcn_beaconsEnabledSecondCat",
"Session-only safety switch; every XML write stores false and every read starts false"),
Map.entry("setBcn_contestScoreSum",
"DEAD FIELD: declared with accessors only, no caller in core or app-desktop, "
+ "never written nor read")