fix: make beacons session-only, slower and QSY-aware (#171)

Forgotten beacons kept filling the chat while nobody was at the station.

- The two enable switches are session state now: they start off, are
  never read from preferences.xml and are always stored as "false", so
  neither a stored switch nor an older release reading a newer file can
  start a beacon. The in-code default was "on" and started a beacon
  whenever the file was unreadable or had no <beaconCQ> block.
- The minimum interval rises from 1 to 5 minutes; the bundled template
  ships 10 instead of 1. Smaller stored values are raised when the
  timer is scheduled.
- BeaconFrequencyGuard switches a beacon off once a logger-reported
  transceiver frequency lies more than 2 kHz from the one reported when
  the beacon was switched on. UCXLog RadioInfo and Win-Test STATUS feed
  it wherever they already set MYQRG/SECONDQRG. Without SO2R the single
  radio stands for both categories, with SO2R each radio for its own.

Manual (DE/EN), changelog, website and PROJECT_CONTEXT updated.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJ1tUJVmWChD9V5Pg7uH8X
This commit is contained in:
Claude committed 2026-11-11 09:53:27 +00:00
1 parent e5c057378f
commit 86289609e8
25 files changed
+600 -39

No files matched your search

@@ -0,0 +1,100 @@
package kst4contest.controller;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
/**
* Notices that a transceiver has left the frequency a beacon was switched on at.
*
* <p>A beacon announces where the station is calling. It keeps doing so after the
* operator has tuned away, and nothing in the chat window shows that the announced
* frequency has become wrong. This guard remembers the frequency each radio reported
* when a beacon was switched on and names the beacons whose radio has since moved by
* more than {@link #LIMIT_HZ}.</p>
*
* <p>The comparison is made against the frequency at switch-on, not against the
* previous report: tuning slowly across the band never jumps, yet it ends just as far
* away.</p>
*
* <p>Pure and free of preferences, so the rule is tested without a controller. The
* caller says which beacons are on and switches off the ones returned.</p>
*/
final class BeaconFrequencyGuard {
/** The two beacons, named after the chat category they send in. */
enum Beacon {
MAIN,
SECOND
}
/** A move of exactly this size still counts as staying on the frequency. */
static final long LIMIT_HZ = 2_000L;
private final Map<RadioSlot, Long> lastReportedHz = new EnumMap<>(RadioSlot.class);
private final Map<Beacon, Map<RadioSlot, Long>> referenceHz = new EnumMap<>(Beacon.class);
/**
* Takes the frequencies last reported as the reference of a beacon that is being
* switched on. A radio that has not reported yet contributes its first report.
*/
synchronized void arm(Beacon beacon) {
referenceHz.put(beacon, new EnumMap<>(lastReportedHz));
}
/**
* Records the frequency a radio reported and compares it with the references.
*
* <p>Without SO2R only one radio is known, so its move concerns both beacons. With
* SO2R radio 1 belongs to the main category and radio 2 to the second one, the same
* assignment MYQRG and SECONDQRG follow.</p>
*
* @param slot the radio the report belongs to
* @param frequencyHz the reported frequency
* @param so2rEnabled whether the two radios are told apart
* @param enabledBeacons the beacons that are switched on right now
* @return the enabled beacons whose radio moved beyond the limit; the caller
* switches these off
*/
synchronized Set<Beacon> report(
RadioSlot slot,
long frequencyHz,
boolean so2rEnabled,
Set<Beacon> enabledBeacons
) {
lastReportedHz.put(slot, frequencyHz);
Set<Beacon> moved = EnumSet.noneOf(Beacon.class);
for (Beacon beacon : Beacon.values()) {
if (!enabledBeacons.contains(beacon)) {
/*
* A reference only means something while its beacon is on. Dropping it
* here keeps a switch that was flipped without arm() from being judged
* against the frequency of an earlier activation.
*/
referenceHz.remove(beacon);
continue;
}
if (!watches(so2rEnabled, slot, beacon)) {
continue;
}
Long reference = referenceHz
.computeIfAbsent(beacon, unused -> new EnumMap<>(RadioSlot.class))
.putIfAbsent(slot, frequencyHz);
if (reference != null && Math.abs(frequencyHz - reference) > LIMIT_HZ) {
moved.add(beacon);
referenceHz.remove(beacon);
}
}
return moved;
}
private static boolean watches(boolean so2rEnabled, RadioSlot slot, Beacon beacon) {
if (!so2rEnabled) {
return true;
}
return (slot == RadioSlot.FIRST) == (beacon == Beacon.MAIN);
}
}
@@ -65,10 +65,15 @@ public class ChatController implements ThreadStatusCallback {
public static final String BEACON_STATUS_SOURCE = "MyBeacon";
public static final int MIN_BEACON_INTERVAL_MINUTES = 1;
/**
* Shortest interval the shared beacon timer accepts. One minute was allowed until
* issue #171: forgotten beacons at that rate flooded the chat.
*/
public static final int MIN_BEACON_INTERVAL_MINUTES = 5;
public static final int MAX_BEACON_TEXT_LENGTH = 120;
private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L;
private final BeaconStatus beaconStatus = new BeaconStatus();
private final BeaconFrequencyGuard beaconFrequencyGuard = new BeaconFrequencyGuard();
private Consumer<Double> viewRotorCallback;
@@ -3385,12 +3390,18 @@ private SimpleRoster<String>
/** Applies the main-category beacon switch to the running session immediately. */
public void setMainBeaconEnabled(boolean enabled) {
if (enabled) {
beaconFrequencyGuard.arm(BeaconFrequencyGuard.Beacon.MAIN);
}
getChatPreferences().setBcn_beaconsEnabledMainCat(enabled);
publishBeaconStatus("Beacon configuration changed.");
}
/** Applies the second-category beacon switch to the running session immediately. */
public void setSecondBeaconEnabled(boolean enabled) {
if (enabled) {
beaconFrequencyGuard.arm(BeaconFrequencyGuard.Beacon.SECOND);
}
getChatPreferences().setBcn_beaconsEnabledSecondCat(enabled);
publishBeaconStatus("Beacon configuration changed.");
}
@@ -3410,6 +3421,12 @@ private SimpleRoster<String>
&& preferences.isBcn_beaconsEnabledSecondCat());
boolean enable = !anyEnabled;
if (enable) {
beaconFrequencyGuard.arm(BeaconFrequencyGuard.Beacon.MAIN);
if (secondAvailable) {
beaconFrequencyGuard.arm(BeaconFrequencyGuard.Beacon.SECOND);
}
}
preferences.setBcn_beaconsEnabledMainCat(enable);
if (secondAvailable) {
preferences.setBcn_beaconsEnabledSecondCat(enable);
@@ -3417,6 +3434,54 @@ private SimpleRoster<String>
publishBeaconStatus("Beacon status toggled from the main window.");
}
/**
* Takes a frequency a logging program reported for one of the transceivers and
* switches off every beacon whose radio has left the frequency it was switched on
* at by more than {@link BeaconFrequencyGuard#LIMIT_HZ}.
*
* <p>A beacon keeps announcing its frequency after the operator has tuned away.
* Only reports that also update MYQRG or SECONDQRG arrive here, so a disabled
* frequency synchronisation leaves the beacons alone, and so does typing into the
* frequency field.</p>
*
* @param slot the radio the report belongs to
* @param frequencyHz the reported frequency in Hz
*/
public void onTrxFrequencyReported(RadioSlot slot, long frequencyHz) {
ChatPreferences preferences = getChatPreferences();
if (preferences == null || slot == null) {
return;
}
Set<BeaconFrequencyGuard.Beacon> enabled =
EnumSet.noneOf(BeaconFrequencyGuard.Beacon.class);
if (preferences.isBcn_beaconsEnabledMainCat()) {
enabled.add(BeaconFrequencyGuard.Beacon.MAIN);
}
if (preferences.isBcn_beaconsEnabledSecondCat()) {
enabled.add(BeaconFrequencyGuard.Beacon.SECOND);
}
Set<BeaconFrequencyGuard.Beacon> moved = beaconFrequencyGuard.report(
slot,
frequencyHz,
preferences.isTrxSynch_so2rEnabled(),
enabled
);
if (moved.isEmpty()) {
return;
}
if (moved.contains(BeaconFrequencyGuard.Beacon.MAIN)) {
preferences.setBcn_beaconsEnabledMainCat(false);
}
if (moved.contains(BeaconFrequencyGuard.Beacon.SECOND)) {
preferences.setBcn_beaconsEnabledSecondCat(false);
}
publishBeaconStatus(
"Beacon switched off: the transceiver moved by more than 2 kHz.");
}
/** Publishes the loaded state without changing either category switch. */
public void refreshBeaconStatus() {
publishBeaconStatus("Beacon configuration loaded.");
@@ -535,6 +535,7 @@ public class ReadUDPByWintestThread extends Thread {
* interpreted as frequencies.
*/
String formattedPassQrg = null;
long usablePassFrequencyRaw = 0L;
if (parts.size() > 11) {
try {
@@ -548,6 +549,7 @@ public class ReadUDPByWintestThread extends Thread {
helper_formatWinTestFrequency(
passFrequencyRaw
);
usablePassFrequencyRaw = passFrequencyRaw;
}
} catch (IllegalArgumentException
| ArithmeticException ignored) {
@@ -569,7 +571,18 @@ public class ReadUDPByWintestThread extends Thread {
if (prefs.isLogsynch_wintestQrgSyncEnabled()) {
RadioSlot target = slot.get();
client.uiDispatcher().runOnUi(() -> target.qrgIn(prefs).set(qrgToSet));
/*
* The beacon guard is told the frequency that is shown. STATUS
* counts in 100 Hz.
*/
final long shownFrequencyHz = 100L
* (usePassQrg && formattedPassQrg != null
? usablePassFrequencyRaw
: mainFrequencyRaw);
client.uiDispatcher().runOnUi(() -> {
target.qrgIn(prefs).set(qrgToSet);
client.onTrxFrequencyReported(target, shownFrequencyHz);
});
}
System.out.println(
@@ -463,9 +463,13 @@ public class ReadUDPbyUCXMessageThread extends Thread {
final String finalFormattedQRG = formattedQRG;
final RadioSlot target = slot.get();
helper_runOnFxThread(() ->
target.qrgIn(this.client.getChatPreferences()).set(finalFormattedQRG)
);
final Long reportedHz = helper_radioInfoFrequencyHz(qrg);
helper_runOnFxThread(() -> {
target.qrgIn(this.client.getChatPreferences()).set(finalFormattedQRG);
if (reportedHz != null) {
this.client.onTrxFrequencyReported(target, reportedHz);
}
});
// System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG);
}
@@ -535,6 +539,21 @@ public class ReadUDPbyUCXMessageThread extends Thread {
}
/**
* Reads the {@code Freq} of a RadioInfo, which counts in 10 Hz, as Hz.
*
* @return the frequency, or {@code null} when the text is no usable number; the
* text is then still shown as received, but nothing can be compared with it
*/
private static Long helper_radioInfoFrequencyHz(String radioInfoFreq) {
try {
long tensOfHz = Long.parseLong(radioInfoFreq.trim());
return tensOfHz > 0 ? Math.multiplyExact(tensOfHz, 10L) : null;
} catch (NumberFormatException | ArithmeticException unusable) {
return null;
}
}
/**
* Runs UI-bound changes on the JavaFX application thread.
*
@@ -78,6 +78,9 @@ public class ChatPreferences {
*/
public static final String PREFERENCE_RESOURCE = "/praktiKSTpreferences.xml";
/** What the file stores for a beacon switch, whatever the running session has set. */
private static final String BEACON_SWITCH_AS_STORED = "false";
/**
@@ -366,7 +369,14 @@ public class ChatPreferences {
* Beacon prefs
*/
MutableValue<Boolean> bcn_beaconsEnabledMainCat = new SimpleValue<>(true);
/*
* The two switches are state of the running session and nothing else: they start
* off, the preferences file is never read into them and always stores "false".
* A stored switch started beacons on every program start that nobody had asked
* for in that session, and the former default of "on" started one whenever the
* file was unreadable (issue #171).
*/
MutableValue<Boolean> bcn_beaconsEnabledMainCat = new SimpleValue<>(false);
MutableValue<Boolean> bcn_beaconsEnabledSecondCat = new SimpleValue<>(false);
int bcn_beaconIntervalInMinutesMainCat = 20;
int bcn_beaconIntervalInMinutesSecondCat = 20;
@@ -2197,12 +2207,17 @@ public class ChatPreferences {
bcn_beaconIntervalInMinutesMainCat.setTextContent(this.getBcn_beaconIntervalInMinutesMainCat()+"");
beaconCQ.appendChild(bcn_beaconIntervalInMinutesMainCat);
/*
* The four switch elements stay in the file so that its layout does not
* change, but they always say "false": a release that still reads them
* must not start a beacon from a file this one has written.
*/
Element beaconCQEnabled = doc.createElement("beaconCQEnabled");
beaconCQEnabled.setTextContent(this.isBcn_beaconsEnabledMainCat()+"");
beaconCQEnabled.setTextContent(BEACON_SWITCH_AS_STORED);
beaconCQ.appendChild(beaconCQEnabled);
Element bcn_beaconsEnabledMainCat = doc.createElement("bcn_beaconsEnabledMainCat");
bcn_beaconsEnabledMainCat.setTextContent(this.isBcn_beaconsEnabledMainCat()+"");
bcn_beaconsEnabledMainCat.setTextContent(BEACON_SWITCH_AS_STORED);
beaconCQ.appendChild(bcn_beaconsEnabledMainCat);
@@ -2223,11 +2238,11 @@ public class ChatPreferences {
beaconCQ.appendChild(bcn_beaconIntervalInMinutesSecondCat);
Element beaconCQEnabledSecondCat = doc.createElement("beaconCQEnabledSecondCat");
beaconCQEnabledSecondCat.setTextContent(this.isBcn_beaconsEnabledSecondCat()+"");
beaconCQEnabledSecondCat.setTextContent(BEACON_SWITCH_AS_STORED);
beaconCQ.appendChild(beaconCQEnabledSecondCat);
Element bcn_beaconsEnabledSecondCat = doc.createElement("bcn_beaconsEnabledSecondCat");
bcn_beaconsEnabledSecondCat.setTextContent(this.isBcn_beaconsEnabledSecondCat()+"");
bcn_beaconsEnabledSecondCat.setTextContent(BEACON_SWITCH_AS_STORED);
beaconCQ.appendChild(bcn_beaconsEnabledSecondCat);
/**
@@ -3178,11 +3193,14 @@ public class ChatPreferences {
Element beaconCQEl = getFirstElement(doc, "beaconCQ");
if (beaconCQEl != null) {
setBcn_beaconsEnabledMainCat(getBoolean(beaconCQEl, isBcn_beaconsEnabledMainCat(), "bcn_beaconsEnabledMainCat", "beaconCQEnabled"));
/*
* The enable switches of both categories are deliberately not read. They
* belong to the running session, so a file of an earlier release cannot
* switch a beacon on and reading again cannot switch a running one off.
*/
bcn_beaconIntervalInMinutesMainCat = 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");
bcn_beaconTextSecondCat = getText(beaconCQEl, bcn_beaconTextSecondCat, "bcn_beaconTextSecondCat", "beaconCQTextSecondText");
@@ -112,10 +112,10 @@
</textSnippets>
<beaconCQ>
<beaconCQText>_.~'"´"'~.__.~'´ CQ CQ CQ NAC de MYCALL, pse try 144.317 for JO54 `'~.__.~'"`"'~._</beaconCQText>
<beaconCQIntervalMinutes>1</beaconCQIntervalMinutes>
<beaconCQIntervalMinutes>10</beaconCQIntervalMinutes>
<beaconCQEnabled>false</beaconCQEnabled>
<beaconCQTextSecondText>_.~'"´"'~.__.~'´ CQ CQ CQ de MYCALL, pse sked or SECONDQRG `'~.__.~'"`"'~._</beaconCQTextSecondText>
<beaconCQIntervalMinutesSecondCat>3</beaconCQIntervalMinutesSecondCat>
<beaconCQIntervalMinutesSecondCat>10</beaconCQIntervalMinutesSecondCat>
<beaconCQEnabledSecondCat>false</beaconCQEnabledSecondCat>
</beaconCQ>
<beaconUnworkedstations>
@@ -86,6 +86,85 @@ class BeaconControlTest {
assertLastDescription("1/2");
}
@Test
void aTransceiverMoveBeyondTwoKilohertzSwitchesTheBeaconsOffAndSaysWhy() {
preferences.setLoginToSecondChatEnabled(true);
controller.onTrxFrequencyReported(RadioSlot.FIRST, 144_300_000L);
controller.setMainBeaconEnabled(true);
controller.setSecondBeaconEnabled(true);
controller.onTrxFrequencyReported(RadioSlot.FIRST, 144_301_500L);
assertTrue(preferences.isBcn_beaconsEnabledMainCat(), "1.5 kHz is within the limit");
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
controller.onTrxFrequencyReported(RadioSlot.FIRST, 144_302_500L);
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
assertFalse(preferences.isBcn_beaconsEnabledSecondCat(),
"without SO2R the one reported radio stands for both categories");
assertLastDescription("off");
assertTrue(lastMessage().getRunningInformation().contains("2 kHz"),
lastMessage().getRunningInformation());
}
@Test
void withSo2rARadioSwitchesOnlyTheBeaconOfItsOwnCategoryOff() {
preferences.setLoginToSecondChatEnabled(true);
preferences.setTrxSynch_so2rEnabled(true);
controller.onTrxFrequencyReported(RadioSlot.FIRST, 144_300_000L);
controller.onTrxFrequencyReported(RadioSlot.SECOND, 432_200_000L);
controller.toggleUsableBeacons();
controller.onTrxFrequencyReported(RadioSlot.SECOND, 432_250_000L);
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
assertFalse(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("1/2");
}
@Test
void aBeaconSwitchedOnAgainAfterTheMoveStaysOnAtTheNewFrequency() {
controller.onTrxFrequencyReported(RadioSlot.FIRST, 144_300_000L);
controller.setMainBeaconEnabled(true);
controller.onTrxFrequencyReported(RadioSlot.FIRST, 144_350_000L);
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
controller.toggleUsableBeacons();
controller.onTrxFrequencyReported(RadioSlot.FIRST, 144_350_500L);
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
}
@Test
void aTransceiverMoveWhileNoBeaconIsOnPublishesNothing() {
controller.onTrxFrequencyReported(RadioSlot.FIRST, 144_300_000L);
controller.onTrxFrequencyReported(RadioSlot.FIRST, 432_200_000L);
org.mockito.Mockito.verifyNoInteractions(listener);
}
/** Stored values of earlier releases may lie below the minimum of five minutes. */
@Test
void aStoredIntervalBelowTheMinimumIsRaisedWhenTheTimerStarts() {
preferences.setBcn_beaconIntervalInMinutesMainCat(1);
preferences.setBcn_beaconIntervalInMinutesSecondCat(3);
controller.onOn4KstConnectionOnline();
controller.onOn4KstConnectionLost();
assertEquals(5, ChatController.MIN_BEACON_INTERVAL_MINUTES);
assertEquals(5, preferences.getBcn_beaconIntervalInMinutesMainCat());
assertEquals(5, preferences.getBcn_beaconIntervalInMinutesSecondCat());
}
private ThreadStateMessage lastMessage() {
ArgumentCaptor<ThreadStateMessage> message = ArgumentCaptor.forClass(ThreadStateMessage.class);
org.mockito.Mockito.verify(listener, org.mockito.Mockito.atLeastOnce())
.onThreadStatusChanged(org.mockito.ArgumentMatchers.anyString(), message.capture());
return message.getValue();
}
private void assertLastDescription(String expected) {
ArgumentCaptor<String> key = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<ThreadStateMessage> message = ArgumentCaptor.forClass(ThreadStateMessage.class);
@@ -0,0 +1,107 @@
package kst4contest.controller;
import static kst4contest.controller.BeaconFrequencyGuard.Beacon.MAIN;
import static kst4contest.controller.BeaconFrequencyGuard.Beacon.SECOND;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.EnumSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
import kst4contest.controller.BeaconFrequencyGuard.Beacon;
class BeaconFrequencyGuardTest {
private static final long QRG = 144_300_000L;
private static final Set<Beacon> NONE = EnumSet.noneOf(Beacon.class);
private static final Set<Beacon> BOTH = EnumSet.allOf(Beacon.class);
private final BeaconFrequencyGuard guard = new BeaconFrequencyGuard();
@Test
void aMoveOfExactlyTheLimitKeepsTheBeaconAndOneStepMoreSwitchesItOff() {
guard.report(RadioSlot.FIRST, QRG, false, NONE);
guard.arm(MAIN);
assertEquals(NONE, guard.report(RadioSlot.FIRST, QRG + 2_000, false, EnumSet.of(MAIN)));
assertEquals(EnumSet.of(MAIN), guard.report(RadioSlot.FIRST, QRG + 2_010, false, EnumSet.of(MAIN)));
}
@Test
void aMoveDownwardsCountsLikeAMoveUpwards() {
guard.report(RadioSlot.FIRST, QRG, false, NONE);
guard.arm(MAIN);
assertEquals(EnumSet.of(MAIN), guard.report(RadioSlot.FIRST, QRG - 2_010, false, EnumSet.of(MAIN)));
}
/** Tuning slowly across the band never jumps, so the reference must not follow the radio. */
@Test
void smallStepsAddUpAgainstTheFrequencyTheBeaconWasSwitchedOnAt() {
guard.report(RadioSlot.FIRST, QRG, false, NONE);
guard.arm(MAIN);
assertEquals(NONE, guard.report(RadioSlot.FIRST, QRG + 900, false, EnumSet.of(MAIN)));
assertEquals(NONE, guard.report(RadioSlot.FIRST, QRG + 1_800, false, EnumSet.of(MAIN)));
assertEquals(EnumSet.of(MAIN), guard.report(RadioSlot.FIRST, QRG + 2_700, false, EnumSet.of(MAIN)));
}
@Test
void switchingABeaconOnAgainTakesTheCurrentFrequencyAsTheNewReference() {
guard.report(RadioSlot.FIRST, QRG, false, NONE);
guard.arm(MAIN);
guard.report(RadioSlot.FIRST, QRG + 50_000, false, EnumSet.of(MAIN));
guard.arm(MAIN);
assertEquals(NONE, guard.report(RadioSlot.FIRST, QRG + 50_500, false, EnumSet.of(MAIN)));
}
@Test
void aBeaconSwitchedOnBeforeAnyReportTakesTheFirstReportAsItsReference() {
guard.arm(MAIN);
assertEquals(NONE, guard.report(RadioSlot.FIRST, QRG, false, EnumSet.of(MAIN)));
assertEquals(EnumSet.of(MAIN), guard.report(RadioSlot.FIRST, QRG + 5_000, false, EnumSet.of(MAIN)));
}
/** A switch that was flipped without arm() must not be judged against a stale reference. */
@Test
void aReferenceIsForgottenWhileItsBeaconIsOff() {
guard.report(RadioSlot.FIRST, QRG, false, NONE);
guard.arm(MAIN);
guard.report(RadioSlot.FIRST, QRG + 50_000, false, NONE);
assertEquals(NONE, guard.report(RadioSlot.FIRST, QRG + 50_100, false, EnumSet.of(MAIN)));
}
@Test
void withoutSo2rTheOneRadioSwitchesBothBeaconsOff() {
guard.report(RadioSlot.FIRST, QRG, false, NONE);
guard.arm(MAIN);
guard.arm(SECOND);
assertEquals(BOTH, guard.report(RadioSlot.FIRST, QRG + 10_000, false, BOTH));
}
@Test
void withSo2rEachRadioSwitchesOnlyTheBeaconOfItsOwnCategory() {
guard.report(RadioSlot.FIRST, QRG, true, NONE);
guard.report(RadioSlot.SECOND, 432_200_000L, true, NONE);
guard.arm(MAIN);
guard.arm(SECOND);
assertEquals(EnumSet.of(SECOND), guard.report(RadioSlot.SECOND, 432_250_000L, true, BOTH));
assertEquals(NONE, guard.report(RadioSlot.FIRST, QRG + 500, true, EnumSet.of(MAIN)));
assertEquals(EnumSet.of(MAIN), guard.report(RadioSlot.FIRST, QRG + 10_000, true, EnumSet.of(MAIN)));
}
@Test
void aBeaconThatIsOffIsNeverReported() {
guard.report(RadioSlot.FIRST, QRG, false, NONE);
guard.arm(MAIN);
assertEquals(NONE, guard.report(RadioSlot.FIRST, QRG + 10_000, false, NONE));
}
}
@@ -310,6 +310,18 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
assertEquals(List.of("1296.200.00"), firstWrites, "only the marker may write MYQRG");
}
/** RadioInfo counts in 10 Hz; the beacon guard of issue #171 is told in Hz. */
@Test
void aRadioInfoReportsTheTransceiverFrequencyOfItsRadioToTheBeaconGuard(@TempDir Path tempDir) throws Exception {
givenRadioInfoMarkerEnabled();
givenSo2r(true);
sendThenAwaitMarker("<RadioInfo><RadioNr>2</RadioNr><Freq>43220000</Freq><Mode>SSB</Mode></RadioInfo>");
org.mockito.Mockito.verify(controller).onTrxFrequencyReported(RadioSlot.SECOND, 432_200_000L);
org.mockito.Mockito.verify(controller).onTrxFrequencyReported(RadioSlot.FIRST, 1_296_200_000L);
}
@Test
void withSo2rARadioInfoWithoutRadioNumberIsRadioOne(@TempDir Path tempDir) throws Exception {
givenRadioInfoMarkerEnabled();
@@ -10,7 +10,11 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
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. */
@@ -50,6 +54,24 @@ class WinTestStatusSlotTest {
assertTrue(first.isEmpty());
}
/** STATUS counts in 100 Hz; the beacon guard of issue #171 is told in Hz. */
@Test
void aStatusReportsTheTransceiverFrequencyOfItsRadioToTheBeaconGuard() {
thread(true).parseStatus(STATUS_STN2);
verify(controller).onTrxFrequencyReported(RadioSlot.SECOND, 432_200_000L);
}
@Test
void withQrgSyncSwitchedOffAStatusReportsNothingToTheBeaconGuard() {
ReadUDPByWintestThread thread = thread(true);
when(prefs.isLogsynch_wintestQrgSyncEnabled()).thenReturn(false);
thread.parseStatus(STATUS_STN2);
verify(controller, never()).onTrxFrequencyReported(any(), anyLong());
}
@Test
void withoutSo2rTheSecondStationIsFilteredOutAsBefore() {
thread(false).parseStatus(STATUS_STN2);
@@ -4,6 +4,9 @@ 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.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@@ -24,31 +27,93 @@ class ChatPreferencesBeaconStateTest {
preferences.bcnBeaconsEnabledMainCatProperty().addListener(mainChanges::add);
preferences.bcnBeaconsEnabledSecondCatProperty().addListener(secondChanges::add);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconsEnabledSecondCat(true);
preferences.setBcn_beaconsEnabledMainCat(false);
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() {
ChatPreferences preferences = preferences("persistence.xml");
preferences.setBcn_beaconsEnabledMainCat(false);
void aNewPreferencesObjectStartsWithBothBeaconsOff() {
ChatPreferences preferences = preferences("fresh.xml");
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
assertFalse(preferences.isBcn_beaconsEnabledSecondCat());
}
/** Issue #171: a beacon has to be switched on by the operator in every session. */
@Test
void beaconActivationIsNeitherWrittenToTheFileNorRestoredFromIt() throws IOException {
ChatPreferences preferences = preferences("session-only.xml");
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconsEnabledSecondCat(true);
assertTrue(preferences.writePreferencesToXmlFile());
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconsEnabledSecondCat(false);
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
String written = Files.readString(tempDir.resolve("session-only.xml"));
assertTrue(written.contains("<bcn_beaconsEnabledMainCat>false</bcn_beaconsEnabledMainCat>"), written);
assertTrue(written.contains("<beaconCQEnabled>false</beaconCQEnabled>"), written);
assertTrue(written.contains("<bcn_beaconsEnabledSecondCat>false</bcn_beaconsEnabledSecondCat>"), written);
assertTrue(written.contains("<beaconCQEnabledSecondCat>false</beaconCQEnabledSecondCat>"), written);
ChatPreferences restored = preferences("persistence.xml");
ChatPreferences restored = preferences("session-only.xml");
restored.readPreferencesFromXmlFile();
assertFalse(restored.isBcn_beaconsEnabledMainCat());
assertTrue(restored.isBcn_beaconsEnabledSecondCat());
assertFalse(restored.isBcn_beaconsEnabledSecondCat());
}
/** Files of earlier releases carry the stored switch; it must not start a beacon any more. */
@Test
void aFileOfAnEarlierReleaseWithEnabledBeaconsLoadsThemOffAndKeepsTextAndInterval() throws IOException {
Files.writeString(tempDir.resolve("legacy.xml"),
"<praktiKST><beaconCQ>"
+ "<beaconCQText>CQ LEGACY</beaconCQText>"
+ "<beaconCQIntervalMinutes>15</beaconCQIntervalMinutes>"
+ "<beaconCQEnabled>true</beaconCQEnabled>"
+ "<bcn_beaconsEnabledMainCat>true</bcn_beaconsEnabledMainCat>"
+ "<beaconCQEnabledSecondCat>true</beaconCQEnabledSecondCat>"
+ "<bcn_beaconsEnabledSecondCat>true</bcn_beaconsEnabledSecondCat>"
+ "</beaconCQ></praktiKST>");
ChatPreferences restored = preferences("legacy.xml");
restored.readPreferencesFromXmlFile();
assertFalse(restored.isBcn_beaconsEnabledMainCat());
assertFalse(restored.isBcn_beaconsEnabledSecondCat());
assertEquals("CQ LEGACY", restored.getBcn_beaconTextMainCat());
assertEquals(15, restored.getBcn_beaconIntervalInMinutesMainCat());
}
/** The switch is session state: reading the file again must not end a running beacon. */
@Test
void readingTheFileAgainLeavesABeaconOfTheRunningSessionOn() {
ChatPreferences preferences = preferences("reread.xml");
assertTrue(preferences.writePreferencesToXmlFile());
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.readPreferencesFromXmlFile();
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
}
/** The template seeds every first installation and every new operator profile. */
@Test
void theBundledTemplateShipsBothBeaconsOffAndAnIntervalAboveTheMinimum() throws IOException {
try (InputStream template = ChatPreferences.class.getResourceAsStream(ChatPreferences.PREFERENCE_RESOURCE)) {
Files.copy(template, tempDir.resolve("template.xml"));
}
ChatPreferences seeded = preferences("template.xml");
seeded.readPreferencesFromXmlFile();
assertFalse(seeded.isBcn_beaconsEnabledMainCat());
assertFalse(seeded.isBcn_beaconsEnabledSecondCat());
assertEquals(10, seeded.getBcn_beaconIntervalInMinutesMainCat());
assertEquals(10, seeded.getBcn_beaconIntervalInMinutesSecondCat());
}
private ChatPreferences preferences(String fileName) {
@@ -64,6 +64,13 @@ 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 state since issue #171: the file always stores 'false' and "
+ "readPreferencesFromXmlFile never reads the switch, so a beacon has "
+ "to be switched on in every session (see the comment above "
+ "'bcn_beaconsEnabledMainCat')"),
Map.entry("setBcn_beaconsEnabledSecondCat",
"Session state like the main-category beacon switch"),
Map.entry("setBcn_contestScoreSum",
"DEAD FIELD: declared with accessors only, no caller in core or app-desktop, "
+ "never written nor read")