mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-25 15:45:58 +01:00
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:
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();
|
||||
}
|
||||
}
|
||||
+96
-1
@@ -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,
|
||||
|
||||
+13
-26
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user