mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-11-15 00:52:31 +01:00
Harden beacon sessions and mark automatic messages
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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));
|
||||
}
|
||||
|
||||
+7
-7
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user