Fix Compose UI regressions in KST4Contest 1.50 (#147)

* test: make stylesheet tests platform-independent

* fix: address Compose UI regressions

* fix: complete Compose UI workflow corrections

* feat: improve manual QTF heading control
This commit is contained in:
Praktimarc authored and GitHub committed 2026-10-24 22:10:36 +02:00
1 parent 4642d658a3
commit a4e10300e7
114 files changed
+8065 -1128

No files matched your search

@@ -1648,6 +1648,7 @@ private SimpleRoster<String>
* lst_chatMemberList is this very field.
*/
private final SimpleRoster<ChatMember> chatMemberList = new SimpleRoster<>(); // List of active stations
private final SimpleRoster<RecentChatJoin> recentChatJoins = new SimpleRoster<>();
private final SimpleRoster<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = new SimpleRoster<>();
private final SimpleRoster<ClusterMessage> lst_clusterMemberList = new SimpleRoster<>();
@@ -1803,6 +1804,20 @@ private SimpleRoster<String>
* user-enter events.
*/
public void addOrUpdateActiveChatMember(ChatMember member) {
addOrUpdateActiveChatMember(member, System.currentTimeMillis());
}
/**
* Adds or replaces an active member and records a join only for a previously absent
* full callsign/category identity.
*
* @param member member received in a live user-enter frame
* @param joinedAtEpochMillis wall-clock time of that frame
*/
public void addOrUpdateActiveChatMember(
ChatMember member,
long joinedAtEpochMillis
) {
String key = buildActiveChatMemberKey(member);
if (key == null) {
return;
@@ -1810,7 +1825,7 @@ private SimpleRoster<String>
initializeFrequencyFromStationNameIfUnambiguous(member);
activeChatMembersByCallAndCategory.put(key, member);
ChatMember previous = activeChatMembersByCallAndCategory.put(key, member);
runOnFxThread(() -> chatMemberList.mutate(list -> {
int existingIndex = findChatMemberIndexInList(list, key);
@@ -1820,6 +1835,18 @@ private SimpleRoster<String>
list.add(member);
}
}));
if (previous == null) {
int categoryNumber = member.getChatCategory().getCategoryNumber();
recentChatJoins.mutate(joins -> {
boolean alreadyRecorded = joins.stream().anyMatch(join ->
join.matches(member.getCallSign(), categoryNumber));
if (!alreadyRecorded) {
joins.add(0, new RecentChatJoin(
member.getCallSign(), categoryNumber, joinedAtEpochMillis));
}
});
}
}
/**
@@ -1834,6 +1861,9 @@ private SimpleRoster<String>
}
ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key);
int categoryNumber = member.getChatCategory().getCategoryNumber();
recentChatJoins.mutate(joins -> joins.removeIf(join ->
join.matches(member.getCallSign(), categoryNumber)));
runOnFxThread(() -> chatMemberList.mutate(list ->
list.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember)))));
return removedMember != null;
@@ -1845,6 +1875,7 @@ private SimpleRoster<String>
*/
public void clearActiveChatMembers() {
activeChatMembersByCallAndCategory.clear();
recentChatJoins.clear();
runOnFxThread(chatMemberList::clear);
}
@@ -1867,6 +1898,8 @@ private SimpleRoster<String>
}
int categoryNumber = category.getCategoryNumber();
recentChatJoins.mutate(joins -> joins.removeIf(join ->
join.getCategoryNumber() == categoryNumber));
List<ChatMember> safeMembers = completeMembers == null
? List.of() : new ArrayList<>(completeMembers);
Map<String, ChatMember> workedDataFromDatabase =
@@ -1992,8 +2025,8 @@ private SimpleRoster<String>
/**
* Applies a QSO received from an external logger to all active callsign
* variants. Active member objects back JavaFX views, so their state is changed
* only on the JavaFX Application Thread. The band-upgrade hint is evaluated
* variants. Active member objects back UI views, so their state is changed
* only through the configured UI dispatcher. The band-upgrade hint is evaluated
* afterwards and therefore sees the new Worked state.
*
* @param loggedQso validated external QSO
@@ -2199,6 +2232,16 @@ private SimpleRoster<String>
target.setQrv10G(sourceMember.isQrv10G());
}
if (dbHandler != null) {
try {
dbHandler.updateNotQRVInfoOnChatMember(sourceMember);
} catch (SQLException exception) {
LOGGER.log(Level.WARNING,
"Could not persist NOT-QRV state for " + rawCall,
exception);
}
}
if (scoreService != null) {
scoreService.requestRecompute("NOT-QRV state changed");
}
@@ -2743,6 +2786,96 @@ private SimpleRoster<String>
return chatMemberList;
}
/**
* Applies and persists a validated QSO through the same path for external
* logger packets and explicit UI Worked actions.
*
* @param loggedQso validated QSO
* @return true when the QSO was accepted and its station state was persisted
*/
public boolean recordLoggedQso(ExternalLoggedQso loggedQso) {
return loggedQso != null
&& recordLoggedQso(loggedQso, loggedQso.toWorkedChatMember());
}
/**
* Applies and persists a QSO while retaining the supplied database/backup row.
* Logger listeners use this overload so later backup serialization observes the
* same normalization as database persistence.
*
* @param loggedQso validated QSO
* @param workedCall mutable row derived from the QSO
* @return true when the QSO was accepted and its station state was persisted
*/
public boolean recordLoggedQso(ExternalLoggedQso loggedQso, ChatMember workedCall) {
if (loggedQso == null || workedCall == null || dbHandler == null) {
return false;
}
LoggedQsoBand loggedBand = loggedQso.getBand();
if (loggedBand != null
&& loggedBand.getProjectBand() != null
&& loggedQso.getLocator() != null) {
registerWorkedGrossField(
loggedBand.getProjectBand(),
loggedQso.getLocator(),
workedCall,
loggedQso.getSource());
}
applyExternalLoggedQso(loggedQso);
try {
boolean isInChat = dbHandler.updateWkdInfoOnChatMember(workedCall);
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
dbHandler.storeChatMember(workedCall);
}
return true;
} catch (SQLException exception) {
LOGGER.log(Level.WARNING,
"Could not persist Worked state for " + loggedQso.getCallSign(),
exception);
return false;
}
}
/**
* Marks one station and band as worked with contactinfo-equivalent semantics.
* Worked contacts are additive; invoking this method for an already worked band
* is intentionally idempotent.
*
* @param member station selected by the UI
* @param band band to mark as worked
* @return true when a valid contact was accepted and persisted
*/
public boolean markStationWorkedOnBand(ChatMember member, Band band) {
if (member == null || band == null) {
return false;
}
LoggedQsoBand loggedBand = LoggedQsoBand.fromProjectBand(band);
if (loggedBand == null) {
return false;
}
String callSign = member.getCallSign() != null
? member.getCallSign()
: member.getCallSignRaw();
return ExternalLoggedQso.create(callSign, loggedBand, member.getQra(), "MANUAL")
.map(this::recordLoggedQso)
.orElse(false);
}
/** Live joins only; initial and resynchronized user lists never enter this roster. */
public ObservableRoster<RecentChatJoin> getRecentChatJoins() {
return recentChatJoins;
}
public ObservableRoster<ChatMember> getLst_DBBasedWkdCallSignList() {
return lst_DBBasedWkdCallSignList;
}
@@ -2901,7 +3034,7 @@ private SimpleRoster<String>
chatPreferences = new ChatPreferences(preferencesRelativeFileName);
chatPreferences.readPreferencesFromXmlFile();
Path historyFile = Path.of(ApplicationFileUtils.getFilePath(
Path historyFile = Path.of(ApplicationFileUtils.getDebugFilePath(
ApplicationConstants.APPLICATION_NAME,
MessageHistoryRecorder.CURRENT_FILE_NAME));
messageHistoryRecorder = new MessageHistoryRecorder(
@@ -92,6 +92,27 @@ public enum LoggedQsoBand {
};
}
/**
* Resolves a project band for UI actions that must follow the same Worked
* processing as an external logger contact.
*
* @param band project band
* @return normalized logged-QSO band, or {@code null} for a missing band
*/
public static LoggedQsoBand fromProjectBand(Band band) {
if (band == null) {
return null;
}
for (LoggedQsoBand loggedBand : values()) {
if (loggedBand.projectBand == band) {
return loggedBand;
}
}
return null;
}
/**
* Returns the common Band value used by worked-grid persistence.
*
@@ -758,8 +758,15 @@ public class MessageBusManagementThread extends Thread {
// Built only now: the receiver field has always been read after the sender.
InboundChatFrame frame = InboundChatFrame.of(fields);
boolean publicMessage = frame.receiverField().equals("0");
newMessageArrived.setMessageDirectedToCommunity(publicMessage);
newMessageArrived.setMessageDirectedToMe(
publicMessage
&& isPublicMessageExplicitlyAddressedTo(
frame.text(),
prefs.getStn_loginCallSign()));
if (frame.receiverField().equals("0")) {
if (publicMessage) {
// message is not directed to anyone, move it to the cq messages.
newMessageArrived.setReceiver(createAllReceiver());
@@ -778,6 +785,7 @@ public class MessageBusManagementThread extends Thread {
newMessageArrived.getSender().getCallSign(),
newMessageArrived.getReceiver().getCallSign(),
prefs.getStn_loginCallSign());
newMessageArrived.setMessageDirectedToMe(route == ChatRoute.TO_ME);
DirectionalOpportunity opportunity = null;
boolean senderAlreadyInAngleAndRange = false;
@@ -1137,7 +1145,7 @@ public class MessageBusManagementThread extends Thread {
// System.out.println("MSGBUS: User detected");
if (!client.getChatPreferences().getStn_loginCallSign().equals(splittedMessageLine[2])) { //own call ignore
if (!client.getChatPreferences().getStn_loginCallSign().equalsIgnoreCase(splittedMessageLine[2])) { //own call ignore
ChatMember newMember = new ChatMember();
@@ -1156,7 +1164,8 @@ public class MessageBusManagementThread extends Thread {
newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember);
this.client.addOrUpdateActiveChatMember(newMember);
this.client.addOrUpdateActiveChatMember(
newMember, millisClock.getAsLong());
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
this.client.getDbHandler().storeChatMember(newMember);
@@ -1714,6 +1723,33 @@ public class MessageBusManagementThread extends Thread {
return ChatRoute.BETWEEN_OTHERS;
}
/**
* Recognizes the structured address prefix produced by ON4KST for a public
* {@code /cq CALL ...} message.
*
* <p>The address must be the first non-whitespace token and occupy the complete
* parenthesized prefix. A callsign occurring later in ordinary prose is therefore
* not treated as an address.</p>
*/
static boolean isPublicMessageExplicitlyAddressedTo(String messageText, String ownCall) {
if (messageText == null || ownCall == null || ownCall.isBlank()) {
return false;
}
String text = messageText.stripLeading();
if (!text.startsWith("(")) {
return false;
}
int closingParenthesis = text.indexOf(')');
if (closingParenthesis <= 1) {
return false;
}
String addressedCall = text.substring(1, closingParenthesis).trim();
return addressedCall.equalsIgnoreCase(ownCall.trim());
}
/**
* Returns whether the short tick sound for a new directional opportunity is
* played: only with simple sounds enabled and only on the transition into
@@ -774,30 +774,7 @@ public class ReadUDPByWintestThread extends Thread {
}
ChatMember workedCall = loggedQso.toWorkedChatMember();
if (loggedBand != null
&& loggedBand.getProjectBand() != null
&& locatorFromLogger != null) {
this.client.registerWorkedGrossField(
loggedBand.getProjectBand(), locatorFromLogger, workedCall, loggedQso.getSource());
}
client.applyExternalLoggedQso(loggedQso);
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will
// check, if an entry at the db had been modified. If not, then the worked
// station had not been stored. DBHandler will store the information then.
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
client.recordLoggedQso(loggedQso, workedCall);
File logUDPMessageToThisFile = new File(this.client.getChatPreferences()
.getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup());
@@ -3,7 +3,6 @@ package kst4contest.controller;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.Arrays;
import javax.xml.XMLConstants;
@@ -366,118 +365,11 @@ public class ReadUDPbyUCXMessageThread extends Thread {
// client.getChatPreferences().setBcn_contestScoreSum(Long.parseLong(points));
ChatMember workedCall = loggedQso.toWorkedChatMember();
Band workedBand = loggedBand == null ? null : loggedBand.getProjectBand();
if (loggedBand == null && !rawBand.isEmpty()) {
System.out.println("[ReadUDPFromUCX, warning]: unexpected band value: \"" + rawBand + "\"");
}
{
/**
* That means, the station is worked already but maybe at another band. So we
* have to get the worked ChatMember out of the list and to modify the worked
* options.
*/
// modifyThat = (ChatMember) client.getMap_ucxLogInfoWorkedCalls().get(call);
// asd //TODO: Check if callsign and callsignraw is similar, then mark first and further via new checklistforchatmembermultiplemethod with array of indize
client.applyExternalLoggedQso(loggedQso);
/**
* old mechanic to markup worked stations in the chatmember table
*/
// int indexOfChatMemberInTable = -1; //chatmember not in table
// indexOfChatMemberInTable = client.checkListForChatMemberIndexByCallSign(workedCall);
//
// if (indexOfChatMemberInTable == -1) {
// // do nothing
// } else {
// modifyThat = client.getLst_chatMemberList().get(indexOfChatMemberInTable);
//
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)).setWorked(true);
//
// if (workedCall.isWorked144()) {
// modifyThat.setWorked144(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked144(true);
//
// } else if (workedCall.isWorked432()) {
// modifyThat.setWorked432(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked432(true);
//
// } else if (workedCall.isWorked1240()) {
// modifyThat.setWorked1240(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked1240(true);
//
// } else if (workedCall.isWorked2300()) {
// modifyThat.setWorked2300(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked2300(true);
//
// } else if (workedCall.isWorked3400()) {
// modifyThat.setWorked3400(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked3400(true);
//
// } else if (workedCall.isWorked5600()) {
// modifyThat.setWorked5600(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked5600(true);
//
// } else if (workedCall.isWorked10G()) {
// modifyThat.setWorked10G(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked10G(true);
// }
/**
* //TODO: following line is a quick fix to making disappear worked chatmembers of the list
* Thats uncomfortable due to this also causes selection changes,
* Better way is to change all worked and qrv values to observables and then trigger the underlying
* list to fire an invalidationevent. Really Todo!
*/
// try{
//
// GuiUtils.triggerGUIFilteredChatMemberListChange(client); //not clean at all
// } catch (Exception IllegalStateException) {
// //do nothing, as it works...
// }
// }
/**
* end -> old mechanic to markup worked stations in the chatmember table
*/
}
if (workedBand != null && gridSquare != null) {
this.client.registerWorkedGrossField(
workedBand, gridSquare, workedCall, loggedQso.getSource());
}
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will
// check, if an entry at the db had been modified. If not, then the worked
// station had not been stored. DBHandler will store the information then.
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
client.recordLoggedQso(loggedQso, workedCall);
appendToUdpBackupFile(this.client.getChatPreferences()
@@ -594,18 +486,6 @@ public class ReadUDPbyUCXMessageThread extends Thread {
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
} catch (SQLException e) {
// TODO Auto-generated catch block
e.printStackTrace();
threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "CRASHED" + udpMsg, true);
threadStateMessage.setCriticalStateFurtherInfo(Arrays.toString(e.getStackTrace()));
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
// threadStatusMessage = new String[2];
// threadStatusMessage[0] = "STOPPED";
// threadStatusMessage[1] = Arrays.toString(e.getStackTrace());
// callBackToController.onThreadStatus(ThreadNickName,threadStatusMessage);
}
// System.out.println("[ReadUDPbyUCXTh: ] worked size = " + this.client.getMap_ucxLogInfoWorkedCalls().size());
@@ -146,6 +146,22 @@ public final class WorkedGrossFieldCache {
return false;
}
/**
* Returns an immutable snapshot of all gross fields worked on at least one
* band. Map rendering uses the snapshot without holding this cache's monitor.
*
* @return immutable any-band gross-field snapshot
*/
public synchronized Set<String> snapshotWorkedGrossFieldsAny() {
Set<String> snapshot = new HashSet<>();
for (Set<String> workedGrossFields : workedGrossFieldsByBand.values()) {
if (workedGrossFields != null) {
snapshot.addAll(workedGrossFields);
}
}
return Set.copyOf(snapshot);
}
/**
* Tries to normalize a locator. Accepts a plain six-character locator or extracts
* one from exchange strings such as "001JO41HK".
@@ -199,4 +215,4 @@ public final class WorkedGrossFieldCache {
String normalized = grossField.trim().toUpperCase(Locale.ROOT);
return normalized.matches("[A-R]{2}[0-9]{2}") ? normalized : null;
}
}
}
@@ -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;
@@ -425,6 +429,7 @@ public class ChatPreferences {
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
private boolean GUIstationMapPathAnalysisVisible = true;
private boolean GUIstationMapClusteringEnabled = true;
private boolean GUIstationMapAlwaysOnTop = false;
private final Map<String, Double> tableColumnWidths = new LinkedHashMap<>();
private static final String TAG_TABLE_COLUMN_WIDTH = "tableColumnWidth";
@@ -803,6 +808,14 @@ public class ChatPreferences {
this.GUIstationMapClusteringEnabled = GUIstationMapClusteringEnabled;
}
public boolean isGUIstationMapAlwaysOnTop() {
return GUIstationMapAlwaysOnTop;
}
public void setGUIstationMapAlwaysOnTop(boolean GUIstationMapAlwaysOnTop) {
this.GUIstationMapAlwaysOnTop = GUIstationMapAlwaysOnTop;
}
/**
* Returns a stored width for one stable table/leaf-column identity.
*
@@ -1043,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;
}
@@ -1215,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() {
@@ -1733,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);
@@ -2395,6 +2430,12 @@ public class ChatPreferences {
);
guiOptions.appendChild(GUIstationMapClusteringEnabled);
Element GUIstationMapAlwaysOnTop = doc.createElement("GUIstationMapAlwaysOnTop");
GUIstationMapAlwaysOnTop.setTextContent(
String.valueOf(this.isGUIstationMapAlwaysOnTop())
);
guiOptions.appendChild(GUIstationMapAlwaysOnTop);
appendTableColumnWidths(doc, guiOptions);
appendWindowPositions(doc, guiOptions);
@@ -2484,6 +2525,8 @@ public class ChatPreferences {
getGUIstationMapStagePositionXY()[0] + ";" + getGUIstationMapStagePositionXY()[1]);
upsertDirectChildText(document, guiOptions, "GUIstationMapClusteringEnabled",
String.valueOf(isGUIstationMapClusteringEnabled()));
upsertDirectChildText(document, guiOptions, "GUIstationMapAlwaysOnTop",
String.valueOf(isGUIstationMapAlwaysOnTop()));
}
private void appendTableColumnWidths(Document document, Element guiOptions) {
@@ -2611,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,
@@ -2721,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(
@@ -3223,6 +3276,7 @@ public class ChatPreferences {
*
***********************************************/
this.setGUIstationMapClusteringEnabled(true);
this.setGUIstationMapAlwaysOnTop(false);
list = doc.getElementsByTagName("guiOptions");
if (list.getLength() != 0) {
@@ -3272,6 +3326,12 @@ public class ChatPreferences {
"GUIstationMapClusteringEnabled"
));
this.setGUIstationMapAlwaysOnTop(getBooleanOrDefault(
element,
false,
"GUIstationMapAlwaysOnTop"
));
// Splitpane divider positions
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
if (s1 != null) {
@@ -3387,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
@@ -0,0 +1,48 @@
package kst4contest.model;
import java.util.Locale;
import java.util.Objects;
/**
* One live ON4KST join event.
*
* <p>The full callsign and category form the identity. The timestamp belongs to the
* first event while that identity remains active; ordinary user updates must not renew it.</p>
*/
public final class RecentChatJoin {
private final String callSign;
private final int categoryNumber;
private final long joinedAtEpochMillis;
public RecentChatJoin(
String callSign,
int categoryNumber,
long joinedAtEpochMillis
) {
this.callSign = Objects.requireNonNull(callSign, "callSign")
.trim()
.toUpperCase(Locale.ROOT);
this.categoryNumber = categoryNumber;
this.joinedAtEpochMillis = joinedAtEpochMillis;
}
public String getCallSign() {
return callSign;
}
public int getCategoryNumber() {
return categoryNumber;
}
public long getJoinedAtEpochMillis() {
return joinedAtEpochMillis;
}
/** Returns whether the event belongs to the same complete chat identity. */
public boolean matches(String otherCallSign, int otherCategoryNumber) {
return otherCallSign != null
&& categoryNumber == otherCategoryNumber
&& callSign.equalsIgnoreCase(otherCallSign.trim());
}
}
@@ -1,15 +1,19 @@
package kst4contest.utils;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.FileHandler;
import java.util.logging.ErrorManager;
import java.util.logging.Formatter;
import java.util.logging.Handler;
import java.util.logging.Level;
@@ -20,6 +24,10 @@ import java.util.logging.Logger;
public final class ApplicationFileLogging implements AutoCloseable {
/** Logger namespace owned by the application. */
private static final String APP_LOGGER_NAME = "kst4contest";
/** Maximum size of the active error log before it is archived. */
private static final long DEFAULT_SIZE_LIMIT_BYTES = 20L * 1024L * 1024L;
/** Active error log plus four archived generations. */
private static final int DEFAULT_FILE_COUNT = 5;
/** Runtime switch for additional application diagnostics. */
private final AtomicBoolean debugEnabled = new AtomicBoolean();
@@ -44,6 +52,28 @@ public final class ApplicationFileLogging implements AutoCloseable {
final boolean debugEnabled
)
throws IOException {
this(logFile, debugEnabled,
DEFAULT_SIZE_LIMIT_BYTES, DEFAULT_FILE_COUNT);
}
/** Test seam for exercising rotation without writing multi-megabyte files. */
ApplicationFileLogging(
final Path logFile,
final boolean debugEnabled,
final long sizeLimitBytes,
final int fileCount
) throws IOException {
this(logFile, debugEnabled, sizeLimitBytes, fileCount, ignored -> { });
}
/** Test seam for a transient file-system error after the active stream closes. */
ApplicationFileLogging(
final Path logFile,
final boolean debugEnabled,
final long sizeLimitBytes,
final int fileCount,
final RotationFaultInjector rotationFaultInjector
) throws IOException {
final Path parent = logFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
@@ -51,15 +81,175 @@ public final class ApplicationFileLogging implements AutoCloseable {
this.debugEnabled.set(debugEnabled);
previousAppLevel = appLogger.getLevel();
appLogger.setLevel(Level.FINE);
final FileHandler fileHandler =
new FileHandler(logFile.toString(), true);
final Handler fileHandler = new BoundedRotatingFileHandler(
logFile, sizeLimitBytes, fileCount, rotationFaultInjector);
fileHandler.setLevel(Level.ALL);
fileHandler.setFilter(this::isLoggable);
fileHandler.setFormatter(new UtcLogFormatter());
try {
appLogger.setLevel(Level.FINE);
rootLogger.addHandler(fileHandler);
} catch (RuntimeException exception) {
fileHandler.close();
try {
appLogger.setLevel(previousAppLevel);
} catch (RuntimeException restoreFailure) {
exception.addSuppressed(restoreFailure);
}
throw exception;
}
handler = fileHandler;
rootLogger.addHandler(handler);
}
/** Injects a test-only failure at the point where archive operations begin. */
@FunctionalInterface
interface RotationFaultInjector {
void beforeArchive(Path logFile) throws IOException;
}
/** Keeps the active canonical file name while retaining bounded generations. */
private static final class BoundedRotatingFileHandler extends Handler {
private final Path logFile;
private final long sizeLimitBytes;
private final int fileCount;
private final RotationFaultInjector rotationFaultInjector;
private OutputStream output;
private long currentSize;
private BoundedRotatingFileHandler(
final Path logFile,
final long sizeLimitBytes,
final int fileCount,
final RotationFaultInjector rotationFaultInjector
) throws IOException {
if (sizeLimitBytes <= 0L) {
throw new IllegalArgumentException("sizeLimitBytes must be positive");
}
if (fileCount <= 0) {
throw new IllegalArgumentException("fileCount must be positive");
}
this.logFile = logFile;
this.sizeLimitBytes = sizeLimitBytes;
this.fileCount = fileCount;
this.rotationFaultInjector = rotationFaultInjector;
open(true);
}
@Override
public synchronized void publish(final LogRecord record) {
if (!isLoggable(record)) {
return;
}
final byte[] encoded = getFormatter().format(record)
.getBytes(StandardCharsets.UTF_8);
try {
if (output == null) {
open(true);
}
if (currentSize > 0L
&& currentSize + encoded.length > sizeLimitBytes) {
try {
rotate();
} catch (IOException | RuntimeException exception) {
reportError("Could not rotate " + logFile,
exception, ErrorManager.WRITE_FAILURE);
if (output == null) {
return;
}
}
}
output.write(encoded);
output.flush();
currentSize += encoded.length;
} catch (IOException | RuntimeException exception) {
reportError("Could not write or rotate " + logFile,
exception, ErrorManager.WRITE_FAILURE);
}
}
@Override
public synchronized void flush() {
if (output == null) {
return;
}
try {
output.flush();
} catch (IOException exception) {
reportError("Could not flush " + logFile,
exception, ErrorManager.FLUSH_FAILURE);
}
}
@Override
public synchronized void close() {
closeOutput();
}
private void rotate() throws IOException {
closeOutput();
try {
rotationFaultInjector.beforeArchive(logFile);
if (fileCount > 1) {
Files.deleteIfExists(generation(fileCount - 1));
for (int generation = fileCount - 2;
generation >= 1;
generation--) {
Path source = generation(generation);
if (Files.exists(source)) {
Files.move(source, generation(generation + 1),
StandardCopyOption.REPLACE_EXISTING);
}
}
if (Files.exists(logFile)) {
Files.move(logFile, generation(1),
StandardCopyOption.REPLACE_EXISTING);
}
}
open(false);
} catch (IOException | RuntimeException exception) {
try {
open(true);
} catch (IOException | RuntimeException recoveryFailure) {
exception.addSuppressed(recoveryFailure);
}
throw exception;
}
}
private Path generation(final int generation) {
return logFile.resolveSibling(
logFile.getFileName() + "." + generation);
}
private void open(final boolean append) throws IOException {
output = append
? Files.newOutputStream(logFile,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.APPEND)
: Files.newOutputStream(logFile,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING);
currentSize = Files.size(logFile);
}
private void closeOutput() {
if (output == null) {
return;
}
try {
output.close();
} catch (IOException exception) {
reportError("Could not close " + logFile,
exception, ErrorManager.CLOSE_FAILURE);
} finally {
output = null;
}
}
}
/** Applies the runtime debug preference to subsequent file log records. */
@@ -29,6 +29,21 @@ public class ApplicationFileUtils {
fileName).toString();
}
/**
* Gets the path of a diagnostic file below the application's debug directory.
*
* @param applicationName application directory name
* @param fileName diagnostic file name
* @return full path below {@code .applicationName/debug}
*/
public static String getDebugFilePath(
final String applicationName,
final String fileName
) {
return Path.of(getFilePath(applicationName, "debug"), fileName)
.toString();
}
/**
* Copies a resource to the application folder if required.
* @param applicationName Name of the application.
@@ -60,10 +60,30 @@ public record MapCallsignRawSnapshot(
}
public String markerLabel() {
String baseLabel = bandSummary.isBlank()
? displayCallSign
: displayCallSign + " (" + bandSummary + ")";
return offersSelectedBand ? baseLabel + " B+" : baseLabel;
return displayCallSign;
}
/**
* Returns the details removed from the visible marker label for its hover text.
*
* @return multiline callsign, locator, band and frequency summary
*/
public String tooltipText() {
StringJoiner joiner = new StringJoiner("\n");
joiner.add(displayCallSign);
if (!locator6.isBlank()) {
joiner.add("Locator: " + locator6);
}
if (!bandSummary.isBlank()) {
joiner.add("Bands: " + bandSummary);
}
if (offersSelectedBand) {
joiner.add("B+");
}
if (!lastKnownFrequenciesByBand.isEmpty()) {
joiner.add(detailFrequencyText());
}
return joiner.toString();
}
public String detailFrequencyText() {
@@ -77,4 +97,4 @@ public record MapCallsignRawSnapshot(
}
return joiner.toString();
}
}
}
@@ -26,7 +26,7 @@ public final class MapMarkerClusterTooltip {
public static String build(MapMarkerCluster cluster) {
List<String> callsigns = new ArrayList<>();
for (MapCallsignRawSnapshot marker : cluster.getMarkers()) {
String label = marker.markerLabel();
String label = marker.tooltipText().replace('\n', ' ');
if (label != null && !label.isBlank()) {
callsigns.add(label.trim());
}
@@ -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,40 @@
package kst4contest.controller;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class ChatControllerNotQrvPersistenceTest {
@Test
void propagatedNotQrvStateIsPersistedForTheSharedRawCall() throws SQLException {
DBController database = mock(DBController.class);
ChatController controller = new ChatController();
controller.setDbHandler(database);
ChatMember selected = member("9A0BB-2", 2);
ChatMember second = member("9A0BB-70", 3);
controller.addOrUpdateActiveChatMember(selected);
controller.addOrUpdateActiveChatMember(second);
selected.setQrv432(false);
controller.propagateNotQrvStateToActiveMembers(selected);
assertFalse(second.isQrv432());
verify(database).updateNotQRVInfoOnChatMember(argThat(member ->
"9A0BB".equals(member.getCallSignRaw()) && !member.isQrv432()));
}
private static ChatMember member(String callSign, int category) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(category));
return member;
}
}
@@ -0,0 +1,72 @@
package kst4contest.controller;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.RecentChatJoin;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatControllerRecentJoinTest {
@Test
void liveJoinIsRecordedOnceWithoutRenewingTheTimestampOnDuplicateUpdates() {
ChatController controller = new ChatController();
controller.addOrUpdateActiveChatMember(member("HB9SJV", 2), 1_000L);
controller.addOrUpdateActiveChatMember(member("HB9SJV", 2), 9_000L);
List<RecentChatJoin> joins = controller.getRecentChatJoins().snapshot();
assertEquals(1, joins.size());
assertEquals("HB9SJV", joins.get(0).getCallSign());
assertEquals(2, joins.get(0).getCategoryNumber());
assertEquals(1_000L, joins.get(0).getJoinedAtEpochMillis());
}
@Test
void theSameCallsignInTwoCategoriesRemainsTwoJoinIdentities() {
ChatController controller = new ChatController();
controller.addOrUpdateActiveChatMember(member("DL1KDA", 2), 1_000L);
controller.addOrUpdateActiveChatMember(member("DL1KDA", 3), 2_000L);
List<RecentChatJoin> joins = controller.getRecentChatJoins().snapshot();
assertEquals(2, joins.size());
assertEquals(List.of(3, 2), joins.stream().map(RecentChatJoin::getCategoryNumber).toList());
}
@Test
void leavingRemovesTheJoinAndARealRejoinStartsAFreshTimestamp() {
ChatController controller = new ChatController();
ChatMember member = member("OE5SOS", 2);
controller.addOrUpdateActiveChatMember(member, 1_000L);
controller.removeActiveChatMember(member);
assertTrue(controller.getRecentChatJoins().isEmpty());
controller.addOrUpdateActiveChatMember(member("OE5SOS", 2), 7_000L);
assertEquals(7_000L,
controller.getRecentChatJoins().snapshot().get(0).getJoinedAtEpochMillis());
}
@Test
void clearingTheActiveRosterAlsoClearsTransientJoinEvents() {
ChatController controller = new ChatController();
controller.addOrUpdateActiveChatMember(member("PA2M", 2), 1_000L);
controller.clearActiveChatMembers();
assertTrue(controller.getRecentChatJoins().isEmpty());
}
private static ChatMember member(String callSign, int categoryNumber) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(categoryNumber));
return member;
}
}
@@ -0,0 +1,66 @@
package kst4contest.controller;
import kst4contest.model.Band;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ChatControllerWorkedActionTest {
@Test
void manualWorkedActionUsesContactInfoPersistenceAndUpdatesEveryActiveVariant()
throws SQLException {
DBController database = mock(DBController.class);
when(database.updateWkdInfoOnChatMember(argThat(member -> member != null)))
.thenReturn(true);
ChatController controller = new ChatController();
controller.setDbHandler(database);
ChatMember selected = member("9A0BB-2", 2, "JN75AA");
ChatMember secondCategory = member("9A0BB-70", 3, "JN75AA");
controller.addOrUpdateActiveChatMember(selected);
controller.addOrUpdateActiveChatMember(secondCategory);
assertTrue(controller.markStationWorkedOnBand(selected, Band.B_432));
assertTrue(selected.isWorked());
assertTrue(selected.isWorked432());
assertTrue(secondCategory.isWorked());
assertTrue(secondCategory.isWorked432());
assertTrue(controller.getWorkedGrossFieldCache()
.isGrossFieldWorked(Band.B_432, "JN75"));
verify(database).updateWkdInfoOnChatMember(argThat(member ->
"9A0BB".equals(member.getCallSignRaw())
&& member.isWorked()
&& member.isWorked432()
&& "JN75AA".equals(member.getQra())));
verify(database).upsertWorkedGrossField(
Band.B_432, "JN75AA", "9A0BB", "MANUAL");
}
@Test
void manualWorkedActionRejectsMissingStationOrBandWithoutChangingState() {
ChatController controller = new ChatController();
ChatMember member = member("DL1ABC", 2, "JO50AA");
assertFalse(controller.markStationWorkedOnBand(null, Band.B_144));
assertFalse(controller.markStationWorkedOnBand(member, null));
assertFalse(member.isWorked());
}
private static ChatMember member(String callSign, int category, String locator) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(category));
member.setQra(locator);
return member;
}
}
@@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.stream.Stream;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -37,6 +38,15 @@ class ExternalLoggedQsoTest {
assertNull(LoggedQsoBand.fromWinTestBandId("99"));
}
@Test
void resolvesEveryProjectBandForManualWorkedActions() {
for (Band band : Band.values()) {
LoggedQsoBand loggedBand = LoggedQsoBand.fromProjectBand(band);
assertEquals(band, loggedBand.getProjectBand());
}
}
@Test
void missingCallsignIsRejectedBeforeAnyStateIsBuilt() {
assertTrue(ExternalLoggedQso.create(null, LoggedQsoBand.BAND_144, "JO50AA", "TEST").isEmpty());
@@ -41,7 +41,7 @@ class ExternalLoggerListenerValidationTest {
assertDoesNotThrow(() -> listener.processUCXUDPMessage(packet));
verify(controller, never()).applyExternalLoggedQso(any());
verify(controller, never()).recordLoggedQso(any(), any());
verify(controller, never()).getDbHandler();
}
@@ -55,7 +55,7 @@ class ExternalLoggerListenerValidationTest {
assertDoesNotThrow(() -> listener.processWinTestMessage(packet));
verify(controller, never()).applyExternalLoggedQso(any());
verify(controller, never()).recordLoggedQso(any(), any());
verify(controller, never()).getDbHandler();
}
}
@@ -63,6 +63,7 @@ class MessageBusManagementThreadChatFrameTest {
private static final String RECEIVER_LOCATOR = "JO70LL";
private static final double MAX_QRB_KM = 900.0;
private static final double BEAM_WIDTH_DEG = 50.0;
private static final long FIXED_NOW_MILLIS = 1_700_000_123_456L;
@TempDir
Path tempDir;
@@ -102,6 +103,10 @@ class MessageBusManagementThreadChatFrameTest {
.thenReturn(mock(StationMetricsService.class));
when(controller.getScoreService()).thenReturn(mock(ScoreService.class));
when(controller.getDxClusterServer()).thenReturn(dxClusterServer);
DBController database = mock(DBController.class);
when(controller.getDbHandler()).thenReturn(database);
when(database.fetchChatMemberWkdDataForOnlyOneCallsignFromDB(any(ChatMember.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(controller.findActiveChatMember(any(ChatMember.class)))
.thenAnswer(invocation -> activeMemberFor(invocation.getArgument(0)));
}
@@ -146,6 +151,95 @@ class MessageBusManagementThreadChatFrameTest {
verifyNoInteractions(playAudioUtils);
}
@Test
@Timeout(10)
void genuinePrivateMessageCarriesTheAuthoritativeRouteFlags() throws InterruptedException {
runThrough(chatFrame("CH", REMOTE_CALL, "pse ur qrg", MY_CALL));
ChatMessage published = singlePublished();
assertEquals(MY_CALL, published.getReceiver().getCallSign());
assertTrue(published.isMessageDirectedToMe());
assertFalse(published.isMessageDirectedToCommunity());
assertEquals(ChatCategory.VUHF, published.getChatCategory().getCategoryNumber());
}
@Test
@Timeout(10)
void publicMessageWithLeadingAddressIsMarkedAsExplicitlyAddressedToMe()
throws InterruptedException {
runThrough(chatFrame("CH", REMOTE_CALL, "(" + MY_CALL + ") pse ur qrg", "0"));
ChatMessage published = singlePublished();
assertEquals("ALL", published.getReceiver().getCallSign());
assertTrue(published.isMessageDirectedToCommunity());
assertTrue(published.isMessageDirectedToMe());
}
@Test
@Timeout(10)
void arbitraryPublicMentionIsNotMarkedAsExplicitlyAddressedToMe()
throws InterruptedException {
runThrough(chatFrame("CH", REMOTE_CALL, "ask " + MY_CALL + " about the sked", "0"));
ChatMessage published = singlePublished();
assertTrue(published.isMessageDirectedToCommunity());
assertFalse(published.isMessageDirectedToMe());
}
@Test
@Timeout(10)
void liveUserEntryCarriesTheInjectedJoinTimestampToTheController()
throws InterruptedException {
runThrough(message("UA5|2|HB9SJV|Contest team|JN47AA|3|"));
ArgumentCaptor<ChatMember> member = ArgumentCaptor.forClass(ChatMember.class);
verify(controller).addOrUpdateActiveChatMember(member.capture(),
org.mockito.ArgumentMatchers.eq(FIXED_NOW_MILLIS));
assertEquals("HB9SJV", member.getValue().getCallSign());
assertEquals(ChatCategory.VUHF,
member.getValue().getChatCategory().getCategoryNumber());
}
@Test
@Timeout(10)
void alternateLiveUserEntryCarriesTheInjectedJoinTimestampToTheController()
throws InterruptedException {
runThrough(message("UA2|3|OE5SOS|Microwave team|JN78AA|3|"));
ArgumentCaptor<ChatMember> member = ArgumentCaptor.forClass(ChatMember.class);
verify(controller).addOrUpdateActiveChatMember(member.capture(),
org.mockito.ArgumentMatchers.eq(FIXED_NOW_MILLIS));
assertEquals("OE5SOS", member.getValue().getCallSign());
assertEquals(ChatCategory.MICROWAVE,
member.getValue().getChatCategory().getCategoryNumber());
}
@Test
@Timeout(10)
void initialUserListAndItsTerminatorDoNotCreateLiveJoinEvents()
throws InterruptedException {
runThrough(
message("UA0|2|HB9SJV|Contest team|JN47AA|3|"),
message("UE|2|1|"));
verify(controller, never()).addOrUpdateActiveChatMember(
any(ChatMember.class), org.mockito.ArgumentMatchers.anyLong());
verify(controller).stageInitialOn4KstChatMember(
org.mockito.ArgumentMatchers.eq(7L), any(ChatMember.class));
verify(controller).onOn4KstInitialUserListCompleted(
org.mockito.ArgumentMatchers.eq(7L), any(ChatCategory.class));
}
@Test
@Timeout(10)
void ownCallIsExcludedFromLiveJoinEventsIgnoringCase()
throws InterruptedException {
runThrough(message("UA5|2|dn9apw|Own station|JO50LL|3|"));
verify(controller, never()).addOrUpdateActiveChatMember(
any(ChatMember.class), org.mockito.ArgumentMatchers.anyLong());
}
// --- sent by me ---------------------------------------------------------
@Test
@@ -382,7 +476,8 @@ class MessageBusManagementThreadChatFrameTest {
mock(ThreadStatusCallback.class),
7L,
rxQueue,
ignored -> true);
ignored -> true,
() -> FIXED_NOW_MILLIS);
for (ChatMessage frame : frames) {
rxQueue.add(frame);
}
@@ -146,6 +146,25 @@ class MessageHistoryRecorderTest {
});
}
@Test
void dedicatedDebugDirectoryIsCreatedWithoutMovingLegacyHistory()
throws Exception {
Path legacyHistory = historyFile();
Files.writeString(legacyHistory, "legacy history\n");
Path debugDirectory = temporaryDirectory.resolve("debug");
try (MessageHistoryRecorder recorder = new MessageHistoryRecorder(
debugDirectory,
true,
Clock.fixed(FIXED_INSTANT, ZoneOffset.UTC))) {
recorder.recordRx("OK|");
}
assertEquals("legacy history\n", Files.readString(legacyHistory));
assertTrue(Files.isRegularFile(
debugDirectory.resolve("Messagehistory.raw")));
}
private MessageHistoryRecorder recorder(boolean enabled) {
return new MessageHistoryRecorder(
temporaryDirectory,
@@ -25,7 +25,6 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
@@ -145,12 +144,12 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
}
/**
* A UCXLog QSO packet marks the station worked, registers the gross field
* and stores an unknown station in the database and the UDP backup file.
* A UCXLog QSO packet delegates the complete contact to the shared controller
* action and writes the derived row to the UDP backup file.
* Bytes before the XML declaration are transport framing and are dropped.
*/
@Test
void contactInfoPacketMarksTheStationWorkedAndStoresIt(@TempDir Path tempDir) throws Exception {
void contactInfoPacketUsesSharedWorkedActionAndWritesBackup(@TempDir Path tempDir) throws Exception {
Path backupFile = tempDir.resolve("udp-backup.txt");
DBController database = givenQsoProcessingEnabled(backupFile);
when(database.updateWkdInfoOnChatMember(any())).thenReturn(false);
@@ -164,24 +163,13 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
+ "</contactinfo>");
ArgumentCaptor<ExternalLoggedQso> qso = ArgumentCaptor.forClass(ExternalLoggedQso.class);
verify(controller).applyExternalLoggedQso(qso.capture());
verify(controller).recordLoggedQso(qso.capture(), any(ChatMember.class));
assertEquals("OK1ABC", qso.getValue().getCallSign());
assertEquals(LoggedQsoBand.BAND_144, qso.getValue().getBand());
assertEquals("JO70FD", qso.getValue().getLocator());
assertEquals("UCXLOG", qso.getValue().getSource());
verify(controller).registerWorkedGrossField(
eq(Band.B_144), eq("JO70FD"), any(ChatMember.class), eq("UCXLOG"));
ArgumentCaptor<ChatMember> stored = ArgumentCaptor.forClass(ChatMember.class);
verify(database).storeChatMember(stored.capture());
assertEquals("OK1ABC", stored.getValue().getCallSign());
assertEquals("unknown", stored.getValue().getName());
assertEquals("JO70FD", stored.getValue().getQra());
assertTrue(stored.getValue().isWorked());
assertTrue(stored.getValue().isWorked144());
String backup = Files.readString(backupFile, StandardCharsets.UTF_8);
assertTrue(backup.startsWith("\nOK1ABC;unknown;JO70FD;"), backup);
assertTrue(backup.startsWith("\nOK1ABC;null;JO70FD;"), backup);
}
/**
@@ -198,10 +186,10 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
+ "<contactreplace><app>DXLog</app><band>432</band><call>SP2XYZ</call>"
+ "<rcvnr>042JO93AB</rcvnr></contactreplace>");
verify(controller).registerWorkedGrossField(
eq(Band.B_432), eq("JO93AB"), any(ChatMember.class), eq("UCXLOG"));
verify(database).updateWkdInfoOnChatMember(any());
verify(database, never()).storeChatMember(any());
ArgumentCaptor<ExternalLoggedQso> qso = ArgumentCaptor.forClass(ExternalLoggedQso.class);
verify(controller).recordLoggedQso(qso.capture(), any(ChatMember.class));
assertEquals(LoggedQsoBand.BAND_432, qso.getValue().getBand());
assertEquals("JO93AB", qso.getValue().getLocator());
}
@Test
@@ -211,7 +199,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call></contactinfo>");
verify(controller, never()).applyExternalLoggedQso(any());
verify(controller, never()).recordLoggedQso(any(), any());
verify(database, never()).updateWkdInfoOnChatMember(any());
}
@@ -229,8 +217,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call>"
+ "<gridsquare>JO70FD</gridsquare></contactinfo>");
verify(controller).applyExternalLoggedQso(any());
verify(database).storeChatMember(any());
verify(controller).recordLoggedQso(any(), any());
}
/** A RadioInfo without Freq used to end the listener with a NullPointerException. */
@@ -250,7 +237,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
@Test
void runtimeExceptionWhileProcessingDropsOnlyThatPacket(@TempDir Path tempDir) throws Exception {
givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt"));
doThrow(new IllegalStateException("boom")).when(controller).applyExternalLoggedQso(any());
doThrow(new IllegalStateException("boom")).when(controller).recordLoggedQso(any(), any());
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call></contactinfo>");
@@ -297,7 +284,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
@Test
void failingStatusCallbackDoesNotEndTheListener(@TempDir Path tempDir) throws Exception {
givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt"));
doThrow(new IllegalStateException("boom")).when(controller).applyExternalLoggedQso(any());
doThrow(new IllegalStateException("boom")).when(controller).recordLoggedQso(any(), any());
statusCallbackThrows = true;
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call></contactinfo>");
@@ -0,0 +1,25 @@
package kst4contest.controller;
import kst4contest.model.Band;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class WorkedGrossFieldCacheTest {
@Test
void anyBandSnapshotContainsUniqueNormalizedGrossFieldsAndIsImmutable() {
WorkedGrossFieldCache cache = new WorkedGrossFieldCache();
cache.addWorked(Band.B_144, "jo51aa");
cache.addWorked(Band.B_432, "JO51BB");
cache.addWorked(Band.B_1296, "JN49AA");
Set<String> snapshot = cache.snapshotWorkedGrossFieldsAny();
assertEquals(Set.of("JO51", "JN49"), snapshot);
assertThrows(UnsupportedOperationException.class, () -> snapshot.add("JO50"));
}
}
@@ -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>"));
@@ -106,6 +106,8 @@ class ChatPreferencesLayoutPersistenceTest {
preferences.getGUIscn_ChatwindowMainSceneSizeHW()[0] = 812;
preferences.getGUIscn_ChatwindowMainSceneSizeHW()[1] = 1340;
preferences.setGUIstationMapClusteringEnabled(false);
preferences.setGUIselectedCallSignSplitPane_dividerposition(
new double[] {0.68});
preferences.setTableColumnWidth("qso-other-monitor", "call-tx", 123.75);
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
@@ -115,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>"));
@@ -124,6 +126,8 @@ class ChatPreferencesLayoutPersistenceTest {
assertTrue(restored.readPreferencesFromXmlFile());
assertEquals("SAVED-CALL", restored.getStn_loginCallSign());
assertFalse(restored.isGUIstationMapClusteringEnabled());
assertArrayEquals(new double[] {0.68},
restored.getGUIselectedCallSignSplitPane_dividerposition(), 0.0001);
assertEquals(123.75,
restored.getTableColumnWidth("qso-other-monitor", "call-tx").orElseThrow());
}
@@ -0,0 +1,82 @@
package kst4contest.test;
import kst4contest.model.ChatPreferences;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatPreferencesStationMapAlwaysOnTopTest {
@TempDir
Path temporaryDirectory;
@Test
void alwaysOnTopIsDisabledByDefault() {
assertFalse(new ChatPreferences(temporaryDirectory.resolve("unused.xml"))
.isGUIstationMapAlwaysOnTop());
}
@Test
void enabledAlwaysOnTopSurvivesFullRoundTrip() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
ChatPreferences written = new ChatPreferences(preferencesFile);
written.setGUIstationMapAlwaysOnTop(true);
assertTrue(written.writePreferencesToXmlFile());
assertTrue(Files.readString(preferencesFile).contains(
"<GUIstationMapAlwaysOnTop>true</GUIstationMapAlwaysOnTop>"));
ChatPreferences restored = new ChatPreferences(preferencesFile);
assertTrue(restored.readPreferencesFromXmlFile());
assertTrue(restored.isGUIstationMapAlwaysOnTop());
}
@Test
void missingOrMalformedValueKeepsAlwaysOnTopDisabled() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("legacy.xml");
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>8</configVersion>
<guiOptions>
<GUIstationMapAlwaysOnTop>sometimes</GUIstationMapAlwaysOnTop>
</guiOptions>
</praktiKST>
""");
ChatPreferences restored = new ChatPreferences(preferencesFile);
restored.setGUIstationMapAlwaysOnTop(true);
assertTrue(restored.readPreferencesFromXmlFile());
assertFalse(restored.isGUIstationMapAlwaysOnTop());
}
@Test
void selectiveLayoutWritePersistsAlwaysOnTopWithoutFunctionalSettings() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("selective.xml");
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>8</configVersion>
<station><LoginCallSign>SAVED-CALL</LoginCallSign></station>
<guiOptions/>
</praktiKST>
""");
ChatPreferences preferences = new ChatPreferences(preferencesFile);
assertTrue(preferences.readPreferencesFromXmlFile());
preferences.setStn_loginCallSign("UNSAVED-CALL");
preferences.setGUIstationMapAlwaysOnTop(true);
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
String xml = Files.readString(preferencesFile);
assertTrue(xml.contains("<LoginCallSign>SAVED-CALL</LoginCallSign>"));
assertFalse(xml.contains("UNSAVED-CALL"));
assertTrue(xml.contains("<GUIstationMapAlwaysOnTop>true</GUIstationMapAlwaysOnTop>"));
}
}
@@ -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>"));
@@ -1,11 +1,15 @@
package kst4contest.utils;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -47,4 +51,87 @@ class ApplicationFileLoggingTest {
assertTrue(log.contains("error while enabled"));
assertTrue(log.contains("Z\tWARNING\t"));
}
@Test
void createsTheDebugDirectoryOnDemand() throws Exception {
Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log");
Logger logger = Logger.getLogger(
"kst4contest.test.filelogging." + UUID.randomUUID());
try (ApplicationFileLogging logging =
new ApplicationFileLogging(logFile, false)) {
logger.warning("directory test");
}
assertTrue(Files.isRegularFile(logFile));
}
@Test
void rotatesAtTheConfiguredLimitAndKeepsFiveFiles() throws Exception {
Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log");
Logger logger = Logger.getLogger(
"kst4contest.test.filelogging." + UUID.randomUUID());
try (ApplicationFileLogging logging =
new ApplicationFileLogging(logFile, true, 320L, 5)) {
for (int index = 0; index < 40; index++) {
logger.info("rotation entry " + index + " " + "x".repeat(120));
}
}
assertTrue(Files.isRegularFile(logFile));
for (int generation = 1; generation < 5; generation++) {
assertTrue(Files.isRegularFile(
logFile.resolveSibling(logFile.getFileName() + "." + generation)));
}
try (var files = Files.list(logFile.getParent())) {
assertEquals(5L, files.filter(path -> path.getFileName().toString()
.startsWith("kst4contest-errors.log")).count());
}
}
@Test
void anUnusableLogFileIsReportedWithoutChangingTheLoggerLevel() throws Exception {
Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log");
Files.createDirectories(logFile);
Logger appLogger = Logger.getLogger("kst4contest");
Level previousLevel = appLogger.getLevel();
appLogger.setLevel(Level.SEVERE);
try {
assertThrows(IOException.class, () -> new ApplicationFileLogging(
logFile, false));
assertEquals(Level.SEVERE, appLogger.getLevel());
} finally {
appLogger.setLevel(previousLevel);
}
}
@Test
void continuesWritingAfterATransientRotationFailure() throws Exception {
Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log");
Logger logger = Logger.getLogger(
"kst4contest.test.filelogging." + UUID.randomUUID());
AtomicBoolean failNextRotation = new AtomicBoolean(true);
try (ApplicationFileLogging logging = new ApplicationFileLogging(
logFile, true, 320L, 5,
ignored -> {
if (failNextRotation.getAndSet(false)) {
throw new IOException("simulated transient rotation failure");
}
})) {
logger.info("before rotation " + "x".repeat(180));
logger.info("written despite failed rotation " + "x".repeat(180));
logger.info("written after rotation recovery " + "x".repeat(180));
}
Path firstGeneration = logFile.resolveSibling(
logFile.getFileName() + ".1");
assertTrue(Files.readString(firstGeneration)
.contains("written despite failed rotation"));
assertTrue(Files.readString(logFile)
.contains("written after rotation recovery"));
}
}
@@ -0,0 +1,19 @@
package kst4contest.utils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class ApplicationFileUtilsTest {
@Test
void debugFilesResolveBelowTheDedicatedApplicationDirectory() {
assertEquals(
Path.of(ApplicationFileUtils.getFilePath("praktiKST", "debug"),
"kst4contest-errors.log").toString(),
ApplicationFileUtils.getDebugFilePath(
"praktiKST", "kst4contest-errors.log"));
}
}
@@ -0,0 +1,37 @@
package kst4contest.view.map;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MapCallsignRawSnapshotLabelTest {
@Test
void visibleMarkerLabelContainsOnlyTheCallsign() {
MapCallsignRawSnapshot snapshot = snapshot();
assertEquals("DM5M", snapshot.markerLabel());
}
@Test
void tooltipRetainsLocatorBandsUpgradeAndFrequencyDetails() {
String tooltip = snapshot().tooltipText();
assertTrue(tooltip.contains("DM5M"));
assertTrue(tooltip.contains("JO51AA"));
assertTrue(tooltip.contains("144, 432"));
assertTrue(tooltip.contains("B+"));
assertTrue(tooltip.contains("144: 144.310"));
}
private MapCallsignRawSnapshot snapshot() {
return new MapCallsignRawSnapshot(
"DM5M", "DM5M", "JO51AA", 51.0, 10.0,
"144, 432", Map.of("144", "144.310"), true,
false, false, false, 100.0, 180.0, 0, 0L
);
}
}
@@ -51,9 +51,10 @@ class MapMarkerClusterTooltipTest {
List<String> lines = List.of(MapMarkerClusterTooltip.build(clusterOf(3)).split("\\n"));
assertEquals("3 stations", lines.get(0));
assertEquals("DL000AA", lines.get(1));
assertEquals("DL001AA", lines.get(2));
assertEquals("DL002AA", lines.get(3));
assertTrue(lines.get(1).startsWith("DL000AA"));
assertTrue(lines.get(2).startsWith("DL001AA"));
assertTrue(lines.get(3).startsWith("DL002AA"));
assertTrue(lines.get(1).contains("JO50JP"));
}
/** A hundred callsigns is a box taller than the window. */