feat: improve manual QTF heading control

This commit is contained in:
Marc Froehlich committed 2026-10-24 21:39:17 +02:00
1 parent 4be6c178f8
commit 1cda9b4ad9
29 files changed
+663 -19

No files matched your search

@@ -57,11 +57,13 @@ public class ChatPreferences {
* only use is a best-effort warning when a file is newer than the application. Release
* 1.50 added the two palette-override elements and deliberately left this at 7. A bump
* that no reader acts on only invites the next person to bump it again.
* Version 9 marks the manual-QTF-heading opt-out migration. A missing opt-out marker
* enables the feature, and a subsequently written marker preserves the user's decision.
* <p>
* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults.
*/
// private static final int CONFIG_VERSION = 2;
public static final int CONFIG_VERSION = 8;
public static final int CONFIG_VERSION = 9;
// Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility.
private static final String TAG_CONFIG_VERSION = "configVersion";
@@ -202,6 +204,7 @@ public class ChatPreferences {
int stn_on4kstServersPort = 23001;
boolean stn_pstRotatorEnabled = false;
boolean stn_qtfFilterDirectionAsAntennaHeadingOptedOut;
String stn_pstRotatorHost = "127.0.0.1";
int stn_pstRotatorPort = 12000;
boolean stn_pstRotator2Enabled = false;
@@ -259,6 +262,7 @@ public class ChatPreferences {
boolean loginToSecondChatEnabled;
MutableValue<Double> actualQTF = new SimpleValue<>(360.0); // will be updated by user at runtime!
MutableValue<Double> actualQTF2 = new SimpleValue<>(Double.NaN); // heading of rotator 2 (SO2R), NaN when unknown
private boolean actualQtfInitializedFromPreferences;
boolean stn_bandActive50;
boolean stn_bandActive70;
@@ -1052,6 +1056,20 @@ public class ChatPreferences {
this.stn_pstRotatorEnabled = stn_pstRotatorEnabled;
}
public boolean isStn_useQtfFilterDirectionAsAntennaHeading() {
return !stn_qtfFilterDirectionAsAntennaHeadingOptedOut;
}
public void setStn_useQtfFilterDirectionAsAntennaHeading(
boolean stn_useQtfFilterDirectionAsAntennaHeading) {
this.stn_qtfFilterDirectionAsAntennaHeadingOptedOut =
!stn_useQtfFilterDirectionAsAntennaHeading;
}
public boolean isQtfFilterDirectionAsAntennaHeadingEnabled() {
return !stn_pstRotatorEnabled && !stn_qtfFilterDirectionAsAntennaHeadingOptedOut;
}
public String getStn_pstRotatorHost() {
return stn_pstRotatorHost;
}
@@ -1224,7 +1242,9 @@ public class ChatPreferences {
}
public void setStn_qtfDefault(double stn_qtfDefault) {
this.stn_qtfDefault = stn_qtfDefault;
if (Double.isFinite(stn_qtfDefault) && stn_qtfDefault >= 0.0 && stn_qtfDefault <= 360.0) {
this.stn_qtfDefault = stn_qtfDefault;
}
}
public boolean isStn_loginAFKState() {
@@ -1742,6 +1762,12 @@ public class ChatPreferences {
stn_qtfDefault.setTextContent(this.stn_qtfDefault+"");
station.appendChild(stn_qtfDefault);
Element stn_qtfFilterDirectionAsAntennaHeadingOptedOut =
doc.createElement("stn_qtfFilterDirectionAsAntennaHeadingOptedOut");
stn_qtfFilterDirectionAsAntennaHeadingOptedOut.setTextContent(
Boolean.toString(this.stn_qtfFilterDirectionAsAntennaHeadingOptedOut));
station.appendChild(stn_qtfFilterDirectionAsAntennaHeadingOptedOut);
Element stn_pathAnalysisOwnAntennaHeightMeters = doc.createElement("stn_pathAnalysisOwnAntennaHeightMeters");
stn_pathAnalysisOwnAntennaHeightMeters.setTextContent(this.stn_pathAnalysisOwnAntennaHeightMeters + "");
station.appendChild(stn_pathAnalysisOwnAntennaHeightMeters);
@@ -2628,7 +2654,7 @@ public class ChatPreferences {
stn_antennaBeamWidthDeg = getDouble(stationEl, stn_antennaBeamWidthDeg, "stn_antennaBeamWidthDeg");
stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault");
stn_qtfDefault = getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault");
setStn_qtfDefault(getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault"));
stn_pathAnalysisOwnAntennaHeightMeters = getDouble(
stationEl,
@@ -2738,6 +2764,16 @@ public class ChatPreferences {
stn_bandActive10G = getBoolean(stationEl, stn_bandActive10G, "stn_bandActive10G");
stn_pstRotatorEnabled = getBoolean(stationEl, stn_pstRotatorEnabled, "stn_pstRotatorEnabled");
/*
* The positive version-8 setting cannot distinguish an explicit false from its old
* default. Absence of the opt-out marker therefore performs the approved migration
* to enabled; once written, the marker preserves every later user decision.
*/
stn_qtfFilterDirectionAsAntennaHeadingOptedOut = getBoolean(
stationEl,
false,
"stn_qtfFilterDirectionAsAntennaHeadingOptedOut"
);
setStn_pstRotatorHost(
getText(
@@ -3411,10 +3447,18 @@ public class ChatPreferences {
System.out.println(e.getMessage());
}
initializeActualQtfFromDefaultIfRequired();
return true;
}
private void initializeActualQtfFromDefaultIfRequired() {
if (!actualQtfInitializedFromPreferences) {
actualQTF.set(stn_qtfDefault);
actualQtfInitializedFromPreferences = true;
}
}
/**
*
* @return double array with separated values for each doublevalue, seperated by ";", empty array if none
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<praktiKST>
<configVersion>8</configVersion>
<configVersion>9</configVersion>
<station>
<LoginCallSign>DO5AMF</LoginCallSign>
<LoginPassword>changeMe</LoginPassword>
@@ -13,6 +13,7 @@
<stn_antennaBeamWidthDeg>60.0</stn_antennaBeamWidthDeg>
<stn_maxQRBDefault>1333.0</stn_maxQRBDefault>
<stn_qtfDefault>135.0</stn_qtfDefault>
<stn_qtfFilterDirectionAsAntennaHeadingOptedOut>false</stn_qtfFilterDirectionAsAntennaHeadingOptedOut>
<stn_bandActive144>true</stn_bandActive144>
<stn_bandActive432>true</stn_bandActive432>
<stn_bandActive1240>true</stn_bandActive1240>
@@ -0,0 +1,195 @@
package kst4contest.model;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.nio.file.Path;
import java.nio.file.Files;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatPreferencesQtfHeadingTest {
@TempDir
Path directory;
@Test
void loadedQtfDefaultInitializesTheRuntimeHeadingOnce() {
Path file = directory.resolve("preferences.xml");
ChatPreferences written = new ChatPreferences(file);
written.setStn_qtfDefault(247.5);
assertTrue(written.writePreferencesToXmlFile());
ChatPreferences restored = new ChatPreferences(file);
assertTrue(restored.readPreferencesFromXmlFile());
assertEquals(247.5, restored.getActualQTF().get(), 0.0001);
restored.getActualQTF().set(91.0);
assertTrue(restored.readPreferencesFromXmlFile());
assertEquals(91.0, restored.getActualQTF().get(), 0.0001,
"reloading preferences overwrote a later runtime rotor heading");
}
@Test
void eachProfileStartsWithItsOwnQtfDefault() {
Path rootFile = directory.resolve("preferences.xml");
Path operatorFile = directory.resolve("profiles/OP2/preferences.xml");
writeQtfDefault(rootFile, 135.0);
writeQtfDefault(operatorFile, 302.0);
ChatPreferences root = read(rootFile);
ChatPreferences operator = read(operatorFile);
assertEquals(135.0, root.getActualQTF().get(), 0.0001);
assertEquals(302.0, operator.getActualQTF().get(), 0.0001);
}
@Test
void qtfFilterHeadingSynchronizationDefaultsToEnabledForRootAndOperatorProfiles() {
ChatPreferences root = new ChatPreferences(directory.resolve("preferences.xml"));
ChatPreferences operator = new ChatPreferences(directory.resolve("profiles/OP2/preferences.xml"));
assertTrue(root.isStn_useQtfFilterDirectionAsAntennaHeading());
assertTrue(root.isQtfFilterDirectionAsAntennaHeadingEnabled());
assertTrue(operator.isStn_useQtfFilterDirectionAsAntennaHeading());
assertTrue(operator.isQtfFilterDirectionAsAntennaHeadingEnabled());
}
@Test
void primaryRotatorOnlySuspendsTheStoredManualHeadingPreference() {
ChatPreferences preferences = new ChatPreferences(directory.resolve("preferences.xml"));
preferences.setStn_pstRotatorEnabled(true);
assertTrue(preferences.isStn_useQtfFilterDirectionAsAntennaHeading());
assertFalse(preferences.isQtfFilterDirectionAsAntennaHeadingEnabled());
preferences.setStn_pstRotatorEnabled(false);
assertTrue(preferences.isStn_useQtfFilterDirectionAsAntennaHeading());
assertTrue(preferences.isQtfFilterDirectionAsAntennaHeadingEnabled());
}
@Test
void versionEightProfilesMigrateToTheEnabledDefault() throws IOException {
Path file = directory.resolve("preferences.xml");
Files.writeString(file, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>8</configVersion>
<station>
<stn_pstRotatorEnabled>false</stn_pstRotatorEnabled>
<stn_useQtfFilterDirectionAsAntennaHeading>false</stn_useQtfFilterDirectionAsAntennaHeading>
</station>
</praktiKST>
""");
ChatPreferences migrated = read(file);
assertTrue(migrated.isStn_useQtfFilterDirectionAsAntennaHeading());
assertTrue(migrated.isQtfFilterDirectionAsAntennaHeadingEnabled());
assertTrue(migrated.writePreferencesToXmlFile());
String migratedXml = Files.readString(file);
assertTrue(migratedXml.contains("<configVersion>9</configVersion>"));
assertTrue(migratedXml.contains(
"<stn_qtfFilterDirectionAsAntennaHeadingOptedOut>false"));
assertTrue(read(file).isQtfFilterDirectionAsAntennaHeadingEnabled());
}
@Test
void explicitOptOutSurvivesRestartAndPrimaryRotatorChanges() throws IOException {
Path file = directory.resolve("preferences.xml");
ChatPreferences preferences = new ChatPreferences(file);
preferences.setStn_useQtfFilterDirectionAsAntennaHeading(false);
assertTrue(preferences.writePreferencesToXmlFile());
ChatPreferences restored = read(file);
assertFalse(restored.isStn_useQtfFilterDirectionAsAntennaHeading());
assertFalse(restored.isQtfFilterDirectionAsAntennaHeadingEnabled());
assertTrue(Files.readString(file).contains(
"<stn_qtfFilterDirectionAsAntennaHeadingOptedOut>true"));
restored.setStn_pstRotatorEnabled(true);
restored.setStn_pstRotatorEnabled(false);
assertFalse(restored.isStn_useQtfFilterDirectionAsAntennaHeading());
assertFalse(restored.isQtfFilterDirectionAsAntennaHeadingEnabled());
}
@Test
void profilesKeepIndependentManualHeadingDecisions() {
Path rootFile = directory.resolve("preferences.xml");
Path operatorFile = directory.resolve("profiles/OP2/preferences.xml");
ChatPreferences root = new ChatPreferences(rootFile);
root.setStn_useQtfFilterDirectionAsAntennaHeading(false);
assertTrue(root.writePreferencesToXmlFile());
ChatPreferences operator = new ChatPreferences(operatorFile);
assertTrue(operator.writePreferencesToXmlFile());
assertFalse(read(rootFile).isStn_useQtfFilterDirectionAsAntennaHeading());
assertTrue(read(operatorFile).isStn_useQtfFilterDirectionAsAntennaHeading());
}
@Test
void laterRotorHeadingReplacesTheLoadedStartValue() {
Path file = directory.resolve("preferences.xml");
ChatPreferences written = new ChatPreferences(file);
written.setStn_qtfDefault(188.0);
written.setStn_pstRotatorEnabled(true);
assertTrue(written.writePreferencesToXmlFile());
ChatPreferences restored = read(file);
restored.getActualQTF().set(271.25);
assertEquals(271.25, restored.getActualQTF().get(), 0.0001);
assertEquals(188.0, restored.getStn_qtfDefault(), 0.0001,
"a runtime heading changed the persisted start direction");
}
@ParameterizedTest
@ValueSource(strings = {"NaN", "Infinity", "-0.1", "360.1"})
void invalidStoredQtfDefaultsAreIgnored(String invalidValue) throws IOException {
Path file = directory.resolve("preferences.xml");
ChatPreferences written = new ChatPreferences(file);
written.setStn_qtfDefault(247.5);
assertTrue(written.writePreferencesToXmlFile());
Files.writeString(file, Files.readString(file).replace(
"<stn_qtfDefault>247.5</stn_qtfDefault>",
"<stn_qtfDefault>" + invalidValue + "</stn_qtfDefault>"));
ChatPreferences restored = read(file);
assertEquals(135.0, restored.getStn_qtfDefault(), 0.0001);
assertEquals(135.0, restored.getActualQTF().get(), 0.0001);
}
@ParameterizedTest
@ValueSource(strings = {"NaN", "Infinity", "-Infinity", "-0.1", "360.1"})
void invalidQtfDefaultsCannotReplaceAValidValue(String invalidValue) {
ChatPreferences preferences = new ChatPreferences(directory.resolve("preferences.xml"));
preferences.setStn_qtfDefault(247.5);
preferences.setStn_qtfDefault(Double.parseDouble(invalidValue));
assertEquals(247.5, preferences.getStn_qtfDefault(), 0.0001);
}
private void writeQtfDefault(Path file, double qtf) {
ChatPreferences preferences = new ChatPreferences(file);
preferences.setStn_qtfDefault(qtf);
assertTrue(preferences.writePreferencesToXmlFile());
}
private ChatPreferences read(Path file) {
ChatPreferences preferences = new ChatPreferences(file);
assertTrue(preferences.readPreferencesFromXmlFile());
return preferences;
}
}
@@ -181,6 +181,8 @@ class ChatPreferencesRoundTripTest {
assertTrue(fromTemplate.getLst_txtShortCutBtnList().snapshot().contains("sked"));
assertTrue(fromTemplate.isStn_bandActive144());
assertFalse(fromTemplate.isStn_bandActive10G());
assertTrue(fromTemplate.isStn_useQtfFilterDirectionAsAntennaHeading());
assertTrue(fromTemplate.isQtfFilterDirectionAsAntennaHeadingEnabled());
}
@Test
@@ -211,6 +213,8 @@ class ChatPreferencesRoundTripTest {
assertEquals("JO31AA", legacy.getStn_loginLocatorMainCat());
assertEquals(3, legacy.getLoginChatCategoryMain().getCategoryNumber());
assertEquals(8123, legacy.getNotify_dxclusterServerPort());
assertTrue(legacy.isStn_useQtfFilterDirectionAsAntennaHeading());
assertTrue(legacy.isQtfFilterDirectionAsAntennaHeadingEnabled());
// A tag that is missing keeps the compiled-in default.
assertEquals(new ChatPreferences(legacyFile).getStn_on4kstServersPort(),
legacy.getStn_on4kstServersPort());
@@ -54,7 +54,7 @@ class ChatPreferencesDebugModeTest {
assertTrue(written.writePreferencesToXmlFile());
String writtenXml = Files.readString(preferencesFile);
assertTrue(writtenXml.contains("<configVersion>8</configVersion>"));
assertTrue(writtenXml.contains("<configVersion>9</configVersion>"));
assertTrue(writtenXml.contains(
"<messageHandling_debugModeToFileEnabled>false"
+ "</messageHandling_debugModeToFileEnabled>"));
@@ -117,7 +117,7 @@ class ChatPreferencesLayoutPersistenceTest {
assertFalse(writtenXml.contains("UNSAVED-CALL"));
assertTrue(writtenXml.contains("<futureExtension mode=\"keep-me\">"));
assertTrue(writtenXml.contains("<futureLayoutValue>untouched</futureLayoutValue>"));
assertTrue(writtenXml.contains("<configVersion>8</configVersion>"));
assertTrue(writtenXml.contains("<configVersion>9</configVersion>"));
assertTrue(writtenXml.contains("<GUIscn_ChatwindowMainSceneSizeHW>812.0;1340.0"));
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
+ "</GUIstationMapClusteringEnabled>"));
@@ -32,7 +32,7 @@ class ChatPreferencesStationMapClusteringTest {
assertTrue(written.writePreferencesToXmlFile());
String writtenXml = Files.readString(preferencesFile);
assertTrue(writtenXml.contains("<configVersion>8</configVersion>"));
assertTrue(writtenXml.contains("<configVersion>9</configVersion>"));
assertTrue(writtenXml.contains("<GUIstationMapClusteringEnabled>false"
+ "</GUIstationMapClusteringEnabled>"));